Skip to content

Commit 93687dd

Browse files
authored
HttpApiBuilder handler improvements (#5042)
1 parent 2be881b commit 93687dd

File tree

14 files changed

+711
-119
lines changed

14 files changed

+711
-119
lines changed

.changeset/happy-teams-sink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/platform": patch
3+
---
4+
5+
allow return HttpServerResponse from HttpApiBuilder .handle

.changeset/loose-lines-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/platform": minor
3+
---
4+
5+
HttpApiBuilder .handleRaw no longer parses the request body

.changeset/wide-mirrors-study.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/platform": patch
3+
---
4+
5+
add HttpApiSchema.MultipartStream

packages/platform-bun/src/internal/multipart.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Multipart from "@effect/platform/Multipart"
2+
import * as Channel from "effect/Channel"
23
import * as Effect from "effect/Effect"
34
import { pipe } from "effect/Function"
45
import * as Inspectable from "effect/Inspectable"
@@ -94,6 +95,7 @@ class FileImpl extends PartBase implements Multipart.File {
9495
readonly name: string
9596
readonly contentType: string
9697
readonly content: Stream.Stream<Uint8Array, Multipart.MultipartError>
98+
readonly contentEffect: Effect.Effect<Uint8Array, Multipart.MultipartError>
9799

98100
constructor(readonly file: MP.File) {
99101
super()
@@ -104,6 +106,11 @@ class FileImpl extends PartBase implements Multipart.File {
104106
() => file.readable,
105107
(cause) => new Multipart.MultipartError({ reason: "InternalError", cause })
106108
)
109+
this.contentEffect = Stream.toChannel(this.content).pipe(
110+
Channel.pipeTo(Multipart.collectUint8Array),
111+
Channel.run,
112+
Effect.mapError((cause) => new Multipart.MultipartError({ reason: "InternalError", cause }))
113+
)
107114
}
108115

109116
toJSON(): unknown {

packages/platform-node-shared/src/internal/multipart.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class FileImpl extends PartBase implements Multipart.File {
8686
readonly name: string
8787
readonly contentType: string
8888
readonly content: Stream.Stream<Uint8Array, Multipart.MultipartError>
89+
readonly contentEffect: Effect.Effect<Uint8Array, Multipart.MultipartError>
8990

9091
constructor(readonly file: MP.FileStream) {
9192
super()
@@ -96,6 +97,9 @@ class FileImpl extends PartBase implements Multipart.File {
9697
() => file,
9798
(cause) => new Multipart.MultipartError({ reason: "InternalError", cause })
9899
)
100+
this.contentEffect = NodeStream.toUint8Array(() => file, {
101+
onFailure: (cause) => new Multipart.MultipartError({ reason: "InternalError", cause })
102+
})
99103
}
100104

101105
toJSON(): unknown {

packages/platform-node/test/HttpApi.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ import {
1212
HttpClient,
1313
HttpClientRequest,
1414
HttpServerRequest,
15+
HttpServerResponse,
1516
Multipart,
1617
OpenApi
1718
} from "@effect/platform"
1819
import { NodeHttpServer } from "@effect/platform-node"
1920
import { assert, describe, it } from "@effect/vitest"
20-
import { Context, DateTime, Effect, Layer, Redacted, Ref, Schema, Struct } from "effect"
21+
import { Chunk, Context, DateTime, Effect, Layer, Redacted, Ref, Schema, Stream, Struct } from "effect"
2122
import OpenApiFixture from "./fixtures/openapi.json" with { type: "json" }
2223

2324
describe("HttpApi", () => {
@@ -74,6 +75,18 @@ describe("HttpApi", () => {
7475
length: 5
7576
})
7677
}).pipe(Effect.provide(HttpLive)))
78+
79+
it.live("multipart stream", () =>
80+
Effect.gen(function*() {
81+
const client = yield* HttpApiClient.make(Api)
82+
const data = new FormData()
83+
data.append("file", new Blob(["hello"], { type: "text/plain" }), "hello.txt")
84+
const result = yield* client.users.uploadStream({ payload: data })
85+
assert.deepStrictEqual(result, {
86+
contentType: "text/plain",
87+
length: 5
88+
})
89+
}).pipe(Effect.provide(HttpLive)))
7790
})
7891

7992
describe("headers", () => {
@@ -237,6 +250,32 @@ describe("HttpApi", () => {
237250
assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
238251
}).pipe(Effect.provide(HttpLive)))
239252

253+
it.effect(".handle can return HttpServerResponse", () =>
254+
Effect.gen(function*() {
255+
const client = yield* HttpApiClient.make(Api)
256+
const response = yield* client.groups.handle({
257+
path: { id: 1 },
258+
payload: { name: "Some group" }
259+
})
260+
assert.deepStrictEqual(response, {
261+
id: 1,
262+
name: "Some group"
263+
})
264+
}).pipe(Effect.provide(HttpLive)))
265+
266+
it.effect(".handleRaw can manually process body", () =>
267+
Effect.gen(function*() {
268+
const client = yield* HttpApiClient.make(Api)
269+
const response = yield* client.groups.handleRaw({
270+
path: { id: 1 },
271+
payload: { name: "Some group" }
272+
})
273+
assert.deepStrictEqual(response, {
274+
id: 1,
275+
name: "Some group"
276+
})
277+
}).pipe(Effect.provide(HttpLive)))
278+
240279
it("OpenAPI spec", () => {
241280
const spec = OpenApi.fromApi(Api)
242281
assert.deepStrictEqual(spec, OpenApiFixture as any)
@@ -300,6 +339,26 @@ class GroupsApi extends HttpApiGroup.make("groups")
300339
))
301340
.addSuccess(Group)
302341
)
342+
.add(
343+
HttpApiEndpoint.post("handle")`/handle/${HttpApiSchema.param("id", Schema.NumberFromString)}`
344+
.setPayload(Schema.Struct({
345+
name: Schema.String
346+
}))
347+
.addSuccess(Schema.Struct({
348+
id: Schema.Number,
349+
name: Schema.String
350+
}))
351+
)
352+
.add(
353+
HttpApiEndpoint.post("handleRaw")`/handleraw/${HttpApiSchema.param("id", Schema.NumberFromString)}`
354+
.setPayload(Schema.Struct({
355+
name: Schema.String
356+
}))
357+
.addSuccess(Schema.Struct({
358+
id: Schema.Number,
359+
name: Schema.String
360+
}))
361+
)
303362
.addError(GroupError.pipe(
304363
HttpApiSchema.asEmpty({ status: 418, decode: () => new GroupError() })
305364
))
@@ -351,6 +410,16 @@ class UsersApi extends HttpApiGroup.make("users")
351410
length: Schema.Int
352411
}))
353412
)
413+
.add(
414+
HttpApiEndpoint.post("uploadStream")`/uploadstream`
415+
.setPayload(HttpApiSchema.MultipartStream(Schema.Struct({
416+
file: Multipart.SingleFileSchema
417+
})))
418+
.addSuccess(Schema.Struct({
419+
contentType: Schema.String,
420+
length: Schema.Int
421+
}))
422+
)
354423
.middleware(Authorization)
355424
.annotateContext(OpenApi.annotations({ title: "Users API" }))
356425
{}
@@ -456,6 +525,24 @@ const HttpUsersLive = HttpApiBuilder.group(
456525
length: Number(stat.size)
457526
}
458527
}))
528+
.handle("uploadStream", (_) =>
529+
Effect.gen(function*() {
530+
const { content, file } = yield* _.payload.pipe(
531+
Stream.filter((part) => part._tag === "File"),
532+
Stream.mapEffect((file) =>
533+
file.contentEffect.pipe(
534+
Effect.map((content) => ({ file, content }))
535+
)
536+
),
537+
Stream.runCollect,
538+
Effect.flatMap(Chunk.head),
539+
Effect.orDie
540+
)
541+
return {
542+
contentType: file.contentType,
543+
length: content.length
544+
}
545+
}))
459546
})
460547
).pipe(Layer.provide([
461548
DateTime.layerCurrentZoneOffset(0),
@@ -479,6 +566,25 @@ const HttpGroupsLive = HttpApiBuilder.group(
479566
name: "foo" in payload ? payload.foo : payload.name
480567
})
481568
))
569+
.handle(
570+
"handle",
571+
Effect.fn(function*({ path, payload }) {
572+
return HttpServerResponse.unsafeJson({
573+
id: path.id,
574+
name: payload.name
575+
})
576+
})
577+
)
578+
.handleRaw(
579+
"handleRaw",
580+
Effect.fn(function*({ path, request }) {
581+
const body = (yield* Effect.orDie(request.json)) as { name: string }
582+
return HttpServerResponse.unsafeJson({
583+
id: path.id,
584+
name: body.name
585+
})
586+
})
587+
)
482588
)
483589

484590
const TopLevelLive = HttpApiBuilder.group(

0 commit comments

Comments
 (0)