effect-start 0.15.0 → 0.16.0
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/package.json +1 -1
- package/src/ContentNegotiation.test.ts +103 -0
- package/src/ContentNegotiation.ts +10 -3
- package/src/Entity.test.ts +592 -0
- package/src/Entity.ts +362 -0
- package/src/Http.test.ts +315 -20
- package/src/Http.ts +153 -11
- package/src/PathPattern.ts +3 -1
- package/src/Route.ts +22 -10
- package/src/RouteBody.test.ts +81 -29
- package/src/RouteBody.ts +122 -35
- package/src/RouteHook.ts +15 -14
- package/src/RouteHttp.test.ts +2546 -83
- package/src/RouteHttp.ts +321 -113
- package/src/RouteHttpTracer.ts +92 -0
- package/src/RouteMount.test.ts +23 -10
- package/src/RouteMount.ts +161 -4
- package/src/RouteSchema.test.ts +346 -0
- package/src/RouteSchema.ts +386 -7
- package/src/RouteTree.test.ts +233 -85
- package/src/RouteTree.ts +98 -44
- package/src/StreamExtra.ts +21 -1
- package/src/Values.test.ts +263 -0
- package/src/Values.ts +60 -0
- package/src/bun/BunHttpServer.ts +23 -7
- package/src/bun/BunRoute.test.ts +162 -0
- package/src/bun/BunRoute.ts +146 -105
- package/src/index.ts +1 -0
- package/src/testing/TestLogger.test.ts +0 -3
- package/src/testing/TestLogger.ts +15 -9
package/src/Entity.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as ParseResult from "effect/ParseResult"
|
|
3
|
+
import * as Pipeable from "effect/Pipeable"
|
|
4
|
+
import * as Predicate from "effect/Predicate"
|
|
5
|
+
import * as Schema from "effect/Schema"
|
|
6
|
+
import * as Stream from "effect/Stream"
|
|
7
|
+
import * as StreamExtra from "./StreamExtra.ts"
|
|
8
|
+
import * as Values from "./Values.ts"
|
|
9
|
+
|
|
10
|
+
export const TypeId: unique symbol = Symbol.for("effect-start/Entity")
|
|
11
|
+
export type TypeId = typeof TypeId
|
|
12
|
+
|
|
13
|
+
const textDecoder = new TextDecoder()
|
|
14
|
+
const textEncoder = new TextEncoder()
|
|
15
|
+
|
|
16
|
+
function isBinary(v: unknown): v is Uint8Array | ArrayBuffer {
|
|
17
|
+
return v instanceof Uint8Array || v instanceof ArrayBuffer
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Header keys are guaranteed to be lowercase.
|
|
22
|
+
*/
|
|
23
|
+
export type Headers = {
|
|
24
|
+
[header: string]: string | null | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Entity<
|
|
28
|
+
T = unknown,
|
|
29
|
+
E = never,
|
|
30
|
+
> extends Pipeable.Pipeable {
|
|
31
|
+
readonly [TypeId]: TypeId
|
|
32
|
+
readonly body: T
|
|
33
|
+
readonly headers: Headers
|
|
34
|
+
/**
|
|
35
|
+
* Accepts any valid URI (Uniform Resource Identifier), including URLs
|
|
36
|
+
* (http://, https://, file://), URNs (urn:isbn:...), S3 URIs (s3://bucket/key),
|
|
37
|
+
* data URIs, and other schemes. While commonly called "URL" in many APIs,
|
|
38
|
+
* this property handles URIs as the correct superset term per RFC 3986.
|
|
39
|
+
*/
|
|
40
|
+
readonly url: string | undefined
|
|
41
|
+
readonly status: number | undefined
|
|
42
|
+
readonly text: T extends string
|
|
43
|
+
? Effect.Effect<T, ParseResult.ParseError | E>
|
|
44
|
+
: Effect.Effect<string, ParseResult.ParseError | E>
|
|
45
|
+
readonly json: [T] extends [Effect.Effect<infer A, any, any>]
|
|
46
|
+
? Effect.Effect<
|
|
47
|
+
A extends string | Uint8Array | ArrayBuffer ? unknown : A,
|
|
48
|
+
ParseResult.ParseError | E
|
|
49
|
+
>
|
|
50
|
+
: [T] extends [Stream.Stream<any, any, any>]
|
|
51
|
+
? Effect.Effect<unknown, ParseResult.ParseError | E>
|
|
52
|
+
: [T] extends [string | Uint8Array | ArrayBuffer]
|
|
53
|
+
? Effect.Effect<unknown, ParseResult.ParseError | E>
|
|
54
|
+
: [T] extends [Values.Json]
|
|
55
|
+
? Effect.Effect<T, ParseResult.ParseError | E>
|
|
56
|
+
: Effect.Effect<unknown, ParseResult.ParseError | E>
|
|
57
|
+
readonly bytes: Effect.Effect<Uint8Array, ParseResult.ParseError | E>
|
|
58
|
+
readonly stream: T extends Stream.Stream<infer A, infer E1, any>
|
|
59
|
+
? Stream.Stream<A, ParseResult.ParseError | E | E1>
|
|
60
|
+
: Stream.Stream<Uint8Array, ParseResult.ParseError | E>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Proto extends Pipeable.Pipeable {
|
|
64
|
+
readonly [TypeId]: TypeId
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseJson(s: string): Effect.Effect<unknown, ParseResult.ParseError> {
|
|
68
|
+
try {
|
|
69
|
+
return Effect.succeed(JSON.parse(s))
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return Effect.fail(
|
|
72
|
+
new ParseResult.ParseError({
|
|
73
|
+
issue: new ParseResult.Type(
|
|
74
|
+
Schema.Unknown.ast,
|
|
75
|
+
s,
|
|
76
|
+
e instanceof Error ? e.message : "Failed to parse JSON",
|
|
77
|
+
),
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getText(
|
|
84
|
+
self: Entity<unknown, unknown>,
|
|
85
|
+
): Effect.Effect<string, ParseResult.ParseError | unknown> {
|
|
86
|
+
const v = self.body
|
|
87
|
+
if (StreamExtra.isStream(v)) {
|
|
88
|
+
return Stream.mkString(
|
|
89
|
+
Stream.decodeText(v as Stream.Stream<Uint8Array, unknown, never>),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
if (Effect.isEffect(v)) {
|
|
93
|
+
return Effect.flatMap(
|
|
94
|
+
v as Effect.Effect<unknown, unknown, never>,
|
|
95
|
+
(inner): Effect.Effect<string, ParseResult.ParseError | unknown> => {
|
|
96
|
+
if (isEntity(inner)) {
|
|
97
|
+
return inner.text
|
|
98
|
+
}
|
|
99
|
+
if (typeof inner === "string") {
|
|
100
|
+
return Effect.succeed(inner)
|
|
101
|
+
}
|
|
102
|
+
if (isBinary(inner)) {
|
|
103
|
+
return Effect.succeed(textDecoder.decode(inner))
|
|
104
|
+
}
|
|
105
|
+
return Effect.fail(mismatch(Schema.String, inner))
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
if (typeof v === "string") {
|
|
110
|
+
return Effect.succeed(v)
|
|
111
|
+
}
|
|
112
|
+
if (isBinary(v)) {
|
|
113
|
+
return Effect.succeed(textDecoder.decode(v))
|
|
114
|
+
}
|
|
115
|
+
return Effect.fail(mismatch(Schema.String, v))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getJson(
|
|
119
|
+
self: Entity<unknown, unknown>,
|
|
120
|
+
): Effect.Effect<unknown, ParseResult.ParseError | unknown> {
|
|
121
|
+
const v = self.body
|
|
122
|
+
if (StreamExtra.isStream(v)) {
|
|
123
|
+
return Effect.flatMap(getText(self), parseJson)
|
|
124
|
+
}
|
|
125
|
+
if (Effect.isEffect(v)) {
|
|
126
|
+
return Effect.flatMap(
|
|
127
|
+
v as Effect.Effect<unknown, unknown, never>,
|
|
128
|
+
(inner): Effect.Effect<unknown, ParseResult.ParseError | unknown> => {
|
|
129
|
+
if (isEntity(inner)) {
|
|
130
|
+
return inner.json
|
|
131
|
+
}
|
|
132
|
+
if (typeof inner === "object" && inner !== null && !isBinary(inner)) {
|
|
133
|
+
return Effect.succeed(inner)
|
|
134
|
+
}
|
|
135
|
+
if (typeof inner === "string") {
|
|
136
|
+
return parseJson(inner)
|
|
137
|
+
}
|
|
138
|
+
if (isBinary(inner)) {
|
|
139
|
+
return parseJson(textDecoder.decode(inner))
|
|
140
|
+
}
|
|
141
|
+
return Effect.fail(mismatch(Schema.Unknown, inner))
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (typeof v === "object" && v !== null && !isBinary(v)) {
|
|
146
|
+
return Effect.succeed(v)
|
|
147
|
+
}
|
|
148
|
+
if (typeof v === "string") {
|
|
149
|
+
return parseJson(v)
|
|
150
|
+
}
|
|
151
|
+
if (isBinary(v)) {
|
|
152
|
+
return parseJson(textDecoder.decode(v))
|
|
153
|
+
}
|
|
154
|
+
return Effect.fail(mismatch(Schema.Unknown, v))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getBytes(
|
|
158
|
+
self: Entity<unknown, unknown>,
|
|
159
|
+
): Effect.Effect<Uint8Array, ParseResult.ParseError | unknown> {
|
|
160
|
+
const v = self.body
|
|
161
|
+
if (StreamExtra.isStream(v)) {
|
|
162
|
+
return Stream.runFold(
|
|
163
|
+
v as Stream.Stream<Uint8Array, unknown, never>,
|
|
164
|
+
new Uint8Array(0),
|
|
165
|
+
Values.concatBytes,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
if (Effect.isEffect(v)) {
|
|
169
|
+
return Effect.flatMap(
|
|
170
|
+
v as Effect.Effect<unknown, unknown, never>,
|
|
171
|
+
(inner): Effect.Effect<Uint8Array, ParseResult.ParseError | unknown> => {
|
|
172
|
+
if (isEntity(inner)) {
|
|
173
|
+
return inner.bytes
|
|
174
|
+
}
|
|
175
|
+
if (inner instanceof Uint8Array) {
|
|
176
|
+
return Effect.succeed(inner)
|
|
177
|
+
}
|
|
178
|
+
if (inner instanceof ArrayBuffer) {
|
|
179
|
+
return Effect.succeed(new Uint8Array(inner))
|
|
180
|
+
}
|
|
181
|
+
if (typeof inner === "string") {
|
|
182
|
+
return Effect.succeed(textEncoder.encode(inner))
|
|
183
|
+
}
|
|
184
|
+
return Effect.fail(mismatch(Schema.Uint8ArrayFromSelf, inner))
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (v instanceof Uint8Array) {
|
|
189
|
+
return Effect.succeed(v)
|
|
190
|
+
}
|
|
191
|
+
if (v instanceof ArrayBuffer) {
|
|
192
|
+
return Effect.succeed(new Uint8Array(v))
|
|
193
|
+
}
|
|
194
|
+
if (typeof v === "string") {
|
|
195
|
+
return Effect.succeed(textEncoder.encode(v))
|
|
196
|
+
}
|
|
197
|
+
// Allows entity.stream to work when body is a JSON object
|
|
198
|
+
if (typeof v === "object" && v !== null && !isBinary(v)) {
|
|
199
|
+
return Effect.succeed(textEncoder.encode(JSON.stringify(v)))
|
|
200
|
+
}
|
|
201
|
+
return Effect.fail(mismatch(Schema.Uint8ArrayFromSelf, v))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getStream<A, E1, E2>(
|
|
205
|
+
self: Entity<Stream.Stream<A, E1, never>, E2>,
|
|
206
|
+
): Stream.Stream<A, ParseResult.ParseError | E1 | E2>
|
|
207
|
+
function getStream<T, E>(
|
|
208
|
+
self: Entity<T, E>,
|
|
209
|
+
): Stream.Stream<Uint8Array, ParseResult.ParseError | E>
|
|
210
|
+
function getStream(
|
|
211
|
+
self: Entity<unknown, unknown>,
|
|
212
|
+
): Stream.Stream<unknown, unknown> {
|
|
213
|
+
const v = self.body
|
|
214
|
+
if (StreamExtra.isStream(v)) {
|
|
215
|
+
return v as Stream.Stream<unknown, unknown, never>
|
|
216
|
+
}
|
|
217
|
+
if (Effect.isEffect(v)) {
|
|
218
|
+
return Stream.unwrap(
|
|
219
|
+
Effect.map(
|
|
220
|
+
v as Effect.Effect<unknown, unknown, never>,
|
|
221
|
+
(inner) => {
|
|
222
|
+
if (isEntity(inner)) {
|
|
223
|
+
return inner.stream
|
|
224
|
+
}
|
|
225
|
+
return Stream.fromEffect(getBytes(make(inner)))
|
|
226
|
+
},
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
return Stream.fromEffect(getBytes(self))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const Proto: Proto = Object.defineProperties(
|
|
234
|
+
Object.create(null),
|
|
235
|
+
{
|
|
236
|
+
[TypeId]: { value: TypeId },
|
|
237
|
+
pipe: {
|
|
238
|
+
value: function(this: Entity) {
|
|
239
|
+
return Pipeable.pipeArguments(this, arguments)
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
text: {
|
|
243
|
+
get(this: Entity<unknown, unknown>) {
|
|
244
|
+
return getText(this)
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
json: {
|
|
248
|
+
get(this: Entity<unknown, unknown>) {
|
|
249
|
+
return getJson(this)
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
bytes: {
|
|
253
|
+
get(this: Entity<unknown, unknown>) {
|
|
254
|
+
return getBytes(this)
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
stream: {
|
|
258
|
+
get(this: Entity<unknown, unknown>) {
|
|
259
|
+
return getStream(this)
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
export function isEntity(input: unknown): input is Entity {
|
|
266
|
+
return Predicate.hasProperty(input, TypeId)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface Options {
|
|
270
|
+
readonly headers?: Headers
|
|
271
|
+
readonly url?: string
|
|
272
|
+
readonly status?: number
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function make<A, E>(
|
|
276
|
+
body: Effect.Effect<A, E, never>,
|
|
277
|
+
options?: Options,
|
|
278
|
+
): Entity<Effect.Effect<A, E, never>, E>
|
|
279
|
+
export function make<A extends Uint8Array | string, E>(
|
|
280
|
+
body: Stream.Stream<A, E, never>,
|
|
281
|
+
options?: Options,
|
|
282
|
+
): Entity<Stream.Stream<A, E, never>, E>
|
|
283
|
+
export function make<T>(body: T, options?: Options): Entity<T, never>
|
|
284
|
+
export function make(
|
|
285
|
+
body: unknown,
|
|
286
|
+
options?: Options,
|
|
287
|
+
): Entity<unknown, unknown> {
|
|
288
|
+
return Object.assign(
|
|
289
|
+
Object.create(Proto),
|
|
290
|
+
{
|
|
291
|
+
body,
|
|
292
|
+
headers: options?.headers ?? {},
|
|
293
|
+
url: options?.url,
|
|
294
|
+
status: options?.status,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function effect<A, E, R>(
|
|
300
|
+
body: Effect.Effect<Entity<A> | A, E, R>,
|
|
301
|
+
): Entity<A, E> {
|
|
302
|
+
return make(body) as unknown as Entity<A, E>
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function resolve<A, E>(
|
|
306
|
+
entity: Entity<A, E>,
|
|
307
|
+
): Effect.Effect<Entity<A, E>, E, never> {
|
|
308
|
+
const body = entity.body
|
|
309
|
+
if (Effect.isEffect(body)) {
|
|
310
|
+
return Effect.map(
|
|
311
|
+
body as Effect.Effect<Entity<A> | A, E, never>,
|
|
312
|
+
(inner) =>
|
|
313
|
+
isEntity(inner)
|
|
314
|
+
? inner as Entity<A, E>
|
|
315
|
+
: make(inner as A, {
|
|
316
|
+
status: entity.status,
|
|
317
|
+
headers: entity.headers,
|
|
318
|
+
url: entity.url,
|
|
319
|
+
}) as Entity<A, E>,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
return Effect.succeed(entity)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function type(self: Entity): string {
|
|
326
|
+
const h = self.headers
|
|
327
|
+
if (h["content-type"]) {
|
|
328
|
+
return h["content-type"]
|
|
329
|
+
}
|
|
330
|
+
const v = self.body
|
|
331
|
+
if (typeof v === "string") {
|
|
332
|
+
return "text/plain"
|
|
333
|
+
}
|
|
334
|
+
if (typeof v === "object" && v !== null && !isBinary(v)) {
|
|
335
|
+
return "application/json"
|
|
336
|
+
}
|
|
337
|
+
return "application/octet-stream"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function length(self: Entity): number | undefined {
|
|
341
|
+
const h = self.headers
|
|
342
|
+
if (h["content-length"]) {
|
|
343
|
+
return parseInt(h["content-length"], 10)
|
|
344
|
+
}
|
|
345
|
+
const v = self.body
|
|
346
|
+
if (typeof v === "string") {
|
|
347
|
+
return textEncoder.encode(v).byteLength
|
|
348
|
+
}
|
|
349
|
+
if (isBinary(v)) {
|
|
350
|
+
return v.byteLength
|
|
351
|
+
}
|
|
352
|
+
return undefined
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function mismatch(
|
|
356
|
+
expected: Schema.Schema.Any,
|
|
357
|
+
actual: unknown,
|
|
358
|
+
): ParseResult.ParseError {
|
|
359
|
+
return new ParseResult.ParseError({
|
|
360
|
+
issue: new ParseResult.Type(expected.ast, actual),
|
|
361
|
+
})
|
|
362
|
+
}
|
package/src/Http.test.ts
CHANGED
|
@@ -1,24 +1,319 @@
|
|
|
1
1
|
import * as test from "bun:test"
|
|
2
2
|
import * as Http from "./Http.ts"
|
|
3
3
|
|
|
4
|
-
test.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
test
|
|
22
|
-
|
|
23
|
-
|
|
4
|
+
test.describe("mapHeaders", () => {
|
|
5
|
+
test.it("converts Headers to record with lowercase keys", () => {
|
|
6
|
+
const headers = new Headers({
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
"X-Custom-Header": "value",
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const record = Http.mapHeaders(headers)
|
|
12
|
+
|
|
13
|
+
test
|
|
14
|
+
.expect(record)
|
|
15
|
+
.toEqual({
|
|
16
|
+
"content-type": "application/json",
|
|
17
|
+
"x-custom-header": "value",
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test.it("returns empty record for empty headers", () => {
|
|
22
|
+
const headers = new Headers()
|
|
23
|
+
const record = Http.mapHeaders(headers)
|
|
24
|
+
|
|
25
|
+
test
|
|
26
|
+
.expect(record)
|
|
27
|
+
.toEqual({})
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test.describe("parseCookies", () => {
|
|
32
|
+
test.it("parses cookie header string", () => {
|
|
33
|
+
const cookieHeader = "session=abc123; token=xyz789"
|
|
34
|
+
const cookies = Http.parseCookies(cookieHeader)
|
|
35
|
+
|
|
36
|
+
test
|
|
37
|
+
.expect(cookies)
|
|
38
|
+
.toEqual({
|
|
39
|
+
session: "abc123",
|
|
40
|
+
token: "xyz789",
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test.it("handles cookies with = in value", () => {
|
|
45
|
+
const cookieHeader = "data=key=value"
|
|
46
|
+
const cookies = Http.parseCookies(cookieHeader)
|
|
47
|
+
|
|
48
|
+
test
|
|
49
|
+
.expect(cookies)
|
|
50
|
+
.toEqual({
|
|
51
|
+
data: "key=value",
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test.it("trims whitespace from cookie names and values", () => {
|
|
56
|
+
const cookieHeader = " session = abc123 ; token = xyz789 "
|
|
57
|
+
const cookies = Http.parseCookies(cookieHeader)
|
|
58
|
+
|
|
59
|
+
test
|
|
60
|
+
.expect(cookies)
|
|
61
|
+
.toEqual({
|
|
62
|
+
session: "abc123",
|
|
63
|
+
token: "xyz789",
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test.it("handles empty cookie values", () => {
|
|
68
|
+
const cookieHeader = "session=; token=xyz789"
|
|
69
|
+
const cookies = Http.parseCookies(cookieHeader)
|
|
70
|
+
|
|
71
|
+
test
|
|
72
|
+
.expect(cookies)
|
|
73
|
+
.toEqual({
|
|
74
|
+
session: "",
|
|
75
|
+
token: "xyz789",
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test.it("handles cookies without values", () => {
|
|
80
|
+
const cookieHeader = "flag; session=abc123"
|
|
81
|
+
const cookies = Http.parseCookies(cookieHeader)
|
|
82
|
+
|
|
83
|
+
test
|
|
84
|
+
.expect(cookies)
|
|
85
|
+
.toEqual({
|
|
86
|
+
flag: undefined,
|
|
87
|
+
session: "abc123",
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test.it("ignores empty parts", () => {
|
|
92
|
+
const cookieHeader = "session=abc123;; ; token=xyz789"
|
|
93
|
+
const cookies = Http.parseCookies(cookieHeader)
|
|
94
|
+
|
|
95
|
+
test
|
|
96
|
+
.expect(cookies)
|
|
97
|
+
.toEqual({
|
|
98
|
+
session: "abc123",
|
|
99
|
+
token: "xyz789",
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test.it("returns empty record for null cookie header", () => {
|
|
104
|
+
const cookies = Http.parseCookies(null)
|
|
105
|
+
|
|
106
|
+
test
|
|
107
|
+
.expect(cookies)
|
|
108
|
+
.toEqual({})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test.it("returns empty record for empty cookie header", () => {
|
|
112
|
+
const cookies = Http.parseCookies("")
|
|
113
|
+
|
|
114
|
+
test
|
|
115
|
+
.expect(cookies)
|
|
116
|
+
.toEqual({})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test.describe("mapUrlSearchParams", () => {
|
|
121
|
+
test.it("converts single values to strings", () => {
|
|
122
|
+
const params = new URLSearchParams("page=1&limit=10")
|
|
123
|
+
const record = Http.mapUrlSearchParams(params)
|
|
124
|
+
|
|
125
|
+
test
|
|
126
|
+
.expect(record)
|
|
127
|
+
.toEqual({
|
|
128
|
+
page: "1",
|
|
129
|
+
limit: "10",
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test.it("converts multiple values to arrays", () => {
|
|
134
|
+
const params = new URLSearchParams("tags=red&tags=blue&tags=green")
|
|
135
|
+
const record = Http.mapUrlSearchParams(params)
|
|
136
|
+
|
|
137
|
+
test
|
|
138
|
+
.expect(record)
|
|
139
|
+
.toEqual({
|
|
140
|
+
tags: ["red", "blue", "green"],
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test.it("handles mixed single and multiple values", () => {
|
|
145
|
+
const params = new URLSearchParams("page=1&tags=red&tags=blue")
|
|
146
|
+
const record = Http.mapUrlSearchParams(params)
|
|
147
|
+
|
|
148
|
+
test
|
|
149
|
+
.expect(record)
|
|
150
|
+
.toEqual({
|
|
151
|
+
page: "1",
|
|
152
|
+
tags: ["red", "blue"],
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test.it("returns empty record for empty params", () => {
|
|
157
|
+
const params = new URLSearchParams()
|
|
158
|
+
const record = Http.mapUrlSearchParams(params)
|
|
159
|
+
|
|
160
|
+
test
|
|
161
|
+
.expect(record)
|
|
162
|
+
.toEqual({})
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test.describe("parseFormData", () => {
|
|
167
|
+
function createFormDataRequest(formData: FormData): Request {
|
|
168
|
+
return new Request("http://localhost/", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: formData,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
test.it("parses single string field", async () => {
|
|
175
|
+
const formData = new FormData()
|
|
176
|
+
formData.append("name", "John")
|
|
177
|
+
|
|
178
|
+
const request = createFormDataRequest(formData)
|
|
179
|
+
const result = await Http.parseFormData(request)
|
|
180
|
+
|
|
181
|
+
test
|
|
182
|
+
.expect(result)
|
|
183
|
+
.toEqual({
|
|
184
|
+
name: "John",
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test.it("parses multiple string fields", async () => {
|
|
189
|
+
const formData = new FormData()
|
|
190
|
+
formData.append("name", "John")
|
|
191
|
+
formData.append("email", "john@example.com")
|
|
192
|
+
|
|
193
|
+
const request = createFormDataRequest(formData)
|
|
194
|
+
const result = await Http.parseFormData(request)
|
|
195
|
+
|
|
196
|
+
test
|
|
197
|
+
.expect(result)
|
|
198
|
+
.toEqual({
|
|
199
|
+
name: "John",
|
|
200
|
+
email: "john@example.com",
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test.it("parses multiple values for same key as array", async () => {
|
|
205
|
+
const formData = new FormData()
|
|
206
|
+
formData.append("tags", "red")
|
|
207
|
+
formData.append("tags", "blue")
|
|
208
|
+
formData.append("tags", "green")
|
|
209
|
+
|
|
210
|
+
const request = createFormDataRequest(formData)
|
|
211
|
+
const result = await Http.parseFormData(request)
|
|
212
|
+
|
|
213
|
+
test
|
|
214
|
+
.expect(result)
|
|
215
|
+
.toEqual({
|
|
216
|
+
tags: ["red", "blue", "green"],
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test.it("parses single file upload", async () => {
|
|
221
|
+
const formData = new FormData()
|
|
222
|
+
const fileContent = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
|
|
223
|
+
const file = new File([fileContent], "test.txt", { type: "text/plain" })
|
|
224
|
+
formData.append("document", file)
|
|
225
|
+
|
|
226
|
+
const request = createFormDataRequest(formData)
|
|
227
|
+
const result = await Http.parseFormData(request)
|
|
228
|
+
|
|
229
|
+
test.expect(result.document).toBeDefined()
|
|
230
|
+
const files = result.document as ReadonlyArray<Http.FilePart>
|
|
231
|
+
test.expect(files).toHaveLength(1)
|
|
232
|
+
test.expect(files[0]._tag).toBe("File")
|
|
233
|
+
test.expect(files[0].key).toBe("document")
|
|
234
|
+
test.expect(files[0].name).toBe("test.txt")
|
|
235
|
+
test.expect(files[0].contentType.startsWith("text/plain")).toBe(true)
|
|
236
|
+
test.expect(files[0].content).toEqual(fileContent)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test.it("parses multiple file uploads for same key", async () => {
|
|
240
|
+
const formData = new FormData()
|
|
241
|
+
const file1 = new File([new Uint8Array([1, 2, 3])], "file1.bin", {
|
|
242
|
+
type: "application/octet-stream",
|
|
243
|
+
})
|
|
244
|
+
const file2 = new File([new Uint8Array([4, 5, 6])], "file2.bin", {
|
|
245
|
+
type: "application/octet-stream",
|
|
246
|
+
})
|
|
247
|
+
formData.append("files", file1)
|
|
248
|
+
formData.append("files", file2)
|
|
249
|
+
|
|
250
|
+
const request = createFormDataRequest(formData)
|
|
251
|
+
const result = await Http.parseFormData(request)
|
|
252
|
+
|
|
253
|
+
const files = result.files as ReadonlyArray<Http.FilePart>
|
|
254
|
+
test.expect(files).toHaveLength(2)
|
|
255
|
+
test.expect(files[0].name).toBe("file1.bin")
|
|
256
|
+
test.expect(files[0].content).toEqual(new Uint8Array([1, 2, 3]))
|
|
257
|
+
test.expect(files[1].name).toBe("file2.bin")
|
|
258
|
+
test.expect(files[1].content).toEqual(new Uint8Array([4, 5, 6]))
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test.it("uses default content type for files without type", async () => {
|
|
262
|
+
const formData = new FormData()
|
|
263
|
+
const file = new File([new Uint8Array([1, 2, 3])], "unknown.dat", {
|
|
264
|
+
type: "",
|
|
265
|
+
})
|
|
266
|
+
formData.append("upload", file)
|
|
267
|
+
|
|
268
|
+
const request = createFormDataRequest(formData)
|
|
269
|
+
const result = await Http.parseFormData(request)
|
|
270
|
+
|
|
271
|
+
const files = result.upload as ReadonlyArray<Http.FilePart>
|
|
272
|
+
test.expect(files[0].contentType).toBe("application/octet-stream")
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test.it("parses mixed string fields and file uploads", async () => {
|
|
276
|
+
const formData = new FormData()
|
|
277
|
+
formData.append("title", "My Document")
|
|
278
|
+
const file = new File([new Uint8Array([1, 2, 3])], "doc.pdf", {
|
|
279
|
+
type: "application/pdf",
|
|
280
|
+
})
|
|
281
|
+
formData.append("attachment", file)
|
|
282
|
+
formData.append("description", "A test document")
|
|
283
|
+
|
|
284
|
+
const request = createFormDataRequest(formData)
|
|
285
|
+
const result = await Http.parseFormData(request)
|
|
286
|
+
|
|
287
|
+
test.expect(result.title).toBe("My Document")
|
|
288
|
+
test.expect(result.description).toBe("A test document")
|
|
289
|
+
const files = result.attachment as ReadonlyArray<Http.FilePart>
|
|
290
|
+
test.expect(files).toHaveLength(1)
|
|
291
|
+
test.expect(files[0].name).toBe("doc.pdf")
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test.it("returns empty record for empty form data", async () => {
|
|
295
|
+
const formData = new FormData()
|
|
296
|
+
|
|
297
|
+
const request = createFormDataRequest(formData)
|
|
298
|
+
const result = await Http.parseFormData(request)
|
|
299
|
+
|
|
300
|
+
test
|
|
301
|
+
.expect(result)
|
|
302
|
+
.toEqual({})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test.it("parses Blob as file", async () => {
|
|
306
|
+
const formData = new FormData()
|
|
307
|
+
const blob = new Blob([new Uint8Array([10, 20, 30])], { type: "image/png" })
|
|
308
|
+
formData.append("image", blob, "image.png")
|
|
309
|
+
|
|
310
|
+
const request = createFormDataRequest(formData)
|
|
311
|
+
const result = await Http.parseFormData(request)
|
|
312
|
+
|
|
313
|
+
const files = result.image as ReadonlyArray<Http.FilePart>
|
|
314
|
+
test.expect(files).toHaveLength(1)
|
|
315
|
+
test.expect(files[0].name).toBe("image.png")
|
|
316
|
+
test.expect(files[0].contentType).toBe("image/png")
|
|
317
|
+
test.expect(files[0].content).toEqual(new Uint8Array([10, 20, 30]))
|
|
318
|
+
})
|
|
24
319
|
})
|