Skip to content

Commit 5137c70

Browse files
authored
allow Multipart configuration in HttpApiSchema.Multipart (#5081)
1 parent c23d25c commit 5137c70

File tree

12 files changed

+679
-630
lines changed

12 files changed

+679
-630
lines changed

.changeset/fifty-views-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
expose Stream.provideSomeContext

.changeset/upset-donuts-kiss.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 configuring multipart limits in HttpApiSchema.Multipart

.changeset/wet-years-beg.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+
use Context.Reference for Multipart configuration

packages/effect/src/Stream.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3629,6 +3629,18 @@ export const provideContext: {
36293629
<A, E, R>(self: Stream<A, E, R>, context: Context.Context<R>): Stream<A, E>
36303630
} = internal.provideContext
36313631

3632+
/**
3633+
* Provides the stream with some of its required context, which eliminates its
3634+
* dependency on `R`.
3635+
*
3636+
* @since 3.16.9
3637+
* @category context
3638+
*/
3639+
export const provideSomeContext: {
3640+
<R2>(context: Context.Context<R2>): <A, E, R>(self: Stream<A, E, R>) => Stream<A, E, Exclude<R, R2>>
3641+
<A, E, R, R2>(self: Stream<A, E, R>, context: Context.Context<R2>): Stream<A, E, Exclude<R, R2>>
3642+
} = internal.provideSomeContext
3643+
36323644
/**
36333645
* Provides a `Layer` to the stream, which translates it to another level.
36343646
*

packages/platform-node/src/internal/httpIncomingMessage.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as Headers from "@effect/platform/Headers"
22
import * as IncomingMessage from "@effect/platform/HttpIncomingMessage"
33
import * as UrlParams from "@effect/platform/UrlParams"
44
import * as Effect from "effect/Effect"
5-
import * as FiberRef from "effect/FiberRef"
65
import * as Inspectable from "effect/Inspectable"
76
import * as Option from "effect/Option"
87
import type * as Stream from "effect/Stream"
@@ -39,7 +38,7 @@ export abstract class HttpIncomingMessageImpl<E> extends Inspectable.Class
3938
}
4039
this.textEffect = Effect.runSync(Effect.cached(
4140
Effect.flatMap(
42-
FiberRef.get(IncomingMessage.maxBodySize),
41+
IncomingMessage.MaxBodySize,
4342
(maxBodySize) =>
4443
NodeStream.toString(() => this.source, {
4544
onFailure: this.onError,
@@ -82,7 +81,7 @@ export abstract class HttpIncomingMessageImpl<E> extends Inspectable.Class
8281

8382
get arrayBuffer(): Effect.Effect<ArrayBuffer, E> {
8483
return Effect.flatMap(
85-
FiberRef.get(IncomingMessage.maxBodySize),
84+
IncomingMessage.MaxBodySize,
8685
(maxBodySize) =>
8786
NodeStream.toUint8Array(() => this.source, {
8887
onFailure: this.onError,

packages/platform-node/src/internal/httpServer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ export const make = (
112112
})
113113
})
114114
}).pipe(
115-
Effect.locally(
116-
IncomingMessage.maxBodySize,
115+
Effect.provideService(
116+
IncomingMessage.MaxBodySize,
117117
Option.some(FileSystem.Size(1024 * 1024 * 10))
118118
)
119119
)

packages/platform/src/HttpApiBuilder.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import * as HttpRouter from "./HttpRouter.js"
3737
import * as HttpServer from "./HttpServer.js"
3838
import * as HttpServerRequest from "./HttpServerRequest.js"
3939
import * as HttpServerResponse from "./HttpServerResponse.js"
40+
import * as Multipart from "./Multipart.js"
4041
import * as OpenApi from "./OpenApi.js"
4142
import type { Path } from "./Path.js"
4243
import * as UrlParams from "./UrlParams.js"
@@ -525,7 +526,8 @@ export const handler = <
525526

526527
const requestPayload = (
527528
request: HttpServerRequest.HttpServerRequest,
528-
urlParams: ReadonlyRecord<string, string | Array<string>>
529+
urlParams: ReadonlyRecord<string, string | Array<string>>,
530+
multipartLimits: Option.Option<Multipart.withLimits.Options>
529531
): Effect.Effect<
530532
unknown,
531533
never,
@@ -542,7 +544,10 @@ const requestPayload = (
542544
if (contentType.includes("application/json")) {
543545
return Effect.orDie(request.json)
544546
} else if (contentType.includes("multipart/form-data")) {
545-
return Effect.orDie(request.multipart)
547+
return Effect.orDie(Option.match(multipartLimits, {
548+
onNone: () => request.multipart,
549+
onSome: (limits) => Multipart.withLimits(request.multipart, limits)
550+
}))
546551
} else if (contentType.includes("x-www-form-urlencoded")) {
547552
return Effect.map(Effect.orDie(request.urlParamsBody), UrlParams.toRecord)
548553
} else if (contentType.startsWith("text/")) {
@@ -628,9 +633,12 @@ const handlerToRoute = (
628633
): HttpRouter.Route<any, any> => {
629634
const endpoint = endpoint_ as HttpApiEndpoint.HttpApiEndpoint.AnyWithProps
630635
const isMultipartStream = endpoint.payloadSchema.pipe(
631-
Option.map(({ ast }) => HttpApiSchema.getMultipartStream(ast)),
636+
Option.map(({ ast }) => HttpApiSchema.getMultipartStream(ast) !== undefined),
632637
Option.getOrElse(constFalse)
633638
)
639+
const multipartLimits = endpoint.payloadSchema.pipe(
640+
Option.flatMapNullable(({ ast }) => HttpApiSchema.getMultipart(ast) || HttpApiSchema.getMultipartStream(ast))
641+
)
634642
const decodePath = Option.map(endpoint.pathSchema, Schema.decodeUnknown)
635643
const decodePayload = isFullRequest || isMultipartStream
636644
? Option.none()
@@ -654,11 +662,14 @@ const handlerToRoute = (
654662
}
655663
if (decodePayload._tag === "Some") {
656664
request.payload = yield* Effect.flatMap(
657-
requestPayload(httpRequest, urlParams),
665+
requestPayload(httpRequest, urlParams, multipartLimits),
658666
decodePayload.value
659667
)
660668
} else if (isMultipartStream) {
661-
request.payload = httpRequest.multipartStream
669+
request.payload = Option.match(multipartLimits, {
670+
onNone: () => httpRequest.multipartStream,
671+
onSome: (limits) => Multipart.withLimitsStream(httpRequest.multipartStream, limits)
672+
})
662673
}
663674
if (decodeHeaders._tag === "Some") {
664675
request.headers = yield* decodeHeaders.value(httpRequest.headers)

packages/platform/src/HttpApiSchema.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import * as Effectable from "effect/Effectable"
77
import type { LazyArg } from "effect/Function"
88
import { constant, constVoid, dual } from "effect/Function"
99
import { globalValue } from "effect/GlobalValue"
10+
import type * as Option from "effect/Option"
1011
import { hasProperty } from "effect/Predicate"
1112
import * as Schema from "effect/Schema"
1213
import * as AST from "effect/SchemaAST"
1314
import * as Struct from "effect/Struct"
15+
import type * as FileSystem from "./FileSystem.js"
16+
import type * as Multipart_ from "./Multipart.js"
1417

1518
/**
1619
* @since 1.0.0
@@ -111,14 +114,15 @@ export const getEmptyDecodeable = (ast: AST.AST): boolean =>
111114
* @since 1.0.0
112115
* @category annotations
113116
*/
114-
export const getMultipart = (ast: AST.AST): boolean => getAnnotation<boolean>(ast, AnnotationMultipart) ?? false
117+
export const getMultipart = (ast: AST.AST): Multipart_.withLimits.Options | undefined =>
118+
getAnnotation<Multipart_.withLimits.Options>(ast, AnnotationMultipart)
115119

116120
/**
117121
* @since 1.0.0
118122
* @category annotations
119123
*/
120-
export const getMultipartStream = (ast: AST.AST): boolean =>
121-
getAnnotation<boolean>(ast, AnnotationMultipartStream) ?? false
124+
export const getMultipartStream = (ast: AST.AST): Multipart_.withLimits.Options | undefined =>
125+
getAnnotation<Multipart_.withLimits.Options>(ast, AnnotationMultipartStream)
122126

123127
const encodingJson: Encoding = {
124128
kind: "Json",
@@ -406,9 +410,15 @@ export interface Multipart<S extends Schema.Schema.Any>
406410
* @since 1.0.0
407411
* @category multipart
408412
*/
409-
export const Multipart = <S extends Schema.Schema.Any>(self: S): Multipart<S> =>
413+
export const Multipart = <S extends Schema.Schema.Any>(self: S, options?: {
414+
readonly maxParts?: Option.Option<number> | undefined
415+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
416+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
417+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
418+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
419+
}): Multipart<S> =>
410420
self.annotations({
411-
[AnnotationMultipart]: true
421+
[AnnotationMultipart]: options ?? {}
412422
}) as any
413423

414424
/**
@@ -439,9 +449,15 @@ export interface MultipartStream<S extends Schema.Schema.Any> extends
439449
* @since 1.0.0
440450
* @category multipart
441451
*/
442-
export const MultipartStream = <S extends Schema.Schema.Any>(self: S): MultipartStream<S> =>
452+
export const MultipartStream = <S extends Schema.Schema.Any>(self: S, options?: {
453+
readonly maxParts?: Option.Option<number> | undefined
454+
readonly maxFieldSize?: FileSystem.SizeInput | undefined
455+
readonly maxFileSize?: Option.Option<FileSystem.SizeInput> | undefined
456+
readonly maxTotalSize?: Option.Option<FileSystem.SizeInput> | undefined
457+
readonly fieldMimeTypes?: ReadonlyArray<string> | undefined
458+
}): MultipartStream<S> =>
443459
self.annotations({
444-
[AnnotationMultipartStream]: true
460+
[AnnotationMultipartStream]: options ?? {}
445461
}) as any
446462

447463
const defaultContentType = (encoding: Encoding["kind"]) => {

packages/platform/src/HttpIncomingMessage.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
/**
22
* @since 1.0.0
33
*/
4+
import * as Context from "effect/Context"
45
import * as Effect from "effect/Effect"
5-
import * as FiberRef from "effect/FiberRef"
66
import { dual } from "effect/Function"
7-
import * as Global from "effect/GlobalValue"
87
import * as Inspectable from "effect/Inspectable"
98
import * as Option from "effect/Option"
109
import type * as ParseResult from "effect/ParseResult"
@@ -85,10 +84,9 @@ export const schemaHeaders = <A, I extends Readonly<Record<string, string | unde
8584
* @since 1.0.0
8685
* @category fiber refs
8786
*/
88-
export const maxBodySize: FiberRef.FiberRef<Option.Option<FileSystem.Size>> = Global.globalValue(
89-
"@effect/platform/HttpIncomingMessage/maxBodySize",
90-
() => FiberRef.unsafeMake(Option.none<FileSystem.Size>())
91-
)
87+
export class MaxBodySize extends Context.Reference<MaxBodySize>()("@effect/platform/HttpIncomingMessage/MaxBodySize", {
88+
defaultValue: Option.none<FileSystem.Size>
89+
}) {}
9290

9391
/**
9492
* @since 1.0.0
@@ -97,7 +95,7 @@ export const maxBodySize: FiberRef.FiberRef<Option.Option<FileSystem.Size>> = Gl
9795
export const withMaxBodySize = dual<
9896
(size: Option.Option<FileSystem.SizeInput>) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>,
9997
<A, E, R>(effect: Effect.Effect<A, E, R>, size: Option.Option<FileSystem.SizeInput>) => Effect.Effect<A, E, R>
100-
>(2, (effect, size) => Effect.locally(effect, maxBodySize, Option.map(size, FileSystem.Size)))
98+
>(2, (effect, size) => Effect.provideService(effect, MaxBodySize, Option.map(size, FileSystem.Size)))
10199

102100
/**
103101
* @since 1.0.0

packages/platform/src/HttpServerRequest.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ export {
2727
* @since 1.0.0
2828
* @category fiber refs
2929
*/
30-
maxBodySize
30+
MaxBodySize,
31+
/**
32+
* @since 1.0.0
33+
* @category fiber refs
34+
*/
35+
withMaxBodySize
3136
} from "./HttpIncomingMessage.js"
3237

3338
/**

0 commit comments

Comments
 (0)