Skip to content

Commit 24cc35e

Browse files
authored
improve HttpApi handling of payload encoding types (#4024)
1 parent e9dfea3 commit 24cc35e

File tree

8 files changed

+214
-44
lines changed

8 files changed

+214
-44
lines changed

.changeset/spicy-apes-rescue.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@effect/platform-node": patch
3+
"@effect/platform": patch
4+
---
5+
6+
improve HttpApi handling of payload encoding types

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe("HttpApi", () => {
211211
assert.strictEqual(response.status, 200)
212212
}).pipe(Effect.provide(HttpLive)))
213213

214-
it.effect("multipart or json", () =>
214+
it.effect("multiple payload types", () =>
215215
Effect.gen(function*() {
216216
const client = yield* HttpApiClient.make(Api)
217217
let [group, response] = yield* client.groups.create({
@@ -229,6 +229,11 @@ describe("HttpApi", () => {
229229
})
230230
assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
231231
assert.strictEqual(response.status, 200)
232+
233+
group = yield* client.groups.create({
234+
payload: { foo: "Some group" }
235+
})
236+
assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
232237
}).pipe(Effect.provide(HttpLive)))
233238

234239
it("OpenAPI spec", () => {
@@ -287,6 +292,9 @@ class GroupsApi extends HttpApiGroup.make("groups")
287292
HttpApiEndpoint.post("create", "/")
288293
.setPayload(Schema.Union(
289294
Schema.Struct(Struct.pick(Group.fields, "name")),
295+
Schema.Struct({ foo: Schema.String }).pipe(
296+
HttpApiSchema.withEncoding({ kind: "UrlParams" })
297+
),
290298
HttpApiSchema.Multipart(
291299
Schema.Struct(Struct.pick(Group.fields, "name"))
292300
)
@@ -456,7 +464,13 @@ const HttpGroupsLive = HttpApiBuilder.group(
456464
path.id === 0
457465
? Effect.fail(new GroupError())
458466
: Effect.succeed(new Group({ id: 1, name: "foo" })))
459-
.handle("create", ({ payload }) => Effect.succeed(new Group({ id: 1, name: payload.name })))
467+
.handle("create", ({ payload }) =>
468+
Effect.succeed(
469+
new Group({
470+
id: 1,
471+
name: "foo" in payload ? payload.foo : payload.name
472+
})
473+
))
460474
)
461475

462476
const HttpApiLive = Layer.provide(HttpApiBuilder.api(Api), [

packages/platform-node/test/fixtures/openapi.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@
113113
"additionalProperties": false
114114
}
115115
},
116+
"application/x-www-form-urlencoded": {
117+
"schema": {
118+
"type": "object",
119+
"required": ["foo"],
120+
"properties": {
121+
"foo": {
122+
"type": "string"
123+
}
124+
},
125+
"additionalProperties": false
126+
}
127+
},
116128
"multipart/form-data": {
117129
"schema": {
118130
"type": "object",

packages/platform/src/HttpApi.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,10 @@ export const reflect = <Groups extends HttpApiGroup.HttpApiGroup.Any, Error, R>(
282282
readonly endpoint: HttpApiEndpoint.HttpApiEndpoint<string, HttpMethod>
283283
readonly mergedAnnotations: Context.Context<never>
284284
readonly middleware: ReadonlySet<HttpApiMiddleware.TagClassAny>
285+
readonly payloads: ReadonlyMap<string, {
286+
readonly encoding: HttpApiSchema.Encoding
287+
readonly ast: AST.AST
288+
}>
285289
readonly successes: ReadonlyMap<number, Option.Option<AST.AST>>
286290
readonly errors: ReadonlyMap<number, Option.Option<AST.AST>>
287291
}) => void
@@ -311,6 +315,7 @@ export const reflect = <Groups extends HttpApiGroup.HttpApiGroup.Any, Error, R>(
311315
endpoint,
312316
middleware: new Set([...group.middlewares, ...endpoint.middlewares]),
313317
mergedAnnotations: Context.merge(groupAnnotations, endpoint.annotations),
318+
payloads: endpoint.payloadSchema._tag === "Some" ? extractPayloads(endpoint.payloadSchema.value.ast) : emptyMap,
314319
successes: extractMembers(endpoint.successSchema.ast, new Map(), HttpApiSchema.getStatusSuccessAST),
315320
errors
316321
})
@@ -320,6 +325,8 @@ export const reflect = <Groups extends HttpApiGroup.HttpApiGroup.Any, Error, R>(
320325

321326
// -------------------------------------------------------------------------------------
322327

328+
const emptyMap = new Map<never, never>()
329+
323330
const extractMembers = (
324331
topAst: AST.AST,
325332
inherited: ReadonlyMap<number, Option.Option<AST.AST>>,
@@ -361,6 +368,44 @@ const extractMembers = (
361368
return members
362369
}
363370

371+
const extractPayloads = (topAst: AST.AST): ReadonlyMap<string, {
372+
readonly encoding: HttpApiSchema.Encoding
373+
readonly ast: AST.AST
374+
}> => {
375+
const members = new Map<string, {
376+
encoding: HttpApiSchema.Encoding
377+
ast: AST.AST
378+
}>()
379+
function process(ast: AST.AST) {
380+
if (ast._tag === "NeverKeyword") {
381+
return
382+
}
383+
ast = AST.annotations(ast, {
384+
...topAst.annotations,
385+
...ast.annotations
386+
})
387+
const encoding = HttpApiSchema.getEncoding(ast)
388+
const contentType = HttpApiSchema.getMultipart(ast) ? "multipart/form-data" : encoding.contentType
389+
const current = members.get(contentType)
390+
if (current === undefined) {
391+
members.set(contentType, {
392+
encoding,
393+
ast
394+
})
395+
} else {
396+
current.ast = AST.Union.make([current.ast, ast])
397+
}
398+
}
399+
if (topAst._tag === "Union") {
400+
for (const type of topAst.types) {
401+
process(type)
402+
}
403+
} else {
404+
process(topAst)
405+
}
406+
return members
407+
}
408+
364409
/**
365410
* @since 1.0.0
366411
* @category tags

packages/platform/src/HttpApiBuilder.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import * as HttpServerRequest from "./HttpServerRequest.js"
3939
import * as HttpServerResponse from "./HttpServerResponse.js"
4040
import * as OpenApi from "./OpenApi.js"
4141
import type { Path } from "./Path.js"
42+
import * as UrlParams from "./UrlParams.js"
4243

4344
/**
4445
* The router that the API endpoints are attached to.
@@ -516,12 +517,24 @@ const requestPayload = (
516517
| FileSystem
517518
| Path
518519
| Scope
519-
> =>
520-
HttpMethod.hasBody(request.method)
521-
? request.headers["content-type"].includes("multipart/form-data")
522-
? Effect.orDie(request.multipart)
523-
: Effect.orDie(request.json)
524-
: Effect.succeed(urlParams)
520+
> => {
521+
if (!HttpMethod.hasBody(request.method)) {
522+
return Effect.succeed(urlParams)
523+
}
524+
const contentType = request.headers["content-type"]
525+
? request.headers["content-type"].toLowerCase().trim()
526+
: "application/json"
527+
if (contentType.includes("application/json")) {
528+
return Effect.orDie(request.json)
529+
} else if (contentType.includes("multipart/form-data")) {
530+
return Effect.orDie(request.multipart)
531+
} else if (contentType.includes("x-www-form-urlencoded")) {
532+
return Effect.map(Effect.orDie(request.urlParamsBody), UrlParams.toRecord)
533+
} else if (contentType.startsWith("text/")) {
534+
return Effect.orDie(request.text)
535+
}
536+
return Effect.map(Effect.orDie(request.arrayBuffer), (buffer) => new Uint8Array(buffer))
537+
}
525538

526539
type MiddlewareMap = Map<string, {
527540
readonly tag: HttpApiMiddleware.TagClassAny

packages/platform/src/HttpApiClient.ts

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import * as Context from "effect/Context"
55
import * as Effect from "effect/Effect"
66
import { identity } from "effect/Function"
7+
import { globalValue } from "effect/GlobalValue"
78
import * as Option from "effect/Option"
89
import * as ParseResult from "effect/ParseResult"
910
import type * as Predicate from "effect/Predicate"
@@ -15,12 +16,14 @@ import * as HttpApi from "./HttpApi.js"
1516
import type { HttpApiEndpoint } from "./HttpApiEndpoint.js"
1617
import type { HttpApiGroup } from "./HttpApiGroup.js"
1718
import * as HttpApiSchema from "./HttpApiSchema.js"
19+
import * as HttpBody from "./HttpBody.js"
1820
import * as HttpClient from "./HttpClient.js"
1921
import * as HttpClientError from "./HttpClientError.js"
2022
import * as HttpClientRequest from "./HttpClientRequest.js"
2123
import * as HttpClientResponse from "./HttpClientResponse.js"
2224
import * as HttpMethod from "./HttpMethod.js"
2325
import type { HttpApiMiddleware } from "./index.js"
26+
import * as UrlParams from "./UrlParams.js"
2427

2528
/**
2629
* @since 1.0.0
@@ -160,8 +163,13 @@ const makeClient = <Groups extends HttpApiGroup.Any, ApiError, ApiR>(
160163
successes.forEach((ast, status) => {
161164
decodeMap[status] = ast._tag === "None" ? responseAsVoid : schemaToResponse(ast.value)
162165
})
163-
const encodePayload = endpoint.payloadSchema.pipe(
164-
Option.map(Schema.encodeUnknown)
166+
const encodePayloadBody = endpoint.payloadSchema.pipe(
167+
Option.map((schema) => {
168+
if (HttpMethod.hasBody(endpoint.method)) {
169+
return Schema.encodeUnknown(payloadSchemaBody(schema as any))
170+
}
171+
return Schema.encodeUnknown(schema)
172+
})
165173
)
166174
const encodeHeaders = endpoint.headersSchema.pipe(
167175
Option.map(Schema.encodeUnknown)
@@ -180,13 +188,16 @@ const makeClient = <Groups extends HttpApiGroup.Any, ApiError, ApiR>(
180188
let httpRequest = HttpClientRequest.make(endpoint.method)(
181189
request && request.path ? makeUrl(request.path) : endpoint.path
182190
)
183-
if (request.payload instanceof FormData) {
191+
if (request && request.payload instanceof FormData) {
184192
httpRequest = HttpClientRequest.bodyFormData(httpRequest, request.payload)
185-
} else if (encodePayload._tag === "Some") {
186-
const payload = yield* encodePayload.value(request.payload)
187-
httpRequest = HttpMethod.hasBody(endpoint.method)
188-
? yield* Effect.orDie(HttpClientRequest.bodyJson(httpRequest, payload))
189-
: HttpClientRequest.setUrlParams(httpRequest, payload as any)
193+
} else if (encodePayloadBody._tag === "Some") {
194+
if (HttpMethod.hasBody(endpoint.method)) {
195+
const body = (yield* encodePayloadBody.value(request.payload)) as HttpBody.HttpBody
196+
httpRequest = HttpClientRequest.setBody(httpRequest, body)
197+
} else {
198+
const urlParams = (yield* encodePayloadBody.value(request.payload)) as Record<string, string>
199+
httpRequest = HttpClientRequest.setUrlParams(httpRequest, urlParams)
200+
}
190201
}
191202
if (encodeHeaders._tag === "Some") {
192203
httpRequest = HttpClientRequest.setHeaders(
@@ -416,3 +427,58 @@ const statusCodeError = (response: HttpClientResponse.HttpClientResponse) =>
416427
)
417428

418429
const responseAsVoid = (_response: HttpClientResponse.HttpClientResponse) => Effect.void
430+
431+
const HttpBodyFromSelf = Schema.declare(HttpBody.isHttpBody)
432+
433+
const payloadSchemaBody = (schema: Schema.Schema.All): Schema.Schema<any, HttpBody.HttpBody> => {
434+
const members = schema.ast._tag === "Union" ? schema.ast.types : [schema.ast]
435+
return Schema.Union(...members.map(bodyFromPayload)) as any
436+
}
437+
438+
const bodyFromPayloadCache = globalValue(
439+
"@effect/platform/HttpApiClient/bodyFromPayloadCache",
440+
() => new WeakMap<AST.AST, Schema.Schema.Any>()
441+
)
442+
443+
const bodyFromPayload = (ast: AST.AST) => {
444+
if (bodyFromPayloadCache.has(ast)) {
445+
return bodyFromPayloadCache.get(ast)!
446+
}
447+
const schema = Schema.make(ast)
448+
const encoding = HttpApiSchema.getEncoding(ast)
449+
const transform = Schema.transformOrFail(
450+
HttpBodyFromSelf,
451+
schema,
452+
{
453+
decode(fromA, _, ast) {
454+
return ParseResult.fail(new ParseResult.Forbidden(ast, fromA, "encode only schema"))
455+
},
456+
encode(toI, _, ast) {
457+
switch (encoding.kind) {
458+
case "Json": {
459+
return HttpBody.json(toI).pipe(
460+
ParseResult.mapError((error) => new ParseResult.Type(ast, toI, `Could not encode as JSON: ${error}`))
461+
)
462+
}
463+
case "Text": {
464+
if (typeof toI !== "string") {
465+
return ParseResult.fail(new ParseResult.Type(ast, toI, "Expected a string"))
466+
}
467+
return ParseResult.succeed(HttpBody.text(toI))
468+
}
469+
case "UrlParams": {
470+
return ParseResult.succeed(HttpBody.urlParams(UrlParams.fromInput(toI as any)))
471+
}
472+
case "Uint8Array": {
473+
if (!(toI instanceof Uint8Array)) {
474+
return ParseResult.fail(new ParseResult.Type(ast, toI, "Expected a Uint8Array"))
475+
}
476+
return ParseResult.succeed(HttpBody.uint8Array(toI))
477+
}
478+
}
479+
}
480+
}
481+
)
482+
bodyFromPayloadCache.set(ast, transform)
483+
return transform
484+
}

packages/platform/src/OpenApi.ts

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ export const fromApi = <A extends HttpApi.HttpApi.Any>(self: A): OpenAPISpec =>
231231
})
232232
spec.tags!.push(tag)
233233
},
234-
onEndpoint({ endpoint, errors, group, middleware, successes }) {
234+
onEndpoint({ endpoint, errors, group, middleware, payloads, successes }) {
235235
const path = endpoint.path.replace(/:(\w+)[^/]*/g, "{$1}")
236236
const method = endpoint.method.toLowerCase() as OpenAPISpecMethodName
237237
const op: DeepMutable<OpenAPISpecOperation> = {
@@ -266,35 +266,15 @@ export const fromApi = <A extends HttpApi.HttpApi.Any>(self: A): OpenAPISpec =>
266266
op.security!.push({ [name]: [] })
267267
}
268268
})
269-
endpoint.payloadSchema.pipe(
270-
Option.filter(() => HttpMethod.hasBody(endpoint.method)),
271-
Option.map((schema) => {
272-
const content: Mutable<OpenApiSpecContent> = {}
273-
const members = schema.ast._tag === "Union" ? schema.ast.types : [schema.ast]
274-
const jsonTypes: Array<AST.AST> = []
275-
const multipartTypes: Array<AST.AST> = []
276-
277-
for (const member of members) {
278-
if (HttpApiSchema.getMultipart(member)) {
279-
multipartTypes.push(member)
280-
} else {
281-
jsonTypes.push(member)
282-
}
283-
}
284-
285-
if (jsonTypes.length > 0) {
286-
content["application/json"] = {
287-
schema: makeJsonSchemaOrRef(Schema.make(AST.Union.make(jsonTypes)))
288-
}
269+
if (payloads.size > 0) {
270+
const content: Mutable<OpenApiSpecContent> = {}
271+
payloads.forEach(({ ast }, contentType) => {
272+
content[contentType as OpenApiSpecContentType] = {
273+
schema: makeJsonSchemaOrRef(Schema.make(ast))
289274
}
290-
if (multipartTypes.length > 0) {
291-
content["multipart/form-data"] = {
292-
schema: makeJsonSchemaOrRef(Schema.make(AST.Union.make(multipartTypes)))
293-
}
294-
}
295-
op.requestBody = { content, required: true }
296275
})
297-
)
276+
op.requestBody = { content, required: true }
277+
}
298278
for (const [status, ast] of successes) {
299279
if (op.responses![status]) continue
300280
op.responses![status] = {

packages/platform/src/UrlParams.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,40 @@ const baseUrl = (): string | undefined => {
210210
return undefined
211211
}
212212

213+
/**
214+
* Builds a `Record` containing all the key-value pairs in the given `UrlParams`
215+
* as `string` (if only one value for a key) or a `NonEmptyArray<string>`
216+
* (when more than one value for a key)
217+
*
218+
* @example
219+
* import { UrlParams } from "@effect/platform"
220+
*
221+
* const urlParams = UrlParams.fromInput({ a: 1, b: true, c: "string", e: [1, 2, 3] })
222+
* const result = UrlParams.toRecord(urlParams)
223+
*
224+
* assert.deepStrictEqual(
225+
* result,
226+
* { "a": "1", "b": "true", "c": "string", "e": ["1", "2", "3"] }
227+
* )
228+
*
229+
* @since 1.0.0
230+
* @category conversions
231+
*/
232+
export const toRecord = (self: UrlParams): Record<string, string | Arr.NonEmptyArray<string>> => {
233+
const out: Record<string, string | Arr.NonEmptyArray<string>> = {}
234+
for (const [k, value] of self) {
235+
const curr = out[k]
236+
if (curr === undefined) {
237+
out[k] = value
238+
} else if (typeof curr === "string") {
239+
out[k] = [curr, value]
240+
} else {
241+
curr.push(value)
242+
}
243+
}
244+
return out
245+
}
246+
213247
/**
214248
* @since 1.0.0
215249
* @category schema

0 commit comments

Comments
 (0)