@pikku/core 0.6.22 → 0.6.24
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-handler.js +1 -1
- package/dist/channel/channel-runner.d.ts +1 -1
- package/dist/channel/channel-runner.js +1 -1
- package/dist/channel/local/local-channel-runner.js +5 -5
- package/dist/channel/serverless/serverless-channel-runner.js +4 -4
- package/dist/http/http-route-runner.js +9 -14
- 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/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 +1694 -967
- package/package.json +1 -1
- package/src/channel/channel-handler.ts +1 -1
- package/src/channel/channel-runner.ts +3 -3
- package/src/channel/local/local-channel-runner.test.ts +0 -2
- package/src/channel/local/local-channel-runner.ts +5 -5
- package/src/channel/serverless/serverless-channel-runner.ts +4 -7
- package/src/http/http-route-runner.test.ts +1 -1
- package/src/http/http-route-runner.ts +23 -20
- 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/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
|
@@ -60,7 +60,7 @@ export const openChannel = async ({
|
|
|
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>
|
|
@@ -86,9 +86,9 @@ export const openChannel = async ({
|
|
|
86
86
|
if (coerceToArray && schemaName) {
|
|
87
87
|
coerceQueryStringToArray(schemaName, openingData)
|
|
88
88
|
}
|
|
89
|
-
validateSchema(
|
|
89
|
+
await validateSchema(
|
|
90
90
|
singletonServices.logger,
|
|
91
|
-
singletonServices.
|
|
91
|
+
singletonServices.schema,
|
|
92
92
|
schemaName,
|
|
93
93
|
openingData
|
|
94
94
|
)
|
|
@@ -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
|
|
@@ -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) {
|
|
@@ -52,7 +52,7 @@ export const runLocalChannel = async ({
|
|
|
52
52
|
singletonServices,
|
|
53
53
|
skipUserSession,
|
|
54
54
|
coerceToArray,
|
|
55
|
-
|
|
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 || [],
|
|
@@ -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,
|
|
@@ -90,7 +87,7 @@ export const runChannelConnect = async ({
|
|
|
90
87
|
route,
|
|
91
88
|
singletonServices,
|
|
92
89
|
coerceToArray,
|
|
93
|
-
|
|
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,
|
|
@@ -16,7 +20,10 @@ import {
|
|
|
16
20
|
} from '../errors/errors.js'
|
|
17
21
|
import { closeSessionServices } from '../utils.js'
|
|
18
22
|
import { coerceQueryStringToArray, validateSchema } from '../schema.js'
|
|
19
|
-
import {
|
|
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
|
},
|
|
@@ -214,7 +221,7 @@ const executeRouteWithMiddleware = async (
|
|
|
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,22 +266,18 @@ 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()
|
|
265
273
|
|
|
266
274
|
// Validate request data against the defined schema, if any
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
data
|
|
274
|
-
)
|
|
275
|
-
} catch (e) {
|
|
276
|
-
// TODO: Implement proper handling for schema validation failures
|
|
277
|
-
}
|
|
275
|
+
await validateSchema(
|
|
276
|
+
singletonServices.logger,
|
|
277
|
+
singletonServices.schema,
|
|
278
|
+
schemaName,
|
|
279
|
+
data
|
|
280
|
+
)
|
|
278
281
|
|
|
279
282
|
// Coerce query string parameters to arrays if specified by the schema
|
|
280
283
|
if (options.coerceToArray && schemaName) {
|
|
@@ -312,7 +315,7 @@ const executeRouteWithMiddleware = async (
|
|
|
312
315
|
|
|
313
316
|
// Execute middleware, then run the main logic
|
|
314
317
|
await runMiddleware(
|
|
315
|
-
{ ...singletonServices,
|
|
318
|
+
{ ...singletonServices, userSession },
|
|
316
319
|
{ http },
|
|
317
320
|
middleware,
|
|
318
321
|
runMain
|
|
@@ -397,7 +400,7 @@ export const fetchData = async <In, Out>(
|
|
|
397
400
|
bubbleErrors = false,
|
|
398
401
|
}: RunRouteOptions & RunRouteParams
|
|
399
402
|
): Promise<Out | void> => {
|
|
400
|
-
const
|
|
403
|
+
const userSession = new PikkuUserSessionService()
|
|
401
404
|
let sessionServices: SessionServices<typeof singletonServices> | undefined
|
|
402
405
|
let result: Out
|
|
403
406
|
|
|
@@ -427,7 +430,7 @@ export const fetchData = async <In, Out>(
|
|
|
427
430
|
;({ result, sessionServices } = await executeRouteWithMiddleware(
|
|
428
431
|
{
|
|
429
432
|
singletonServices,
|
|
430
|
-
|
|
433
|
+
userSession,
|
|
431
434
|
createSessionServices,
|
|
432
435
|
skipUserSession,
|
|
433
436
|
},
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'assert'
|
|
3
|
+
import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js' // Adjust path if needed
|
|
4
|
+
|
|
5
|
+
describe('PikkuFetchHTTPResponse', () => {
|
|
6
|
+
test('sets status code', () => {
|
|
7
|
+
const res = new PikkuFetchHTTPResponse().status(404).toResponse()
|
|
8
|
+
assert.strictEqual(res.status, 404)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('sets headers', async () => {
|
|
12
|
+
const res = new PikkuFetchHTTPResponse()
|
|
13
|
+
.header('X-Test', 'value')
|
|
14
|
+
.header('X-Multi', ['a', 'b'])
|
|
15
|
+
.toResponse()
|
|
16
|
+
|
|
17
|
+
assert.strictEqual(res.headers.get('X-Test'), 'value')
|
|
18
|
+
const multi = res.headers.get('X-Multi')?.split(', ')
|
|
19
|
+
assert.deepEqual(multi, ['a', 'b'])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('sets cookies', () => {
|
|
23
|
+
const res = new PikkuFetchHTTPResponse()
|
|
24
|
+
.cookie('token', 'abc123', { httpOnly: true, path: '/' })
|
|
25
|
+
.cookie('other', 'val', { maxAge: 3600 })
|
|
26
|
+
.toResponse()
|
|
27
|
+
|
|
28
|
+
const setCookieHeader = res.headers.get('Set-Cookie')
|
|
29
|
+
assert.ok(setCookieHeader)
|
|
30
|
+
|
|
31
|
+
const cookies = setCookieHeader!.split(', ')
|
|
32
|
+
assert.ok(cookies.some((c) => c.startsWith('token=abc123')))
|
|
33
|
+
assert.ok(cookies.some((c) => c.startsWith('other=val')))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('json sets correct content type and body', async () => {
|
|
37
|
+
const res = new PikkuFetchHTTPResponse()
|
|
38
|
+
.json({ hello: 'world' })
|
|
39
|
+
.toResponse()
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(res.headers.get('Content-Type'), 'application/json')
|
|
42
|
+
const body = await res.json()
|
|
43
|
+
assert.deepEqual(body, { hello: 'world' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('text sets correct content type and body', async () => {
|
|
47
|
+
const res = new PikkuFetchHTTPResponse().text('hello').toResponse()
|
|
48
|
+
|
|
49
|
+
assert.strictEqual(res.headers.get('Content-Type'), 'text/plain')
|
|
50
|
+
const text = await res.text()
|
|
51
|
+
assert.strictEqual(text, 'hello')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('html sets correct content type and body', async () => {
|
|
55
|
+
const res = new PikkuFetchHTTPResponse().html('<p>hi</p>').toResponse()
|
|
56
|
+
|
|
57
|
+
assert.strictEqual(res.headers.get('Content-Type'), 'text/html')
|
|
58
|
+
const text = await res.text()
|
|
59
|
+
assert.strictEqual(text, '<p>hi</p>')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('arrayBuffer sets correct content type', async () => {
|
|
63
|
+
const buffer = new Uint8Array([1, 2, 3]).buffer
|
|
64
|
+
const res = new PikkuFetchHTTPResponse().arrayBuffer(buffer).toResponse()
|
|
65
|
+
|
|
66
|
+
assert.strictEqual(
|
|
67
|
+
res.headers.get('Content-Type'),
|
|
68
|
+
'application/octet-stream'
|
|
69
|
+
)
|
|
70
|
+
const body = await res.arrayBuffer()
|
|
71
|
+
assert.deepEqual(new Uint8Array(body), new Uint8Array([1, 2, 3]))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('redirect sets Location header and status', () => {
|
|
75
|
+
const res = new PikkuFetchHTTPResponse()
|
|
76
|
+
.redirect('/login', 301)
|
|
77
|
+
.toResponse()
|
|
78
|
+
|
|
79
|
+
assert.strictEqual(res.status, 301)
|
|
80
|
+
assert.strictEqual(res.headers.get('Location'), '/login')
|
|
81
|
+
})
|
|
82
|
+
})
|