Skip to content

Commit 9ad9891

Browse files
authored
Expose HttpApi.ParseOptions for schema decoding and encoding (#8269)
1 parent 23a58c0 commit 9ad9891

14 files changed

Lines changed: 417 additions & 62 deletions

File tree

.changeset/tidy-apis-parse.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
Add `HttpApi.ParseOptions` to configure server and client codecs at the API, group, or endpoint level.
6+
7+
`Sse.decodeSchema` and `ChannelSchema.decode` accept parse options, and `Schema.Cause` encodes reasons to their wire fields.
8+
9+
SSE decoding omits absent IDs, including with default options. Use `Schema.optional(Schema.String)` instead of `Schema.UndefinedOr(Schema.String)` for IDs. With `onExcessProperty: "error"`, declare `event` (default: `"message"`) and any `id`, including inherited IDs.
10+
11+
`HttpApiSchema.StreamSse` data-mode types and OpenAPI schemas now make `id` optional.

packages/effect/src/ChannelSchema.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as Channel from "./Channel.ts"
1212
import * as Effect from "./Effect.ts"
1313
import { dual } from "./Function.ts"
1414
import * as Schema from "./Schema.ts"
15+
import type * as SchemaAST from "./SchemaAST.ts"
1516

1617
/**
1718
* Creates a channel that encodes non-empty chunks of schema values into the
@@ -96,7 +97,8 @@ export const encodeUnknown: <S extends Schema.Constraint>(
9697
* @since 4.0.0
9798
*/
9899
export const decode = <S extends Schema.Constraint>(
99-
schema: S
100+
schema: S,
101+
options?: SchemaAST.ParseOptions
100102
) =>
101103
<IE = never, Done = unknown>(): Channel.Channel<
102104
Arr.NonEmptyReadonlyArray<S["Type"]>,
@@ -107,7 +109,7 @@ export const decode = <S extends Schema.Constraint>(
107109
Done,
108110
S["DecodingServices"]
109111
> => {
110-
const decode = Schema.decodeEffect(Schema.NonEmptyArray(schema))
112+
const decode = Schema.decodeEffect(Schema.NonEmptyArray(schema), options)
111113
return Channel.fromTransform((upstream, _scope) => Effect.succeed(Effect.flatMap(upstream, (chunk) => decode(chunk))))
112114
}
113115

@@ -131,7 +133,8 @@ export const decode = <S extends Schema.Constraint>(
131133
* @since 4.0.0
132134
*/
133135
export const decodeUnknown: <S extends Schema.Constraint>(
134-
schema: S
136+
schema: S,
137+
options?: SchemaAST.ParseOptions
135138
) => <IE = never, Done = unknown>() => Channel.Channel<
136139
Arr.NonEmptyReadonlyArray<S["Type"]>,
137140
IE | Schema.SchemaError,

packages/effect/src/Schema.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10550,7 +10550,16 @@ export function CauseReason<E extends Constraint, D extends Constraint>(
1055010550
return Cause_.makeInterruptReason(e.fiberId)
1055110551
}
1055210552
},
10553-
encode: identity
10553+
encode: (reason) => {
10554+
switch (reason._tag) {
10555+
case "Fail":
10556+
return { _tag: "Fail" as const, error: reason.error }
10557+
case "Die":
10558+
return { _tag: "Die" as const, defect: reason.defect }
10559+
case "Interrupt":
10560+
return { _tag: "Interrupt" as const, fiberId: reason.fiberId }
10561+
}
10562+
}
1055410563
})
1055510564
)
1055610565
}

packages/effect/src/unstable/encoding/Sse.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { hasProperty } from "../../Predicate.ts"
2020
import * as Pull from "../../Pull.ts"
2121
import * as Result from "../../Result.ts"
2222
import * as Schema from "../../Schema.ts"
23+
import type * as SchemaAST from "../../SchemaAST.ts"
2324
import * as SchemaTransformation from "../../SchemaTransformation.ts"
2425

2526
const SseErrorTypeId = "~effect/encoding/Sse/SseError"
@@ -166,8 +167,12 @@ export interface EventCodec extends
166167
*
167168
* **Details**
168169
*
169-
* The schema receives the untagged event shape containing `id`, `event`, and
170-
* string `data`.
170+
* The schema receives `{ event, data, id? }`, with string `data`. Absent IDs are
171+
* omitted, even with default options: use `Schema.optional(Schema.String)`, not
172+
* `Schema.UndefinedOr(Schema.String)`, to accept events without an ID.
173+
*
174+
* With `onExcessProperty: "error"`, declare `event` (default: `"message"`) and
175+
* `id` if the stream carries IDs, including inherited IDs.
171176
*
172177
* @category decoding
173178
* @since 4.0.0
@@ -178,7 +183,8 @@ export const decodeSchema = <
178183
Done
179184
>(
180185
schema: S,
181-
options?: DecodeOptions
186+
options?: DecodeOptions,
187+
parseOptions?: SchemaAST.ParseOptions
182188
): Channel.Channel<
183189
NonEmptyReadonlyArray<S["Type"]>,
184190
IE | Retry | SseError | Schema.SchemaError,
@@ -190,9 +196,12 @@ export const decodeSchema = <
190196
> =>
191197
Channel.pipeTo(
192198
decode<IE, Done>(options),
193-
ChannelSchema.decode(EventEncoded.pipe(
194-
Schema.decodeTo(schema)
195-
))()
199+
ChannelSchema.decode(
200+
Event.pipe(
201+
Schema.decodeTo(schema, transformEvent)
202+
),
203+
parseOptions
204+
)()
196205
)
197206

198207
/**
@@ -561,7 +570,10 @@ export const transformEvent = SchemaTransformation.transform<{
561570
readonly event: string
562571
readonly data: string
563572
}>({
564-
decode: (event) => event,
573+
decode: (event) =>
574+
event.id === undefined
575+
? { event: event.event, data: event.data }
576+
: { id: event.id, event: event.event, data: event.data },
565577
encode: (event) => ({
566578
_tag: "Event",
567579
id: event.id,

packages/effect/src/unstable/httpapi/HttpApi.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { type Pipeable, pipeArguments } from "../../Pipeable.ts"
1515
import * as Predicate from "../../Predicate.ts"
1616
import * as Record from "../../Record.ts"
1717
import type * as Schema from "../../Schema.ts"
18+
import type * as SchemaAST from "../../SchemaAST.ts"
1819
import type { PathInput } from "../http/HttpRouter.ts"
1920
import * as HttpApiEndpoint from "./HttpApiEndpoint.ts"
2021
import type * as HttpApiGroup from "./HttpApiGroup.ts"
@@ -336,3 +337,24 @@ export class AdditionalSchemas extends Context.Service<
336337
AdditionalSchemas,
337338
ReadonlyArray<Schema.Constraint>
338339
>()("effect/httpapi/HttpApi/AdditionalSchemas") {}
340+
341+
/**
342+
* Schema parse options for server and client codecs, set on an API, group, or
343+
* endpoint.
344+
*
345+
* **Details**
346+
*
347+
* Endpoint options override group options, which override API options. Objects
348+
* are replaced, not merged. Without an annotation, Schema defaults apply.
349+
*
350+
* Header codecs receive all HTTP headers, so `onExcessProperty: "error"` rejects
351+
* undeclared headers such as `content-type`. Annotate the API before passing it
352+
* to `HttpApiBuilder.group` or `HttpApiBuilder.endpoint`.
353+
*
354+
* @category services
355+
* @since 4.0.0
356+
*/
357+
export class ParseOptions extends Context.Service<
358+
ParseOptions,
359+
SchemaAST.ParseOptions
360+
>()("effect/httpapi/HttpApi/ParseOptions") {}

packages/effect/src/unstable/httpapi/HttpApiBuilder.ts

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import * as Response from "../http/HttpServerResponse.ts"
4444
import type { HttpServerResponse } from "../http/HttpServerResponse.ts"
4545
import * as Multipart from "../http/Multipart.ts"
4646
import * as UrlParams from "../http/UrlParams.ts"
47-
import type * as HttpApi from "./HttpApi.ts"
47+
import * as HttpApi from "./HttpApi.ts"
4848
import * as HttpApiEndpoint from "./HttpApiEndpoint.ts"
4949
import { HttpApiSchemaError } from "./HttpApiError.ts"
5050
import type * as HttpApiGroup from "./HttpApiGroup.ts"
@@ -151,7 +151,7 @@ export const group = <
151151
: result
152152
const routes: Array<HttpRouter.Route<any, any>> = []
153153
for (const item of handlers.handlers.values()) {
154-
routes.push(handlerToRoute(group as any, item, services))
154+
routes.push(handlerToRoute(api, group as any, item, services))
155155
}
156156
return Context.makeUnsafe(
157157
new Map([[group.key, {
@@ -509,6 +509,7 @@ export const endpoint = <
509509
const group = api.groups[groupIdentifier] as unknown as HttpApiGroup.Top
510510
const endpoint = group.endpoints[endpointIdentifier]
511511
return Effect.succeed(handlerToHttpEffect(
512+
api,
512513
group,
513514
endpoint,
514515
Context.omit(Scope.Scope)(context),
@@ -721,11 +722,12 @@ type PayloadDecoder =
721722
}
722723

723724
function buildPayloadDecoders(
724-
payloadMap: HttpApiEndpoint.PayloadMap
725+
payloadMap: HttpApiEndpoint.PayloadMap,
726+
options: SchemaAST.ParseOptions | undefined
725727
): Map<string, PayloadDecoder> {
726728
const result = new Map<string, PayloadDecoder>()
727729
payloadMap.forEach(({ encoding, schemas }, contentType) => {
728-
const decode = Schema.decodeUnknownEffect(Schema.Union(schemas))
730+
const decode = Schema.decodeUnknownEffect(Schema.Union(schemas), options)
729731
if (encoding._tag === "Multipart") {
730732
result.set(contentType, { _tag: "Multipart", mode: encoding.mode, limits: encoding.limits, decode })
731733
} else {
@@ -800,25 +802,29 @@ function decodePayload(
800802
}
801803

802804
function handlerToHttpEffect(
805+
api: Pick<HttpApi.Top, "annotations">,
803806
group: HttpApiGroup.Top,
804807
endpoint: HttpApiEndpoint.Top,
805808
context: Context.Context<any>,
806809
handler: HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, any, any>,
807810
isRaw: boolean
808811
) {
809-
const encodeSuccess = Schema.encodeUnknownEffect(makeSuccessSchema(endpoint))
810-
const encodeError = Schema.encodeUnknownEffect(makeErrorSchema(endpoint))
811-
const decodeParams = UndefinedOr.map(endpoint.params, Schema.decodeUnknownEffect)
812-
const decodeHeaders = UndefinedOr.map(endpoint.headers, Schema.decodeUnknownEffect)
812+
const annotations = Context.merge(Context.merge(api.annotations, group.annotations), endpoint.annotations)
813+
const options = Context.getOrUndefined(annotations, HttpApi.ParseOptions)
814+
const decodeUnknownEffect = <S extends Schema.Constraint>(schema: S) => Schema.decodeUnknownEffect(schema, options)
815+
const encodeSuccess = Schema.encodeUnknownEffect(makeSuccessSchema(endpoint), options)
816+
const encodeError = Schema.encodeUnknownEffect(makeErrorSchema(endpoint), options)
817+
const decodeParams = UndefinedOr.map(endpoint.params, decodeUnknownEffect)
818+
const decodeHeaders = UndefinedOr.map(endpoint.headers, decodeUnknownEffect)
813819
const decodeQuery = UndefinedOr.map(
814820
endpoint.query,
815-
(schema) => Schema.decodeUnknownEffect(Schema.toCodecArrayFromSingle(schema))
821+
(schema) => decodeUnknownEffect(Schema.toCodecArrayFromSingle(schema))
816822
)
817-
const encodeStream = makeStreamEncoder(endpoint)
818-
const encodeWithHeaders = makeWithHeadersEncoder(endpoint)
823+
const encodeStream = makeStreamEncoder(endpoint, options)
824+
const encodeWithHeaders = makeWithHeadersEncoder(endpoint, options)
819825

820826
const shouldParsePayload = endpoint.payload.size > 0 && !isRaw
821-
const payloadBy = shouldParsePayload ? buildPayloadDecoders(endpoint.payload) : undefined
827+
const payloadBy = shouldParsePayload ? buildPayloadDecoders(endpoint.payload, options) : undefined
822828

823829
return applyMiddleware(
824830
group,
@@ -886,6 +892,7 @@ function handlerToHttpEffect(
886892

887893
/** @internal */
888894
export function handlerToRoute(
895+
api: Pick<HttpApi.Top, "annotations">,
889896
group: HttpApiGroup.Top,
890897
handler: HandlerRuntime,
891898
context: Context.Context<any>
@@ -894,7 +901,7 @@ export function handlerToRoute(
894901
return HttpRouter.route(
895902
endpoint.method,
896903
HttpApiPath.toRouterPath(endpoint.path, endpoint.params) as HttpRouter.PathInput,
897-
handlerToHttpEffect(group, endpoint, context, handler.handler, handler.isRaw),
904+
handlerToHttpEffect(api, group, endpoint, context, handler.handler, handler.isRaw),
898905
{ uninterruptible: handler.uninterruptible }
899906
)
900907
}
@@ -992,12 +999,15 @@ interface WithHeadersEncoders {
992999
readonly encodeHeaders: ReadonlyMap<number, WithHeadersEncoder>
9931000
}
9941001

995-
function makeWithHeadersEncoder(endpoint: HttpApiEndpoint.Top): WithHeadersEncoders | undefined {
1002+
function makeWithHeadersEncoder(
1003+
endpoint: HttpApiEndpoint.Top,
1004+
options: SchemaAST.ParseOptions | undefined
1005+
): WithHeadersEncoders | undefined {
9961006
const encodeHeaders = new Map<number, WithHeadersEncoder>()
9971007
const bodySchemas: Array<Schema.ConstraintEncoder<HttpServerResponse, unknown>> = []
9981008
for (const schema of endpoint.success) {
9991009
if (!HttpApiSchema.isWithHeaders(schema)) continue
1000-
encodeHeaders.set(HttpApiSchema.getStatusSuccessSchema(schema), Schema.encodeUnknownEffect(schema.headers))
1010+
encodeHeaders.set(HttpApiSchema.getStatusSuccessSchema(schema), Schema.encodeUnknownEffect(schema.headers, options))
10011011
if (!HttpApiSchema.isStreamSchema(schema.schema)) {
10021012
bodySchemas.push(toResponseSuccessSchema(schema))
10031013
}
@@ -1011,12 +1021,15 @@ function makeWithHeadersEncoder(endpoint: HttpApiEndpoint.Top): WithHeadersEncod
10111021
? bodySchemas[0]
10121022
: Schema.Union(bodySchemas)
10131023
return {
1014-
encodeBody: Schema.encodeUnknownEffect(bodySchema),
1024+
encodeBody: Schema.encodeUnknownEffect(bodySchema, options),
10151025
encodeHeaders
10161026
}
10171027
}
10181028

1019-
function makeStreamEncoder(endpoint: HttpApiEndpoint.Top): StreamEncoder | undefined {
1029+
function makeStreamEncoder(
1030+
endpoint: HttpApiEndpoint.Top,
1031+
options: SchemaAST.ParseOptions | undefined
1032+
): StreamEncoder | undefined {
10201033
const successSchema = getStreamSuccessSchema(endpoint)
10211034
if (successSchema === undefined) {
10221035
return undefined
@@ -1045,7 +1058,7 @@ function makeStreamEncoder(endpoint: HttpApiEndpoint.Top): StreamEncoder | undef
10451058
}
10461059
}
10471060

1048-
const sseEncoder = makeSseEncoder(streamSchema)
1061+
const sseEncoder = makeSseEncoder(streamSchema, options)
10491062

10501063
return (response, context) => {
10511064
if (!Stream.isStream(response)) {
@@ -1090,13 +1103,14 @@ interface SseStreamEncoder {
10901103
}
10911104

10921105
function makeSseEncoder<Events extends Sse.EventCodec, Error extends Schema.Constraint>(
1093-
streamSchema: HttpApiSchema.StreamSse<Events, Error, unknown>
1106+
streamSchema: HttpApiSchema.StreamSse<Events, Error, unknown>,
1107+
options: SchemaAST.ParseOptions | undefined
10941108
): SseStreamEncoder {
10951109
const CauseSchema = Schema.toCodecJson(Schema.Cause(streamSchema.error, Schema.Defect()))
10961110
return {
10971111
sseMode: streamSchema.sseMode,
1098-
encodeEvents: Schema.encodeUnknownEffect(Schema.Array(streamSchema.events)) as any,
1099-
encodeCause: Schema.encodeUnknownEffect(Schema.fromJsonString(CauseSchema))
1112+
encodeEvents: Schema.encodeUnknownEffect(Schema.Array(streamSchema.events), options) as any,
1113+
encodeCause: Schema.encodeUnknownEffect(Schema.fromJsonString(CauseSchema), options)
11001114
}
11011115
}
11021116

0 commit comments

Comments
 (0)