spiceflow 1.0.2 → 1.0.3

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.
@@ -8,6 +8,7 @@ import type {
8
8
  Static,
9
9
  StaticDecode,
10
10
  TAnySchema,
11
+ TObject,
11
12
  TSchema,
12
13
  } from '@sinclair/typebox'
13
14
  import type { TypeCheck, ValueError } from '@sinclair/typebox/compiler'
@@ -20,12 +21,11 @@ import type { Context, ErrorContext, PreContext } from './context'
20
21
  import {
21
22
  ELYSIA_RESPONSE,
22
23
  InternalServerError,
23
- InvalidCookieSignature,
24
24
  NotFoundError,
25
25
  ParseError,
26
26
  ValidationError,
27
27
  } from './error'
28
- import { ZodTypeAny } from 'zod'
28
+ import { ZodTypeAny, ZodObject } from 'zod'
29
29
 
30
30
  export type MaybeArray<T> = T | T[]
31
31
  export type MaybePromise<T> = T | Promise<T>
@@ -174,10 +174,10 @@ export interface MetadataBase {
174
174
 
175
175
  export interface RouteSchema {
176
176
  body?: unknown
177
- headers?: unknown
177
+ // headers?: unknown
178
178
  query?: unknown
179
179
  params?: unknown
180
- cookie?: unknown
180
+ // cookie?: unknown
181
181
  response?: unknown
182
182
  }
183
183
 
@@ -187,6 +187,8 @@ type OptionalField = {
187
187
 
188
188
  export type TypeSchema = TSchema | ZodTypeAny
189
189
 
190
+ export type TypeObject = TObject | ZodObject<any, any, any>
191
+
190
192
  export type UnwrapSchema<
191
193
  Schema extends TypeSchema | string | undefined,
192
194
  Definitions extends Record<string, unknown> = {},
@@ -210,8 +212,8 @@ export interface UnwrapRoute<
210
212
  > {
211
213
  body: UnwrapSchema<Schema['body'], Definitions>
212
214
  // headers: UnwrapSchema<Schema['headers'], Definitions>
213
- // query: UnwrapSchema<Schema['query'], Definitions>
214
- // params: UnwrapSchema<Schema['params'], Definitions>
215
+ query: UnwrapSchema<Schema['query'], Definitions>
216
+ params: UnwrapSchema<Schema['params'], Definitions>
215
217
  // cookie: UnwrapSchema<Schema['cookie'], Definitions>
216
218
  response: Schema['response'] extends TypeSchema | string
217
219
  ? {
@@ -320,8 +322,8 @@ export type HTTPMethod =
320
322
  export interface InputSchema<Name extends string = string> {
321
323
  body?: TypeSchema | Name
322
324
  // headers?: TObject | TNull | TUndefined | Name
323
- // query?: TObject | TNull | TUndefined | Name
324
- // params?: TObject | TNull | TUndefined | Name
325
+ query?: TypeObject | Name
326
+ params?: TypeObject | Name
325
327
  // cookie?: TObject | TNull | TUndefined | Name
326
328
  response?:
327
329
  | TypeSchema
@@ -335,10 +337,10 @@ export interface MergeSchema<
335
337
  in out B extends RouteSchema,
336
338
  > {
337
339
  body: undefined extends A['body'] ? B['body'] : A['body']
338
- headers: undefined extends A['headers'] ? B['headers'] : A['headers']
340
+ // headers: undefined extends A['headers'] ? B['headers'] : A['headers']
339
341
  query: undefined extends A['query'] ? B['query'] : A['query']
340
342
  params: undefined extends A['params'] ? B['params'] : A['params']
341
- cookie: undefined extends A['cookie'] ? B['cookie'] : A['cookie']
343
+ // cookie: undefined extends A['cookie'] ? B['cookie'] : A['cookie']
342
344
  response: {} extends A['response']
343
345
  ? {} extends B['response']
344
346
  ? {}
@@ -686,22 +688,22 @@ export type ErrorHandler<
686
688
  Volatile['resolve']
687
689
  >
688
690
  >
689
- | Prettify<
690
- {
691
- request: Request
692
- code: 'INVALID_COOKIE_SIGNATURE'
693
- error: Readonly<InvalidCookieSignature>
694
- } & NeverKey<
695
- Singleton['derive'] &
696
- Ephemeral['derive'] &
697
- Volatile['derive']
698
- > &
699
- NeverKey<
700
- Singleton['derive'] &
701
- Ephemeral['resolve'] &
702
- Volatile['resolve']
703
- >
704
- >
691
+ // | Prettify<
692
+ // {
693
+ // request: Request
694
+ // code: 'INVALID_COOKIE_SIGNATURE'
695
+ // error: Readonly<InvalidCookieSignature>
696
+ // } & NeverKey<
697
+ // Singleton['derive'] &
698
+ // Ephemeral['derive'] &
699
+ // Volatile['derive']
700
+ // > &
701
+ // NeverKey<
702
+ // Singleton['derive'] &
703
+ // Ephemeral['resolve'] &
704
+ // Volatile['resolve']
705
+ // >
706
+ // >
705
707
  | Prettify<
706
708
  {
707
709
  [K in keyof T]: {
@@ -1,6 +1,7 @@
1
1
  import { test, describe, expect } from 'vitest'
2
2
  import { Type } from '@sinclair/typebox'
3
3
  import { bfs, Spiceflow } from './spiceflow'
4
+ import { z } from 'zod'
4
5
 
5
6
  test('works', async () => {
6
7
  const res = await new Spiceflow()
@@ -24,6 +25,87 @@ test('GET dynamic route', async () => {
24
25
  expect(await res.json()).toEqual('hi')
25
26
  })
26
27
 
28
+ test('GET with query, untyped', async () => {
29
+ const res = await new Spiceflow()
30
+ .get('/query', ({ query }) => {
31
+ return query.id
32
+ })
33
+ .handle(new Request('http://localhost/query?id=hi', { method: 'GET' }))
34
+ expect(res.status).toBe(200)
35
+ expect(await res.json()).toEqual('hi')
36
+ })
37
+
38
+ test('GET with query, zod, fails validation', async () => {
39
+ const res = await new Spiceflow()
40
+ .get(
41
+ '/query',
42
+ ({ query }) => {
43
+ return query.id
44
+ },
45
+ {
46
+ query: z.object({
47
+ id: z.number(),
48
+ }),
49
+ },
50
+ )
51
+ .handle(new Request('http://localhost/query?id=hi', { method: 'GET' }))
52
+ expect(res.status).toBe(422)
53
+ })
54
+
55
+ test('GET with query and zod', async () => {
56
+ const res = await new Spiceflow()
57
+ .get(
58
+ '/query',
59
+ ({ query }) => {
60
+ return query.id
61
+ // @ts-expect-error
62
+ void query.sdfsd
63
+ },
64
+ {
65
+ query: z.object({
66
+ id: z.string(),
67
+ }),
68
+ },
69
+ )
70
+ .handle(new Request('http://localhost/query?id=hi', { method: 'GET' }))
71
+ expect(res.status).toBe(200)
72
+ expect(await res.json()).toEqual('hi')
73
+ })
74
+
75
+ test('GET dynamic route, params are typed', async () => {
76
+ const res = await new Spiceflow()
77
+ .get('/ids/:id', ({ params }) => {
78
+ let id = params.id
79
+ // @ts-expect-error
80
+ params.sdfsd
81
+ return id
82
+ })
83
+ .handle(new Request('http://localhost/ids/hi', { method: 'GET' }))
84
+ expect(res.status).toBe(200)
85
+ expect(await res.json()).toEqual('hi')
86
+ })
87
+
88
+ test('GET dynamic route, params are typed with schema', async () => {
89
+ const res = await new Spiceflow()
90
+ .get(
91
+ '/ids/:id',
92
+ ({ params }) => {
93
+ let id = params.id
94
+ // @ts-expect-error
95
+ params.sdfsd
96
+ return id
97
+ },
98
+ {
99
+ params: z.object({
100
+ id: z.string(),
101
+ }),
102
+ },
103
+ )
104
+ .handle(new Request('http://localhost/ids/hi', { method: 'GET' }))
105
+ expect(res.status).toBe(200)
106
+ expect(await res.json()).toEqual('hi')
107
+ })
108
+
27
109
  test('missing route is not found', async () => {
28
110
  const res = await new Spiceflow()
29
111
  .get('/ids/:id', () => 'hi')
@@ -48,9 +130,9 @@ test('body is parsed as json', async () => {
48
130
  const res = await new Spiceflow()
49
131
  .state('id', '')
50
132
 
51
- .post('/post', (c) => {
52
- body = c.body
53
- // console.log({ body })
133
+ .post('/post', async (c) => {
134
+ body = await c.request.json()
135
+ // console.log({request})
54
136
  return body
55
137
  })
56
138
  .handle(
@@ -71,8 +153,9 @@ test('validate body works, request success', async () => {
71
153
 
72
154
  .post(
73
155
  '/post',
74
- ({ body }) => {
75
- // console.log({ body })
156
+ async ({ request }) => {
157
+ // console.log({request})
158
+ let body = await request.json()
76
159
  expect(body).toEqual({ name: 'John' })
77
160
  return 'ok'
78
161
  },
@@ -100,8 +183,9 @@ test('validate body works, request fails', async () => {
100
183
 
101
184
  .post(
102
185
  '/post',
103
- ({ body, redirect, error }) => {
104
- // console.log({ body })
186
+ async ({ request, redirect }) => {
187
+ // console.log({request})
188
+ let body = await request.json()
105
189
  expect(body).toEqual({ name: 'John' })
106
190
  },
107
191
  {
@@ -120,7 +204,7 @@ test('validate body works, request fails', async () => {
120
204
  body: JSON.stringify({ name: 'John' }),
121
205
  }),
122
206
  )
123
- expect(res.status).toBe(400)
207
+ expect(res.status).toBe(422)
124
208
  expect(await res.text()).toMatchInlineSnapshot(
125
209
  `"data must have required property 'requiredField'"`,
126
210
  )
package/src/spiceflow.ts CHANGED
@@ -89,7 +89,10 @@ export type InternalRoute = {
89
89
  path: string
90
90
  handler: InlineHandler<any, any, any>
91
91
  hooks: LocalHook<any, any, any, any, any, any, any>
92
- validate?: ValidateFunction
92
+ validateBody?: ValidateFunction
93
+ validateQuery?: ValidateFunction
94
+ validateParams?: ValidateFunction
95
+
93
96
  prefix: string
94
97
 
95
98
  // store: Record<any, any>
@@ -160,15 +163,9 @@ export class Spiceflow<
160
163
  // }
161
164
 
162
165
  let bodySchema: TypeSchema = hooks?.body
163
- let validate: ValidateFunction | undefined
164
-
165
- if (isZodSchema(bodySchema)) {
166
- let jsonSchema = zodToJsonSchema(bodySchema, {})
167
- validate = ajv.compile(jsonSchema)
168
- } else if (bodySchema) {
169
- // console.log(bodySchema)
170
- validate = ajv.compile(bodySchema)
171
- }
166
+ let validateBody = getValidateFunction(bodySchema)
167
+ let validateQuery = getValidateFunction(hooks?.query)
168
+ let validateParams = getValidateFunction(hooks?.params)
172
169
 
173
170
  const store = router.router.register(path)
174
171
  let route: InternalRoute = {
@@ -180,7 +177,9 @@ export class Spiceflow<
180
177
  // prefix,
181
178
  handler: handler!,
182
179
  hooks,
183
- validate,
180
+ validateBody,
181
+ validateParams,
182
+ validateQuery,
184
183
  }
185
184
  router.routes.push(route)
186
185
  store[method] = route
@@ -216,6 +215,7 @@ export class Spiceflow<
216
215
 
217
216
  const { onErrorHandlers, onRequestHandlers } = router
218
217
  const params = route['params'] || {}
218
+
219
219
  return {
220
220
  ...data,
221
221
  router,
@@ -355,7 +355,7 @@ export class Spiceflow<
355
355
  ? ResolvePath<Path>
356
356
  : Schema['params']
357
357
  query: Schema['query']
358
- headers: Schema['headers']
358
+
359
359
  response: ComposeSpiceflowResponse<
360
360
  Schema['response'],
361
361
  Handle
@@ -422,7 +422,7 @@ export class Spiceflow<
422
422
  ? ResolvePath<Path>
423
423
  : Schema['params']
424
424
  query: Schema['query']
425
- headers: Schema['headers']
425
+
426
426
  response: ComposeSpiceflowResponse<
427
427
  Schema['response'],
428
428
  Handle
@@ -487,7 +487,7 @@ export class Spiceflow<
487
487
  ? ResolvePath<Path>
488
488
  : Schema['params']
489
489
  query: Schema['query']
490
- headers: Schema['headers']
490
+
491
491
  response: ComposeSpiceflowResponse<
492
492
  Schema['response'],
493
493
  Handle
@@ -553,7 +553,7 @@ export class Spiceflow<
553
553
  ? ResolvePath<Path>
554
554
  : Schema['params']
555
555
  query: Schema['query']
556
- headers: Schema['headers']
556
+
557
557
  response: ComposeSpiceflowResponse<
558
558
  Schema['response'],
559
559
  Handle
@@ -619,7 +619,7 @@ export class Spiceflow<
619
619
  ? ResolvePath<Path>
620
620
  : Schema['params']
621
621
  query: Schema['query']
622
- headers: Schema['headers']
622
+
623
623
  response: ComposeSpiceflowResponse<
624
624
  Schema['response'],
625
625
  Handle
@@ -685,7 +685,7 @@ export class Spiceflow<
685
685
  ? ResolvePath<Path>
686
686
  : Schema['params']
687
687
  query: Schema['query']
688
- headers: Schema['headers']
688
+
689
689
  response: ComposeSpiceflowResponse<
690
690
  Schema['response'],
691
691
  Handle
@@ -751,7 +751,7 @@ export class Spiceflow<
751
751
  ? ResolvePath<Path>
752
752
  : Schema['params']
753
753
  query: Schema['query']
754
- headers: Schema['headers']
754
+
755
755
  response: ComposeSpiceflowResponse<
756
756
  Schema['response'],
757
757
  Handle
@@ -819,7 +819,7 @@ export class Spiceflow<
819
819
  ? ResolvePath<Path>
820
820
  : Schema['params']
821
821
  query: Schema['query']
822
- headers: Schema['headers']
822
+
823
823
  response: ComposeSpiceflowResponse<
824
824
  Schema['response'],
825
825
  Handle
@@ -976,6 +976,7 @@ export class Spiceflow<
976
976
  */
977
977
  async handle(request: Request, platform?: P): Promise<Response> {
978
978
  platform ??= {} as P
979
+
979
980
  let u = new URL(request.url)
980
981
  let path = u.pathname + u.search
981
982
  const defaultContext = {
@@ -995,7 +996,7 @@ export class Spiceflow<
995
996
  onErrorHandlers = this.getRouteAndParents(route.router)
996
997
  .reverse()
997
998
  .flatMap((x) => x.onErrorHandlers)
998
- const { params, store: defaultStore } = route
999
+ let { params, store: defaultStore } = route
999
1000
  const onReqHandlers = this.getRouteAndParents(route.router)
1000
1001
  .reverse()
1001
1002
  .flatMap((x) => x.onRequestHandlers)
@@ -1004,25 +1005,17 @@ export class Spiceflow<
1004
1005
  // TODO add content type
1005
1006
 
1006
1007
  let content = route?.hooks?.content
1007
- let body = await getRequestBody({ request, content })
1008
+ // let body = await getRequestBody({ request, content })
1008
1009
 
1009
- if (route.validate) {
1010
- // TODO move compile to the router
1010
+ if (route.validateBody) {
1011
+ // TODO don't clone the request
1012
+ let typedRequest = new TypedRequest(request)
1013
+ typedRequest.validateBody = route.validateBody
1014
+ request = typedRequest
1015
+ }
1011
1016
 
1012
- const valid = route.validate(body)
1013
- if (!valid) {
1014
- const error = ajv.errorsText(route.validate.errors, {
1015
- separator: '\n',
1016
- })
1017
+ let query = parseQuery.parse((u.search || '').slice(1))
1017
1018
 
1018
- return new Response(error, {
1019
- status: 400,
1020
- headers: {
1021
- 'content-type': 'text/plain',
1022
- },
1023
- })
1024
- }
1025
- }
1026
1019
  if (onReqHandlers.length > 0) {
1027
1020
  for (const handler of onReqHandlers) {
1028
1021
  const res = await handler({
@@ -1030,6 +1023,7 @@ export class Spiceflow<
1030
1023
  response,
1031
1024
  store,
1032
1025
  path,
1026
+ query,
1033
1027
  } satisfies Context<any, any, any>)
1034
1028
  if (res) {
1035
1029
  return await turnHandlerResultIntoResponse(res)
@@ -1037,6 +1031,9 @@ export class Spiceflow<
1037
1031
  }
1038
1032
  }
1039
1033
 
1034
+ query = runValidation(query, route.validateQuery)
1035
+ params = runValidation(params, route.validateParams)
1036
+
1040
1037
  // console.log(route)
1041
1038
 
1042
1039
  const res = route.handler({
@@ -1045,7 +1042,8 @@ export class Spiceflow<
1045
1042
  response,
1046
1043
  params: params as any,
1047
1044
  store,
1048
- body,
1045
+ query,
1046
+ // body,
1049
1047
  path,
1050
1048
 
1051
1049
  // platform
@@ -1060,14 +1058,16 @@ export class Spiceflow<
1060
1058
 
1061
1059
  return await turnHandlerResultIntoResponse(res)
1062
1060
  } catch (err: any) {
1061
+ if (err instanceof Response) return err
1063
1062
  let res = await this.runErrorHandlers({
1064
1063
  onErrorHandlers,
1065
1064
  error: err,
1066
1065
  request,
1067
1066
  })
1068
1067
  if (res) return res
1068
+ let status = err?.status ?? 500
1069
1069
  return new Response(err?.message || 'Internal Server Error', {
1070
- status: 500,
1070
+ status,
1071
1071
  })
1072
1072
  }
1073
1073
  }
@@ -1201,59 +1201,59 @@ export class Spiceflow<
1201
1201
  }
1202
1202
  }
1203
1203
 
1204
- async function getRequestBody({
1205
- request,
1206
- content,
1207
- }: {
1208
- content
1209
- request: Request
1210
- }) {
1211
- let body: string | Record<string, any> | undefined
1212
- if (request.method === 'GET' || request.method === 'HEAD') {
1213
- return
1214
- }
1204
+ // async function getRequestBody({
1205
+ // request,
1206
+ // content,
1207
+ // }: {
1208
+ // content
1209
+ // request: Request
1210
+ // }) {
1211
+ // let body: string | Record<string, any> | undefined
1212
+ // if (request.method === 'GET' || request.method === 'HEAD') {
1213
+ // return
1214
+ // }
1215
1215
 
1216
- const contentType =
1217
- content || request.headers.get('content-type')?.split(';')?.[0]
1216
+ // const contentType =
1217
+ // content || request.headers.get('content-type')?.split(';')?.[0]
1218
1218
 
1219
- if (!contentType) {
1220
- return
1221
- }
1219
+ // if (!contentType) {
1220
+ // return
1221
+ // }
1222
1222
 
1223
- switch (contentType) {
1224
- case 'application/json':
1225
- body = (await request.json()) as any
1226
- break
1223
+ // switch (contentType) {
1224
+ // case 'application/json':
1225
+ // body = (await request.json()) as any
1226
+ // break
1227
1227
 
1228
- case 'text/plain':
1229
- body = await request.text()
1230
- break
1228
+ // case 'text/plain':
1229
+ // body = await request.text()
1230
+ // break
1231
1231
 
1232
- case 'application/x-www-form-urlencoded':
1233
- body = parseQuery.parse(await request.text()) as any
1234
- break
1232
+ // case 'application/x-www-form-urlencoded':
1233
+ // body = parseQuery.parse(await request.text()) as any
1234
+ // break
1235
1235
 
1236
- case 'application/octet-stream':
1237
- body = await request.arrayBuffer()
1238
- break
1236
+ // case 'application/octet-stream':
1237
+ // body = await request.arrayBuffer()
1238
+ // break
1239
1239
 
1240
- case 'multipart/form-data':
1241
- body = {}
1240
+ // case 'multipart/form-data':
1241
+ // body = {}
1242
1242
 
1243
- const form = await request.formData()
1244
- for (const key of form.keys()) {
1245
- if (body[key]) continue
1243
+ // const form = await request.formData()
1244
+ // for (const key of form.keys()) {
1245
+ // if (body[key]) continue
1246
1246
 
1247
- const value = form.getAll(key)
1248
- if (value.length === 1) body[key] = value[0]
1249
- else body[key] = value
1250
- }
1247
+ // const value = form.getAll(key)
1248
+ // if (value.length === 1) body[key] = value[0]
1249
+ // else body[key] = value
1250
+ // }
1251
1251
 
1252
- break
1253
- }
1252
+ // break
1253
+ // }
1254
1254
 
1255
- return body
1256
- }
1255
+ // return body
1256
+ // }
1257
1257
 
1258
1258
  const METHODS = [
1259
1259
  'ALL',
@@ -1288,6 +1288,15 @@ function bfsFind<T>(
1288
1288
  }
1289
1289
  return
1290
1290
  }
1291
+ export class TypedRequest<T = any> extends Request {
1292
+ validateBody?: ValidateFunction
1293
+
1294
+ async json(): Promise<T> {
1295
+ const body = (await super.json()) as Promise<T>
1296
+ return runValidation(body, this.validateBody)
1297
+ }
1298
+ }
1299
+
1291
1300
  export function bfs(tree: RouterTree) {
1292
1301
  const queue = [tree]
1293
1302
  let nodes: RouterTree[] = []
@@ -1346,3 +1355,26 @@ export function isZodSchema(value: unknown): value is ZodType {
1346
1355
  'nullable' in value)
1347
1356
  )
1348
1357
  }
1358
+
1359
+ function getValidateFunction(schema: TypeSchema) {
1360
+ if (isZodSchema(schema)) {
1361
+ let jsonSchema = zodToJsonSchema(schema, {})
1362
+ return ajv.compile(jsonSchema)
1363
+ }
1364
+
1365
+ if (schema) {
1366
+ return ajv.compile(schema)
1367
+ }
1368
+ }
1369
+
1370
+ function runValidation(value: any, validate?: ValidateFunction) {
1371
+ if (!validate) return value
1372
+ const valid = validate(value)
1373
+ if (!valid) {
1374
+ const error = ajv.errorsText(validate.errors, {
1375
+ separator: '\n',
1376
+ })
1377
+ throw new ValidationError(error)
1378
+ }
1379
+ return value
1380
+ }
package/src/zod.test.ts CHANGED
@@ -11,13 +11,14 @@ test('body is parsed as json', async () => {
11
11
 
12
12
  .post(
13
13
  '/post',
14
- (c) => {
15
- name = c.body.name
14
+ async (c) => {
15
+ const body = await c.request.json()
16
+ name = body.name
16
17
  // @ts-expect-error
17
- c.body.nonExistingField
18
+ body.nonExistingField
18
19
  return {
19
20
  name,
20
- nameEcho: c.body.name,
21
+ nameEcho: body.name,
21
22
  // add: 3,
22
23
  }
23
24
  },
@@ -33,13 +34,14 @@ test('body is parsed as json', async () => {
33
34
  )
34
35
  .post(
35
36
  '/post2',
36
- (c) => {
37
- name = c.body.name
37
+ async (c) => {
38
+ const body = await c.request.json()
39
+ name = body.name
38
40
  // @ts-expect-error
39
- c.body.nonExistingField
41
+ body.nonExistingField
40
42
  return {
41
43
  name,
42
- nameEcho: c.body.name,
44
+ nameEcho: body.name,
43
45
  }
44
46
  },
45
47
  {