@pikku/core 0.6.23 → 0.6.25
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 +12 -0
- package/dist/channel/channel-runner.d.ts +2 -2
- package/dist/channel/channel-runner.js +5 -5
- package/dist/channel/channel.types.d.ts +1 -1
- package/dist/channel/local/local-channel-runner.d.ts +1 -1
- package/dist/channel/local/local-channel-runner.js +7 -7
- package/dist/channel/serverless/serverless-channel-runner.d.ts +1 -1
- package/dist/channel/serverless/serverless-channel-runner.js +6 -6
- package/dist/http/http-route-runner.d.ts +1 -1
- package/dist/http/http-route-runner.js +15 -14
- package/dist/http/http-routes.types.d.ts +1 -1
- package/dist/http/pikku-fetch-http-request.d.ts +1 -0
- package/dist/http/pikku-fetch-http-request.js +64 -8
- package/dist/http/pikku-fetch-http-response.d.ts +2 -1
- package/dist/http/pikku-fetch-http-response.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/middleware/auth-apikey.js +2 -2
- package/dist/middleware/auth-bearer.js +2 -2
- package/dist/middleware/auth-cookie.d.ts +7 -3
- package/dist/middleware/auth-cookie.js +34 -17
- package/dist/middleware-runner.d.ts +3 -3
- package/dist/middleware-runner.js +2 -2
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +4 -1
- package/dist/services/jwt-service.d.ts +2 -1
- package/dist/services/local-secrets.d.ts +2 -2
- package/dist/services/local-secrets.js +5 -5
- package/dist/services/user-session-service.d.ts +2 -0
- package/dist/services/user-session-service.js +3 -0
- package/dist/time-utils.d.ts +12 -0
- package/dist/time-utils.js +18 -0
- package/dist/types/core.types.d.ts +5 -5
- package/dist/types/functions.types.d.ts +1 -1
- package/lcov.info +1767 -1031
- package/package.json +1 -1
- package/src/channel/channel-runner.ts +6 -6
- package/src/channel/channel.types.ts +1 -1
- package/src/channel/local/local-channel-runner.test.ts +0 -2
- package/src/channel/local/local-channel-runner.ts +7 -7
- package/src/channel/serverless/serverless-channel-runner.ts +6 -9
- package/src/http/http-route-runner.test.ts +1 -1
- package/src/http/http-route-runner.ts +25 -17
- package/src/http/http-routes.types.ts +1 -1
- package/src/http/pikku-fetch-http-request.test.ts +237 -0
- package/src/http/pikku-fetch-http-request.ts +64 -8
- package/src/http/pikku-fetch-http-response.test.ts +82 -0
- package/src/http/pikku-fetch-http-response.ts +16 -2
- package/src/index.ts +1 -1
- package/src/middleware/auth-apikey.ts +2 -2
- package/src/middleware/auth-bearer.ts +2 -2
- package/src/middleware/auth-cookie.ts +51 -21
- package/src/middleware-runner.ts +3 -3
- package/src/schema.test.ts +6 -6
- package/src/schema.ts +3 -1
- package/src/services/jwt-service.ts +6 -1
- package/src/services/local-secrets.ts +3 -3
- package/src/services/user-session-service.ts +4 -0
- package/src/time-utils.test.ts +56 -0
- package/src/time-utils.ts +32 -0
- package/src/types/core.types.ts +5 -5
- package/src/types/functions.types.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NotFoundError } from '../errors/errors.js'
|
|
2
2
|
import { pikkuState } from '../pikku-state.js'
|
|
3
|
-
import {
|
|
3
|
+
import { coerceTopLevelDataFromSchema, validateSchema } from '../schema.js'
|
|
4
4
|
import { UserSessionService } from '../services/user-session-service.js'
|
|
5
5
|
import {
|
|
6
6
|
CoreAPIChannel,
|
|
@@ -56,11 +56,11 @@ export const getMatchingChannelConfig = (request: string) => {
|
|
|
56
56
|
export const openChannel = async ({
|
|
57
57
|
route,
|
|
58
58
|
singletonServices,
|
|
59
|
-
|
|
59
|
+
coerceDataFromSchema = true,
|
|
60
60
|
request,
|
|
61
61
|
}: Pick<CoreAPIChannel<unknown, string>, 'route'> &
|
|
62
62
|
RunChannelParams<unknown> & {
|
|
63
|
-
|
|
63
|
+
userSession: UserSessionService<any>
|
|
64
64
|
} & RunChannelOptions): Promise<{
|
|
65
65
|
openingData: unknown
|
|
66
66
|
channelConfig: CoreAPIChannel<unknown, any>
|
|
@@ -83,12 +83,12 @@ export const openChannel = async ({
|
|
|
83
83
|
let openingData: any | undefined
|
|
84
84
|
if (request) {
|
|
85
85
|
openingData = await request.data()
|
|
86
|
-
if (
|
|
87
|
-
|
|
86
|
+
if (coerceDataFromSchema && schemaName) {
|
|
87
|
+
coerceTopLevelDataFromSchema(schemaName, openingData)
|
|
88
88
|
}
|
|
89
89
|
await validateSchema(
|
|
90
90
|
singletonServices.logger,
|
|
91
|
-
singletonServices.
|
|
91
|
+
singletonServices.schema,
|
|
92
92
|
schemaName,
|
|
93
93
|
openingData
|
|
94
94
|
)
|
|
@@ -16,7 +16,7 @@ import { CoreAPIFunction, CoreAPIPermission } from '../types/functions.types.js'
|
|
|
16
16
|
export type RunChannelOptions = Partial<{
|
|
17
17
|
skipUserSession: boolean
|
|
18
18
|
respondWith404: boolean
|
|
19
|
-
|
|
19
|
+
coerceDataFromSchema: boolean
|
|
20
20
|
logWarningsForStatusCodes: number[]
|
|
21
21
|
bubbleErrors: boolean
|
|
22
22
|
}>
|
|
@@ -149,8 +149,6 @@ test('runChannel should return a channel handler if channel matches and no auth
|
|
|
149
149
|
createSessionServices: mockCreateSessionServices,
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
-
console.log(result)
|
|
153
|
-
|
|
154
152
|
assert.ok(result, 'Should return a PikkuChannelHandler instance')
|
|
155
153
|
|
|
156
154
|
// Simulate opening the channel
|
|
@@ -23,7 +23,7 @@ export const runLocalChannel = async ({
|
|
|
23
23
|
createSessionServices,
|
|
24
24
|
skipUserSession = false,
|
|
25
25
|
respondWith404 = true,
|
|
26
|
-
|
|
26
|
+
coerceDataFromSchema = true,
|
|
27
27
|
logWarningsForStatusCodes = [],
|
|
28
28
|
bubbleErrors = false,
|
|
29
29
|
}: Partial<Pick<CoreAPIChannel<unknown, any>, 'route'>> &
|
|
@@ -32,7 +32,7 @@ export const runLocalChannel = async ({
|
|
|
32
32
|
let sessionServices: SessionServices<typeof singletonServices> | undefined
|
|
33
33
|
|
|
34
34
|
let channelHandler: PikkuLocalChannelHandler | undefined
|
|
35
|
-
const
|
|
35
|
+
const userSession = new PikkuUserSessionService()
|
|
36
36
|
|
|
37
37
|
let http: PikkuHTTP | undefined
|
|
38
38
|
if (request) {
|
|
@@ -51,8 +51,8 @@ export const runLocalChannel = async ({
|
|
|
51
51
|
route,
|
|
52
52
|
singletonServices,
|
|
53
53
|
skipUserSession,
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
coerceDataFromSchema,
|
|
55
|
+
userSession,
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
channelHandler = new PikkuLocalChannelHandler(
|
|
@@ -61,7 +61,7 @@ export const runLocalChannel = async ({
|
|
|
61
61
|
openingData
|
|
62
62
|
)
|
|
63
63
|
const channel = channelHandler.getChannel()
|
|
64
|
-
const session = await
|
|
64
|
+
const session = await userSession.get()
|
|
65
65
|
if (createSessionServices) {
|
|
66
66
|
sessionServices = await createSessionServices(
|
|
67
67
|
singletonServices,
|
|
@@ -73,7 +73,7 @@ export const runLocalChannel = async ({
|
|
|
73
73
|
const allServices = {
|
|
74
74
|
...singletonServices,
|
|
75
75
|
...sessionServices,
|
|
76
|
-
userSession:
|
|
76
|
+
userSession: userSession,
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
channelHandler.registerOnOpen(() => {
|
|
@@ -115,7 +115,7 @@ export const runLocalChannel = async ({
|
|
|
115
115
|
await runMiddleware(
|
|
116
116
|
{
|
|
117
117
|
...singletonServices,
|
|
118
|
-
|
|
118
|
+
userSession,
|
|
119
119
|
},
|
|
120
120
|
{ http },
|
|
121
121
|
route.middleware || [],
|
|
@@ -64,7 +64,7 @@ export const runChannelConnect = async ({
|
|
|
64
64
|
createSessionServices,
|
|
65
65
|
channelStore,
|
|
66
66
|
channelHandlerFactory,
|
|
67
|
-
|
|
67
|
+
coerceDataFromSchema = true,
|
|
68
68
|
logWarningsForStatusCodes = [],
|
|
69
69
|
respondWith404 = true,
|
|
70
70
|
bubbleErrors = false,
|
|
@@ -78,10 +78,7 @@ export const runChannelConnect = async ({
|
|
|
78
78
|
http = createHTTPInteraction(new PikkuFetchHTTPRequest(request), response)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const
|
|
82
|
-
channelStore,
|
|
83
|
-
channelId
|
|
84
|
-
)
|
|
81
|
+
const userSession = new PikkuUserSessionService(channelStore, channelId)
|
|
85
82
|
|
|
86
83
|
const { channelConfig, openingData } = await openChannel({
|
|
87
84
|
channelId,
|
|
@@ -89,8 +86,8 @@ export const runChannelConnect = async ({
|
|
|
89
86
|
request,
|
|
90
87
|
route,
|
|
91
88
|
singletonServices,
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
coerceDataFromSchema,
|
|
90
|
+
userSession,
|
|
94
91
|
})
|
|
95
92
|
|
|
96
93
|
const main = async () => {
|
|
@@ -110,7 +107,7 @@ export const runChannelConnect = async ({
|
|
|
110
107
|
sessionServices = await createSessionServices(
|
|
111
108
|
singletonServices,
|
|
112
109
|
{ http },
|
|
113
|
-
await
|
|
110
|
+
await userSession.get()
|
|
114
111
|
)
|
|
115
112
|
}
|
|
116
113
|
await channelConfig.onConnect?.(
|
|
@@ -138,7 +135,7 @@ export const runChannelConnect = async ({
|
|
|
138
135
|
await runMiddleware(
|
|
139
136
|
{
|
|
140
137
|
...singletonServices,
|
|
141
|
-
|
|
138
|
+
userSession,
|
|
142
139
|
},
|
|
143
140
|
{ http },
|
|
144
141
|
channelConfig.middleware || [],
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '../channel/local/local-channel-runner.test.js'
|
|
11
11
|
|
|
12
12
|
const sessionMiddleware: PikkuMiddleware = async (services, _, next) => {
|
|
13
|
-
services.
|
|
13
|
+
services.userSession.set({ userId: 'test' } as any)
|
|
14
14
|
await next()
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
PikkuHTTPRequest,
|
|
8
8
|
PikkuHTTPResponse,
|
|
9
9
|
} from './http-routes.types.js'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
CoreUserSession,
|
|
12
|
+
PikkuMiddleware,
|
|
13
|
+
SessionServices,
|
|
14
|
+
} from '../types/core.types.js'
|
|
11
15
|
import { match } from 'path-to-regexp'
|
|
12
16
|
import {
|
|
13
17
|
ForbiddenError,
|
|
@@ -15,8 +19,11 @@ import {
|
|
|
15
19
|
NotFoundError,
|
|
16
20
|
} from '../errors/errors.js'
|
|
17
21
|
import { closeSessionServices } from '../utils.js'
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
22
|
+
import { coerceTopLevelDataFromSchema, validateSchema } from '../schema.js'
|
|
23
|
+
import {
|
|
24
|
+
PikkuUserSessionService,
|
|
25
|
+
UserSessionService,
|
|
26
|
+
} from '../services/user-session-service.js'
|
|
20
27
|
import { runMiddleware } from '../middleware-runner.js'
|
|
21
28
|
import { handleError } from '../handle-error.js'
|
|
22
29
|
import { pikkuState } from '../pikku-state.js'
|
|
@@ -195,7 +202,7 @@ export const createHTTPInteraction = (
|
|
|
195
202
|
const executeRouteWithMiddleware = async (
|
|
196
203
|
services: {
|
|
197
204
|
singletonServices: any
|
|
198
|
-
|
|
205
|
+
userSession: UserSessionService<CoreUserSession>
|
|
199
206
|
createSessionServices: Function
|
|
200
207
|
skipUserSession: boolean
|
|
201
208
|
},
|
|
@@ -208,13 +215,13 @@ const executeRouteWithMiddleware = async (
|
|
|
208
215
|
},
|
|
209
216
|
http: PikkuHTTP | undefined,
|
|
210
217
|
options: {
|
|
211
|
-
|
|
218
|
+
coerceDataFromSchema: boolean
|
|
212
219
|
}
|
|
213
220
|
) => {
|
|
214
221
|
const { matchedPath, params, route, middleware, schemaName } = matchedRoute
|
|
215
222
|
const {
|
|
216
223
|
singletonServices,
|
|
217
|
-
|
|
224
|
+
userSession,
|
|
218
225
|
createSessionServices,
|
|
219
226
|
skipUserSession,
|
|
220
227
|
} = services
|
|
@@ -232,7 +239,7 @@ const executeRouteWithMiddleware = async (
|
|
|
232
239
|
|
|
233
240
|
// Main route execution logic wrapped for middleware handling
|
|
234
241
|
const runMain = async () => {
|
|
235
|
-
const session =
|
|
242
|
+
const session = userSession.get()
|
|
236
243
|
|
|
237
244
|
// Ensure session is available when required
|
|
238
245
|
if (skipUserSession && requiresSession) {
|
|
@@ -251,7 +258,7 @@ const executeRouteWithMiddleware = async (
|
|
|
251
258
|
|
|
252
259
|
// Create session-specific services for handling the request
|
|
253
260
|
sessionServices = await createSessionServices(
|
|
254
|
-
{ ...singletonServices,
|
|
261
|
+
{ ...singletonServices, userSession },
|
|
255
262
|
{ http },
|
|
256
263
|
session
|
|
257
264
|
)
|
|
@@ -259,6 +266,7 @@ const executeRouteWithMiddleware = async (
|
|
|
259
266
|
const allServices = {
|
|
260
267
|
...singletonServices,
|
|
261
268
|
...sessionServices,
|
|
269
|
+
userSession,
|
|
262
270
|
http,
|
|
263
271
|
}
|
|
264
272
|
const data = await http?.request?.data()
|
|
@@ -266,14 +274,14 @@ const executeRouteWithMiddleware = async (
|
|
|
266
274
|
// Validate request data against the defined schema, if any
|
|
267
275
|
await validateSchema(
|
|
268
276
|
singletonServices.logger,
|
|
269
|
-
singletonServices.
|
|
277
|
+
singletonServices.schema,
|
|
270
278
|
schemaName,
|
|
271
279
|
data
|
|
272
280
|
)
|
|
273
281
|
|
|
274
|
-
// Coerce query string parameters
|
|
275
|
-
if (options.
|
|
276
|
-
|
|
282
|
+
// Coerce (top level) query string parameters or date objects if specified by the schema
|
|
283
|
+
if (options.coerceDataFromSchema && schemaName) {
|
|
284
|
+
coerceTopLevelDataFromSchema(schemaName, data)
|
|
277
285
|
}
|
|
278
286
|
|
|
279
287
|
// Execute permission checks
|
|
@@ -307,7 +315,7 @@ const executeRouteWithMiddleware = async (
|
|
|
307
315
|
|
|
308
316
|
// Execute middleware, then run the main logic
|
|
309
317
|
await runMiddleware(
|
|
310
|
-
{ ...singletonServices,
|
|
318
|
+
{ ...singletonServices, userSession },
|
|
311
319
|
{ http },
|
|
312
320
|
middleware,
|
|
313
321
|
runMain
|
|
@@ -388,11 +396,11 @@ export const fetchData = async <In, Out>(
|
|
|
388
396
|
skipUserSession = false,
|
|
389
397
|
respondWith404 = true,
|
|
390
398
|
logWarningsForStatusCodes = [],
|
|
391
|
-
|
|
399
|
+
coerceDataFromSchema = true,
|
|
392
400
|
bubbleErrors = false,
|
|
393
401
|
}: RunRouteOptions & RunRouteParams
|
|
394
402
|
): Promise<Out | void> => {
|
|
395
|
-
const
|
|
403
|
+
const userSession = new PikkuUserSessionService()
|
|
396
404
|
let sessionServices: SessionServices<typeof singletonServices> | undefined
|
|
397
405
|
let result: Out
|
|
398
406
|
|
|
@@ -422,13 +430,13 @@ export const fetchData = async <In, Out>(
|
|
|
422
430
|
;({ result, sessionServices } = await executeRouteWithMiddleware(
|
|
423
431
|
{
|
|
424
432
|
singletonServices,
|
|
425
|
-
|
|
433
|
+
userSession,
|
|
426
434
|
createSessionServices,
|
|
427
435
|
skipUserSession,
|
|
428
436
|
},
|
|
429
437
|
matchedRoute,
|
|
430
438
|
http,
|
|
431
|
-
{
|
|
439
|
+
{ coerceDataFromSchema }
|
|
432
440
|
))
|
|
433
441
|
|
|
434
442
|
return result
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { PikkuFetchHTTPRequest } from './pikku-fetch-http-request'
|
|
4
|
+
|
|
5
|
+
const createRequest = (method, url, body, headers = {}) => {
|
|
6
|
+
return new Request(url, {
|
|
7
|
+
method,
|
|
8
|
+
headers,
|
|
9
|
+
body:
|
|
10
|
+
method === 'POST'
|
|
11
|
+
? typeof body === 'string'
|
|
12
|
+
? body
|
|
13
|
+
: JSON.stringify(body)
|
|
14
|
+
: undefined,
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('method() returns lowercase HTTP method', () => {
|
|
19
|
+
const req = createRequest('POST', 'http://localhost/test', null)
|
|
20
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
21
|
+
assert.equal(pikkuReq.method(), 'post')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('path() returns pathname only', () => {
|
|
25
|
+
const req = createRequest('GET', 'http://localhost/foo/bar?x=1', null)
|
|
26
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
27
|
+
assert.equal(pikkuReq.path(), '/foo/bar')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('header() retrieves headers case-insensitively', () => {
|
|
31
|
+
const req = createRequest('GET', 'http://localhost', null, {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
})
|
|
34
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
35
|
+
assert.equal(pikkuReq.header('content-type'), 'application/json')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('cookie() parses cookies correctly', () => {
|
|
39
|
+
const req = createRequest('GET', 'http://localhost', null, {
|
|
40
|
+
Cookie: 'session=abc; user=test',
|
|
41
|
+
})
|
|
42
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
43
|
+
assert.equal(pikkuReq.cookie('session'), 'abc')
|
|
44
|
+
assert.equal(pikkuReq.cookie('user'), 'test')
|
|
45
|
+
assert.equal(pikkuReq.cookie('missing'), null)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('params() and setParams()', () => {
|
|
49
|
+
const req = createRequest('GET', 'http://localhost', null)
|
|
50
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
51
|
+
assert.deepEqual(pikkuReq.params(), {})
|
|
52
|
+
pikkuReq.setParams({ id: '123' })
|
|
53
|
+
assert.deepEqual(pikkuReq.params(), { id: '123' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('query() parses URL search params', () => {
|
|
57
|
+
const req = createRequest('GET', 'http://localhost?x=1&y=2', null)
|
|
58
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
59
|
+
assert.equal(pikkuReq.query().x, '1')
|
|
60
|
+
assert.equal(pikkuReq.query().y, '2')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('data() merges json body, query, and params', async () => {
|
|
64
|
+
const req = createRequest(
|
|
65
|
+
'POST',
|
|
66
|
+
'http://localhost/test?a=5',
|
|
67
|
+
{ foo: 'bar' },
|
|
68
|
+
{ 'Content-Type': 'application/json' }
|
|
69
|
+
)
|
|
70
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
71
|
+
pikkuReq.setParams({ id: '22' })
|
|
72
|
+
const result = await pikkuReq.data()
|
|
73
|
+
assert.deepEqual(result, { id: '22', a: '5', foo: 'bar' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('data() wraps string JSON body under data key', async () => {
|
|
77
|
+
const req = createRequest('POST', 'http://localhost', '"hello"', {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
})
|
|
80
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
81
|
+
const result = await pikkuReq.data()
|
|
82
|
+
assert.deepEqual(result, { data: 'hello' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('data() handles text/plain correctly', async () => {
|
|
86
|
+
const req = new Request('http://localhost', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
89
|
+
body: 'hello world',
|
|
90
|
+
})
|
|
91
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
92
|
+
const result = await pikkuReq.data()
|
|
93
|
+
assert.deepEqual(result, { data: 'hello world' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('data() returns arrayBuffer for unknown content-type', async () => {
|
|
97
|
+
const buffer = Buffer.from('raw')
|
|
98
|
+
const req = new Request('http://localhost', {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
101
|
+
body: buffer,
|
|
102
|
+
})
|
|
103
|
+
const pikkuReq = new PikkuFetchHTTPRequest<any>(req)
|
|
104
|
+
const result = await pikkuReq.data()
|
|
105
|
+
assert(result.data instanceof ArrayBuffer)
|
|
106
|
+
const str = Buffer.from(result.data).toString()
|
|
107
|
+
assert.equal(str, 'raw')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('data() handles invalid JSON safely', async () => {
|
|
111
|
+
const req = new Request('http://localhost', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'Content-Type': 'application/json' },
|
|
114
|
+
body: 'not-json',
|
|
115
|
+
})
|
|
116
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
117
|
+
try {
|
|
118
|
+
await pikkuReq.data()
|
|
119
|
+
assert(false, 'Should have thrown')
|
|
120
|
+
} catch (e) {
|
|
121
|
+
assert(e.message.includes('Error parsing body'))
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('data() parses application/x-www-form-urlencoded correctly', async () => {
|
|
126
|
+
const formBody = 'name=Yasser&age=35&active=true'
|
|
127
|
+
|
|
128
|
+
const req = new Request('http://localhost/form?ref=abc', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
132
|
+
},
|
|
133
|
+
body: formBody,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
137
|
+
pikkuReq.setParams({ userId: '999' })
|
|
138
|
+
|
|
139
|
+
const result = await pikkuReq.data()
|
|
140
|
+
|
|
141
|
+
assert.deepEqual(result, {
|
|
142
|
+
userId: '999',
|
|
143
|
+
ref: 'abc',
|
|
144
|
+
name: 'Yasser',
|
|
145
|
+
age: '35',
|
|
146
|
+
active: 'true',
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// --- Compatible types
|
|
151
|
+
|
|
152
|
+
test('data() treats "123" and 123 as equivalent', async () => {
|
|
153
|
+
const req = createRequest(
|
|
154
|
+
'POST',
|
|
155
|
+
'http://localhost?id=123',
|
|
156
|
+
{ id: 123 },
|
|
157
|
+
{
|
|
158
|
+
'Content-Type': 'application/json',
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const r = new PikkuFetchHTTPRequest(req)
|
|
163
|
+
r.setParams({ id: '123' }) // All match
|
|
164
|
+
const result = await r.data()
|
|
165
|
+
assert.deepEqual(result, { id: '123' })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('data() treats "true" and true as equivalent', async () => {
|
|
169
|
+
const req = createRequest(
|
|
170
|
+
'POST',
|
|
171
|
+
'http://localhost?flag=true',
|
|
172
|
+
{ flag: true },
|
|
173
|
+
{
|
|
174
|
+
'Content-Type': 'application/json',
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const r = new PikkuFetchHTTPRequest(req)
|
|
179
|
+
r.setParams({ flag: 'true' }) // All match
|
|
180
|
+
const result = await r.data()
|
|
181
|
+
assert.deepEqual(result, { flag: 'true' })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// --- Conflicts
|
|
185
|
+
|
|
186
|
+
test('data() throws on conflicting values', async () => {
|
|
187
|
+
const req = createRequest(
|
|
188
|
+
'POST',
|
|
189
|
+
'http://localhost?foo=123',
|
|
190
|
+
{ foo: 456 },
|
|
191
|
+
{
|
|
192
|
+
'Content-Type': 'application/json',
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const r = new PikkuFetchHTTPRequest(req)
|
|
197
|
+
r.setParams({ foo: '123' })
|
|
198
|
+
|
|
199
|
+
await assert.rejects(async () => await r.data(), {
|
|
200
|
+
message: 'Conflicting values for key "foo": "123" vs "456"',
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('data() throws on boolean conflict', async () => {
|
|
205
|
+
const req = createRequest(
|
|
206
|
+
'POST',
|
|
207
|
+
'http://localhost?debug=false',
|
|
208
|
+
{ debug: true },
|
|
209
|
+
{
|
|
210
|
+
'Content-Type': 'application/json',
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const r = new PikkuFetchHTTPRequest(req)
|
|
215
|
+
r.setParams({})
|
|
216
|
+
|
|
217
|
+
await assert.rejects(async () => await r.data(), {
|
|
218
|
+
message: 'Conflicting values for key "debug": "false" vs "true"',
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// --- Safe fallback: only one source
|
|
223
|
+
|
|
224
|
+
test('data() works when only body has values', async () => {
|
|
225
|
+
const req = createRequest(
|
|
226
|
+
'POST',
|
|
227
|
+
'http://localhost',
|
|
228
|
+
{ test: 'ok' },
|
|
229
|
+
{
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const r = new PikkuFetchHTTPRequest(req)
|
|
235
|
+
const result = await r.data()
|
|
236
|
+
assert.deepEqual(result, { test: 'ok' })
|
|
237
|
+
})
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
PikkuHTTPRequest,
|
|
6
6
|
PikkuQuery,
|
|
7
7
|
} from './http-routes.types.js'
|
|
8
|
+
import { UnprocessableContentError } from '../errors/errors.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Abstract class representing a pikku request.
|
|
@@ -96,16 +97,71 @@ export class PikkuFetchHTTPRequest<In = unknown>
|
|
|
96
97
|
* @returns A promise that resolves to an object containing the combined data.
|
|
97
98
|
*/
|
|
98
99
|
public async data(): Promise<In> {
|
|
100
|
+
const body = await this.body()
|
|
101
|
+
const parts = [this.params(), this.query(), body]
|
|
102
|
+
const merged: Record<string, unknown> = {}
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
for (const [key, value] of Object.entries(part)) {
|
|
105
|
+
if (key in merged && !valuesAreEquivalent(merged[key], value)) {
|
|
106
|
+
throw new UnprocessableContentError(
|
|
107
|
+
`Conflicting values for key "${key}": "${merged[key]}" vs "${value}"`
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
merged[key] ??= value
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return merged as In
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async body(): Promise<any> {
|
|
117
|
+
const noBodyMethods: HTTPMethod[] = ['get', 'head', 'options']
|
|
118
|
+
if (noBodyMethods.includes(this.method())) {
|
|
119
|
+
return {}
|
|
120
|
+
}
|
|
121
|
+
|
|
99
122
|
let body: any = {}
|
|
123
|
+
const contentType = this.header('content-type') || ''
|
|
100
124
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
if (contentType.includes('application/json')) {
|
|
126
|
+
const parsed = await this.json()
|
|
127
|
+
body =
|
|
128
|
+
typeof parsed === 'object' &&
|
|
129
|
+
parsed !== null &&
|
|
130
|
+
!Array.isArray(parsed)
|
|
131
|
+
? parsed
|
|
132
|
+
: { data: parsed }
|
|
133
|
+
} else if (contentType.includes('text/')) {
|
|
134
|
+
const text = await this.request.text()
|
|
135
|
+
body = { data: text }
|
|
136
|
+
} else if (contentType.includes('application/octet-stream')) {
|
|
137
|
+
const buffer = await this.request.arrayBuffer()
|
|
138
|
+
body = { data: buffer }
|
|
139
|
+
} else if (contentType === 'application/x-www-form-urlencoded') {
|
|
140
|
+
const text = await this.request.text()
|
|
141
|
+
body = Object.fromEntries(new URLSearchParams(text))
|
|
142
|
+
} else {
|
|
143
|
+
throw new UnprocessableContentError(
|
|
144
|
+
`Unsupported content type ${contentType}`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
throw new UnprocessableContentError(`Error parsing body: ${e}`)
|
|
109
149
|
}
|
|
150
|
+
return body
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function valuesAreEquivalent(a: unknown, b: unknown): boolean {
|
|
155
|
+
return coerce(a) === coerce(b)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function coerce(value: unknown): string | number | boolean {
|
|
159
|
+
if (typeof value === 'boolean' || typeof value === 'number') return value
|
|
160
|
+
if (typeof value === 'string') {
|
|
161
|
+
if (value === 'true') return true
|
|
162
|
+
if (value === 'false') return false
|
|
163
|
+
const num = Number(value)
|
|
164
|
+
return isNaN(num) ? value : num
|
|
110
165
|
}
|
|
166
|
+
return value as any
|
|
111
167
|
}
|