@pikku/core 0.7.0 → 0.7.1

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/channel/channel-runner.js +1 -1
  3. package/dist/channel/serverless/serverless-channel-runner.js +1 -1
  4. package/dist/http/http-runner.js +4 -2
  5. package/dist/http/log-http-routes.js +1 -1
  6. package/lcov.info +4585 -0
  7. package/package.json +1 -1
  8. package/src/channel/channel-handler.ts +165 -0
  9. package/src/channel/channel-runner.ts +153 -0
  10. package/src/channel/channel-store.ts +29 -0
  11. package/src/channel/channel.types.ts +133 -0
  12. package/src/channel/eventhub-service.ts +30 -0
  13. package/src/channel/eventhub-store.ts +8 -0
  14. package/src/channel/index.ts +8 -0
  15. package/src/channel/local/index.ts +3 -0
  16. package/src/channel/local/local-channel-handler.ts +50 -0
  17. package/src/channel/local/local-channel-runner.test.ts +157 -0
  18. package/src/channel/local/local-channel-runner.ts +139 -0
  19. package/src/channel/local/local-eventhub-service.test.ts +95 -0
  20. package/src/channel/local/local-eventhub-service.ts +95 -0
  21. package/src/channel/log-channels.ts +20 -0
  22. package/src/channel/pikku-abstract-channel-handler.test.ts +52 -0
  23. package/src/channel/pikku-abstract-channel-handler.ts +38 -0
  24. package/src/channel/serverless/index.ts +1 -0
  25. package/src/channel/serverless/serverless-channel-runner.ts +220 -0
  26. package/src/errors/error-handler.ts +61 -0
  27. package/src/errors/error.test.ts +54 -0
  28. package/src/errors/errors.ts +339 -0
  29. package/src/errors/index.ts +2 -0
  30. package/src/function/function-runner.ts +91 -0
  31. package/src/function/functions.types.ts +77 -0
  32. package/src/function/index.ts +2 -0
  33. package/src/handle-error.ts +73 -0
  34. package/src/http/http-runner.test.ts +140 -0
  35. package/src/http/http-runner.ts +493 -0
  36. package/src/http/http.types.ts +250 -0
  37. package/src/http/incomingmessage-to-request-convertor.ts +0 -0
  38. package/src/http/index.ts +8 -0
  39. package/src/http/log-http-routes.ts +22 -0
  40. package/src/http/pikku-fetch-http-request.test.ts +237 -0
  41. package/src/http/pikku-fetch-http-request.ts +163 -0
  42. package/src/http/pikku-fetch-http-response.test.ts +82 -0
  43. package/src/http/pikku-fetch-http-response.ts +138 -0
  44. package/src/index.ts +20 -0
  45. package/src/middleware/auth-apikey.ts +66 -0
  46. package/src/middleware/auth-bearer.ts +66 -0
  47. package/src/middleware/auth-cookie.ts +103 -0
  48. package/src/middleware/index.ts +3 -0
  49. package/src/middleware/timeout.ts +13 -0
  50. package/src/middleware-runner.ts +43 -0
  51. package/src/permissions.test.ts +58 -0
  52. package/src/permissions.ts +43 -0
  53. package/src/pikku-function.ts +1 -0
  54. package/src/pikku-request.ts +23 -0
  55. package/src/pikku-response.ts +5 -0
  56. package/src/pikku-state.ts +87 -0
  57. package/src/scheduler/index.ts +5 -0
  58. package/src/scheduler/log-schedulers.ts +20 -0
  59. package/src/scheduler/scheduler-runner.ts +105 -0
  60. package/src/scheduler/scheduler.types.ts +33 -0
  61. package/src/schema.test.ts +57 -0
  62. package/src/schema.ts +99 -0
  63. package/src/services/content-service.ts +68 -0
  64. package/src/services/index.ts +18 -0
  65. package/src/services/jwt-service.ts +30 -0
  66. package/src/services/local-content.ts +105 -0
  67. package/src/services/local-secrets.ts +43 -0
  68. package/src/services/local-variables.ts +17 -0
  69. package/src/services/logger-console.ts +97 -0
  70. package/src/services/logger.ts +57 -0
  71. package/src/services/schema-service.ts +26 -0
  72. package/src/services/secret-service.ts +17 -0
  73. package/src/services/user-session-service.ts +53 -0
  74. package/src/services/variables-service.ts +6 -0
  75. package/src/time-utils.test.ts +56 -0
  76. package/src/time-utils.ts +32 -0
  77. package/src/types/core.types.ts +178 -0
  78. package/src/utils.ts +40 -0
  79. package/tsconfig.json +13 -0
  80. package/tsconfig.tsbuildinfo +1 -0
  81. package/dist/http/http-route-runner.d.ts +0 -90
  82. package/dist/http/http-route-runner.js +0 -325
  83. package/dist/http/http-routes.types.d.ts +0 -179
  84. package/dist/http/http-routes.types.js +0 -1
  85. package/dist/http/pikku-http-request.d.ts +0 -56
  86. package/dist/http/pikku-http-request.js +0 -95
  87. package/dist/http/pikku-http-response.d.ts +0 -13
  88. package/dist/http/pikku-http-response.js +0 -60
  89. package/dist/parse-relative-time-offset.d.ts +0 -12
  90. package/dist/parse-relative-time-offset.js +0 -20
  91. package/dist/pikku-func.d.ts +0 -12
  92. package/dist/pikku-func.js +0 -39
  93. package/dist/types/functions.types.d.ts +0 -37
  94. package/dist/types/functions.types.js +0 -1
@@ -0,0 +1,250 @@
1
+ import type { SerializeOptions } from 'cookie'
2
+ import type { PikkuError } from '../errors/error-handler.js'
3
+ import type {
4
+ APIDocs,
5
+ CoreServices,
6
+ CoreSingletonServices,
7
+ CoreUserSession,
8
+ CreateSessionServices,
9
+ PikkuMiddleware,
10
+ } from '../types/core.types.js'
11
+ import type {
12
+ CoreAPIFunction,
13
+ CoreAPIFunctionSessionless,
14
+ CoreAPIPermission,
15
+ CorePermissionGroup,
16
+ } from '../function/functions.types.js'
17
+
18
+ type ExtractRouteParams<S extends string> =
19
+ S extends `${string}:${infer Param}/${infer Rest}`
20
+ ? Param | ExtractRouteParams<`/${Rest}`>
21
+ : S extends `${string}:${infer Param}`
22
+ ? Param
23
+ : never
24
+
25
+ export type AssertRouteParams<In, Route extends string> =
26
+ ExtractRouteParams<Route> extends keyof In
27
+ ? unknown
28
+ : ['Error: Route parameters', ExtractRouteParams<Route>, 'not in', keyof In]
29
+
30
+ export type RunRouteOptions = Partial<{
31
+ skipUserSession: boolean
32
+ respondWith404: boolean
33
+ logWarningsForStatusCodes: number[]
34
+ coerceDataFromSchema: boolean
35
+ bubbleErrors: boolean
36
+ generateRequestId: () => string
37
+ }>
38
+
39
+ export type RunRouteParams = {
40
+ singletonServices: CoreSingletonServices
41
+ createSessionServices: CreateSessionServices<
42
+ CoreSingletonServices,
43
+ CoreServices<CoreSingletonServices>,
44
+ CoreUserSession
45
+ >
46
+ }
47
+
48
+ /**
49
+ * Represents the HTTP methods supported for API routes.
50
+ */
51
+ export type HTTPMethod =
52
+ | 'post'
53
+ | 'get'
54
+ | 'delete'
55
+ | 'patch'
56
+ | 'head'
57
+ | 'put'
58
+ | 'options'
59
+
60
+ /**
61
+ * Represents an API route without a function, including metadata such as content type, route, and timeout settings.
62
+ */
63
+ export type CoreHTTPFunction = {
64
+ contentType?: 'xml' | 'json'
65
+ route: string
66
+ eventChannel?: false
67
+ returnsJSON?: false
68
+ timeout?: number
69
+ docs?: Partial<{
70
+ description: string
71
+ response: string
72
+ errors: Array<typeof PikkuError>
73
+ tags: string[]
74
+ }>
75
+ }
76
+ /**
77
+ * Represents a http interaction within Pikku, including a request and response.
78
+ */
79
+ export interface PikkuHTTP {
80
+ request?: PikkuHTTPRequest
81
+ response?: PikkuHTTPResponse
82
+ }
83
+
84
+ /**
85
+ * Represents request headers as either a record or a function to get headers by name.
86
+ */
87
+ export type RequestHeaders =
88
+ | Record<string, string | string[] | undefined>
89
+ | ((headerName: string) => string | string[] | undefined)
90
+
91
+ /**
92
+ * Represents a query object for Pikku, where each key can be a string, a value, or an array of values.
93
+ */
94
+ export type PikkuQuery<T = Record<string, string | undefined>> = Record<
95
+ string,
96
+ string | T | null | Array<T | null>
97
+ >
98
+
99
+ /**
100
+ * Represents a core API route, which can have different configurations depending on whether it requires authentication and permissions.
101
+ *
102
+ * @template In - The input type.
103
+ * @template Out - The output type.
104
+ * @template R - The route string type.
105
+ * @template APIFunction - The API function type, defaults to `CoreAPIFunction`.
106
+ * @template APIFunctionSessionless - The sessionless API function type, defaults to `CoreAPIFunctionSessionless`.
107
+ * @template APIPermission - The permission function type, defaults to `CoreAPIPermission`.
108
+ */
109
+ export type CoreHTTPFunctionRoute<
110
+ In,
111
+ Out,
112
+ R extends string,
113
+ APIFunction = CoreAPIFunction<In, Out>,
114
+ APIFunctionSessionless = CoreAPIFunctionSessionless<In, Out>,
115
+ APIPermission = CoreAPIPermission<In>,
116
+ APIMiddleware = PikkuMiddleware,
117
+ > =
118
+ | (CoreHTTPFunction & {
119
+ route: R
120
+ method: HTTPMethod
121
+ func: APIFunction
122
+ permissions?: CorePermissionGroup<APIPermission>
123
+ auth?: true
124
+ tags?: string[]
125
+ middleware?: APIMiddleware[]
126
+ sse?: undefined
127
+ })
128
+ | (CoreHTTPFunction & {
129
+ route: R
130
+ method: HTTPMethod
131
+ func: APIFunctionSessionless
132
+ permissions?: undefined
133
+ auth?: false
134
+ tags?: string[]
135
+ middleware?: APIMiddleware[]
136
+ sse?: undefined
137
+ })
138
+ | (CoreHTTPFunction & {
139
+ route: R
140
+ method: 'get'
141
+ func: APIFunction
142
+ permissions?: CorePermissionGroup<APIPermission>
143
+ auth?: true
144
+ sse?: boolean
145
+ tags?: string[]
146
+ middleware?: APIMiddleware[]
147
+ })
148
+ | (CoreHTTPFunction & {
149
+ route: R
150
+ method: 'get'
151
+ func: APIFunctionSessionless
152
+ permissions?: undefined
153
+ auth?: false
154
+ sse?: boolean
155
+ tags?: string[]
156
+ middleware?: APIMiddleware[]
157
+ })
158
+ | (CoreHTTPFunction & {
159
+ route: R
160
+ method: 'post'
161
+ func: APIFunction
162
+ permissions?: CorePermissionGroup<APIPermission>
163
+ auth?: true
164
+ query?: Array<keyof In>
165
+ tags?: string[]
166
+ middleware?: APIMiddleware[]
167
+ sse?: undefined
168
+ })
169
+ | (CoreHTTPFunction & {
170
+ route: R
171
+ method: 'post'
172
+ func: APIFunctionSessionless
173
+ permissions?: undefined
174
+ auth?: false
175
+ query?: Array<keyof In>
176
+ tags?: string[]
177
+ middleware?: APIMiddleware[]
178
+ sse?: undefined
179
+ })
180
+
181
+ /**
182
+ * Represents the input types for route metadata, including parameters, query, and body types.
183
+ */
184
+ export type HTTPFunctionMetaInputTypes = {
185
+ params?: string
186
+ query?: string
187
+ body?: string
188
+ }
189
+
190
+ /**
191
+ * Represents metadata for a set of routes, including route details, methods, input/output types, and documentation.
192
+ */
193
+ export type HTTPRouteMeta = {
194
+ pikkuFuncName: string
195
+ route: string
196
+ method: HTTPMethod
197
+ params?: string[]
198
+ query?: string[]
199
+ input: string | null
200
+ output: string | null
201
+ inputTypes?: HTTPFunctionMetaInputTypes
202
+ docs?: APIDocs
203
+ tags?: string[]
204
+ sse?: true
205
+ }
206
+ export type HTTPRoutesMeta = Array<HTTPRouteMeta>
207
+
208
+ export type HTTPFunctionsMeta = Array<{
209
+ name: string
210
+ inputs: string[] | null
211
+ outputs: string[] | null
212
+ }>
213
+
214
+ export type HTTPRouteMiddleware = {
215
+ route: string
216
+ middleware: PikkuMiddleware[]
217
+ }
218
+
219
+ export interface PikkuHTTPRequest<In = unknown> {
220
+ method(): HTTPMethod
221
+ path(): string
222
+ data(): Promise<In>
223
+ json(): Promise<unknown>
224
+ arrayBuffer(): Promise<ArrayBuffer>
225
+ header(headerName: string): string | null
226
+ cookie(name?: string): string | null
227
+ params(): Partial<Record<string, string | string[]>>
228
+ setParams(params: Record<string, string | string[] | undefined>): void
229
+ query(): PikkuQuery
230
+ }
231
+
232
+ export interface PikkuHTTPResponse {
233
+ status(code: number): this
234
+ cookie(name: string, value: string | null, options: SerializeOptions): this
235
+ header(name: string, value: string | string[]): this
236
+ arrayBuffer(
237
+ data:
238
+ | ArrayBuffer
239
+ | ArrayBufferView
240
+ | Blob
241
+ | string
242
+ | FormData
243
+ | URLSearchParams
244
+ | ReadableStream
245
+ ): this
246
+ json(data: unknown): this
247
+ redirect(location: string, status?: number): this
248
+ close?: () => void
249
+ setMode?: (mode: 'stream') => void
250
+ }
@@ -0,0 +1,8 @@
1
+ export * from './pikku-fetch-http-request.js'
2
+ export * from './pikku-fetch-http-response.js'
3
+ export * from './incomingmessage-to-request-convertor.js'
4
+ export * from './log-http-routes.js'
5
+
6
+ export { fetch, fetchData, addHTTPRoute } from './http-runner.js'
7
+
8
+ export type * from './http.types.js'
@@ -0,0 +1,22 @@
1
+ import { pikkuState } from '../pikku-state.js'
2
+ import { Logger } from '../services/index.js'
3
+
4
+ /**
5
+ * Logs all the loaded routes.
6
+ * @param logger - A logger for logging information.
7
+ */
8
+ export const logRoutes = (logger: Logger) => {
9
+ const routesByType = pikkuState('http', 'routes')
10
+ if (routesByType.size === 0) {
11
+ logger.info('No routes added')
12
+ return
13
+ }
14
+
15
+ let routesMessage = 'Routes loaded:'
16
+ routesByType.forEach((routes) => {
17
+ routes.forEach((route) => {
18
+ routesMessage += `\n\t- ${route.method.toUpperCase()} -> ${route.route}`
19
+ })
20
+ })
21
+ logger.info(routesMessage)
22
+ }
@@ -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
+ })
@@ -0,0 +1,163 @@
1
+ import { parse as parseQuery } from 'picoquery'
2
+ import { parse as parseCookie } from 'cookie'
3
+ import { HTTPMethod, PikkuHTTPRequest, PikkuQuery } from './http.types.js'
4
+ import { UnprocessableContentError } from '../errors/errors.js'
5
+
6
+ /**
7
+ * Abstract class representing a pikku request.
8
+ * @template In - The type of the request body.
9
+ * @group RequestResponse
10
+ */
11
+ export class PikkuFetchHTTPRequest<In = unknown>
12
+ implements PikkuHTTPRequest<In>
13
+ {
14
+ #cookies: Partial<Record<string, string>> | undefined
15
+ #params: Partial<Record<string, string | string[]>> = {}
16
+ #url: URL
17
+
18
+ constructor(private request: Request) {
19
+ this.#url = new URL(request.url)
20
+ }
21
+
22
+ public method(): HTTPMethod {
23
+ return this.request.method.toLowerCase() as HTTPMethod
24
+ }
25
+
26
+ public path(): string {
27
+ return this.#url.pathname
28
+ }
29
+
30
+ /**
31
+ * Retrieves the request body.
32
+ * @returns A promise that resolves to the request body.
33
+ */
34
+ public json(): Promise<In> {
35
+ return this.request.json()
36
+ }
37
+
38
+ /**
39
+ * Retrieves the raw request body as a Buffer.
40
+ * @returns A promise that resolves to the raw request body.
41
+ */
42
+ public arrayBuffer(): Promise<ArrayBuffer> {
43
+ return this.request.arrayBuffer()
44
+ }
45
+
46
+ /**
47
+ * Retrieves the value of a specific header.
48
+ * @param headerName - The name of the header to retrieve.
49
+ * @returns The value of the header, or undefined if the header is not found.
50
+ */
51
+ public header(headerName: string): string | null {
52
+ return this.request.headers.get(headerName.toLowerCase())
53
+ }
54
+
55
+ /**
56
+ * Retrieves the cookies from the request.
57
+ * @returns An object containing the cookies.
58
+ */
59
+ public cookie(cookieName: string): string | null {
60
+ const cookieHeader = this.header('cookie')
61
+ this.#cookies = cookieHeader ? parseCookie(cookieHeader) : {}
62
+ return this.#cookies[cookieName] || null
63
+ }
64
+
65
+ /**
66
+ * Retrieves the request parameters.
67
+ * @returns An object containing the request parameters.
68
+ */
69
+ public params(): Partial<Record<string, string | string[]>> {
70
+ return this.#params
71
+ }
72
+
73
+ /**
74
+ * Sets the request parameters.
75
+ * @param params - An object containing the request parameters to set.
76
+ */
77
+ public setParams(
78
+ params: Record<string, string | string[] | undefined>
79
+ ): void {
80
+ this.#params = params
81
+ }
82
+
83
+ /**
84
+ * Retrieves the query parameters from the request.
85
+ * @returns An object containing the query parameters.
86
+ */
87
+ public query(): PikkuQuery {
88
+ return parseQuery(this.#url.searchParams.toString()) as PikkuQuery
89
+ }
90
+
91
+ /**
92
+ * Retrieves the combined data from the request, including parameters, query, and body.
93
+ * @returns A promise that resolves to an object containing the combined data.
94
+ */
95
+ public async data(): Promise<In> {
96
+ const body = await this.body()
97
+ const parts = [this.params(), this.query(), body]
98
+ const merged: Record<string, unknown> = {}
99
+ for (const part of parts) {
100
+ for (const [key, value] of Object.entries(part)) {
101
+ if (key in merged && !valuesAreEquivalent(merged[key], value)) {
102
+ throw new UnprocessableContentError(
103
+ `Conflicting values for key "${key}": "${merged[key]}" vs "${value}"`
104
+ )
105
+ }
106
+ merged[key] ??= value
107
+ }
108
+ }
109
+ return merged as In
110
+ }
111
+
112
+ private async body(): Promise<any> {
113
+ const noBodyMethods: HTTPMethod[] = ['get', 'head', 'options']
114
+ if (noBodyMethods.includes(this.method())) {
115
+ return {}
116
+ }
117
+
118
+ let body: any = {}
119
+ const contentType = this.header('content-type') || ''
120
+ try {
121
+ if (contentType.includes('application/json')) {
122
+ const parsed = await this.json()
123
+ body =
124
+ typeof parsed === 'object' &&
125
+ parsed !== null &&
126
+ !Array.isArray(parsed)
127
+ ? parsed
128
+ : { data: parsed }
129
+ } else if (contentType.includes('text/')) {
130
+ const text = await this.request.text()
131
+ body = { data: text }
132
+ } else if (contentType.includes('application/octet-stream')) {
133
+ const buffer = await this.request.arrayBuffer()
134
+ body = { data: buffer }
135
+ } else if (contentType === 'application/x-www-form-urlencoded') {
136
+ const text = await this.request.text()
137
+ body = Object.fromEntries(new URLSearchParams(text))
138
+ } else {
139
+ throw new UnprocessableContentError(
140
+ `Unsupported content type ${contentType}`
141
+ )
142
+ }
143
+ } catch (e) {
144
+ throw new UnprocessableContentError(`Error parsing body: ${e}`)
145
+ }
146
+ return body
147
+ }
148
+ }
149
+
150
+ function valuesAreEquivalent(a: unknown, b: unknown): boolean {
151
+ return coerce(a) === coerce(b)
152
+ }
153
+
154
+ function coerce(value: unknown): string | number | boolean {
155
+ if (typeof value === 'boolean' || typeof value === 'number') return value
156
+ if (typeof value === 'string') {
157
+ if (value === 'true') return true
158
+ if (value === 'false') return false
159
+ const num = Number(value)
160
+ return isNaN(num) ? value : num
161
+ }
162
+ return value as any
163
+ }