Skip to content

Commit

Permalink
HttpApiBuilder: URL parameters are now automatically converted to arr… (
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Feb 13, 2025
1 parent ba409f6 commit c407726
Show file tree
Hide file tree
Showing 3 changed files with 353 additions and 3 deletions.
67 changes: 67 additions & 0 deletions .changeset/new-rules-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@effect/platform": patch
---

HttpApiBuilder: URL parameters are now automatically converted to arrays when needed, closes #4442.

**Example**

```ts
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiGroup,
HttpMiddleware,
HttpServer
} from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Effect, Layer, Schema } from "effect"
import { createServer } from "node:http"

const api = HttpApi.make("api").add(
HttpApiGroup.make("group").add(
HttpApiEndpoint.get("get", "/")
.addSuccess(Schema.String)
.setUrlParams(
Schema.Struct({
param: Schema.NonEmptyArray(Schema.String)
})
)
)
)

const usersGroupLive = HttpApiBuilder.group(api, "group", (handlers) =>
handlers.handle("get", (req) =>
Effect.succeed(req.urlParams.param.join(", "))
)
)

const MyApiLive = HttpApiBuilder.api(api).pipe(Layer.provide(usersGroupLive))

const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
Layer.provide(MyApiLive),
HttpServer.withLogAddress,
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)

Layer.launch(HttpLive).pipe(NodeRuntime.runMain)
```

Previously, if a query parameter was defined as a `NonEmptyArray` (an array that requires at least one element), providing a single value would cause a parsing error.

For example, this worked fine:

```sh
curl "http://localhost:3000/?param=1&param=2"
```

But this would fail:

```sh
curl "http://localhost:3000/?param=1"
```

Resulting in an error because `"1"` was treated as a string instead of an array.

With this update, single values are automatically wrapped in an array, so they match the expected schema without requiring manual fixes.
50 changes: 47 additions & 3 deletions packages/platform/src/HttpApiBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,50 @@ const makeMiddlewareMap = (
return map
}

function isSingleStringType(ast: AST.AST, key?: PropertyKey): boolean {
switch (ast._tag) {
case "StringKeyword":
case "Literal":
case "TemplateLiteral":
return true
case "TypeLiteral": {
if (key !== undefined) {
const ps = ast.propertySignatures.find((ps) => ps.name === key)
return ps !== undefined
? isSingleStringType(ps.type, key)
: ast.indexSignatures.some((is) => Schema.is(Schema.make(is.parameter))(key) && isSingleStringType(is.type))
}
return false
}
case "Union":
return ast.types.some((type) => isSingleStringType(type, key))
case "Suspend":
return isSingleStringType(ast.f(), key)
case "Refinement":
case "Transformation":
return isSingleStringType(ast.from, key)
}
return false
}

/**
* Normalizes the url parameters so that if a key is expected to be an array,
* a single string value is wrapped in an array.
*
* @internal
*/
export function normalizeUrlParams(
params: ReadonlyRecord<string, string | Array<string>>,
ast: AST.AST
): ReadonlyRecord<string, string | Array<string>> {
const out: Record<string, string | Array<string>> = {}
for (const key in params) {
const value = params[key]
out[key] = Array.isArray(value) || isSingleStringType(ast, key) ? value : [value]
}
return out
}

const handlerToRoute = (
endpoint_: HttpApiEndpoint.HttpApiEndpoint.Any,
middleware: MiddlewareMap,
Expand All @@ -574,7 +618,6 @@ const handlerToRoute = (
const decodePath = Option.map(endpoint.pathSchema, Schema.decodeUnknown)
const decodePayload = Option.map(endpoint.payloadSchema, Schema.decodeUnknown)
const decodeHeaders = Option.map(endpoint.headersSchema, Schema.decodeUnknown)
const decodeUrlParams = Option.map(endpoint.urlParamsSchema, Schema.decodeUnknown)
const encodeSuccess = Schema.encode(makeSuccessSchema(endpoint.successSchema))
return HttpRouter.makeRoute(
endpoint.method,
Expand All @@ -600,8 +643,9 @@ const handlerToRoute = (
if (decodeHeaders._tag === "Some") {
request.headers = yield* decodeHeaders.value(httpRequest.headers)
}
if (decodeUrlParams._tag === "Some") {
request.urlParams = yield* decodeUrlParams.value(urlParams)
if (endpoint.urlParamsSchema._tag === "Some") {
const schema = endpoint.urlParamsSchema.value
request.urlParams = yield* Schema.decodeUnknown(schema)(normalizeUrlParams(urlParams, schema.ast))
}
const response = isFullResponse
? yield* handler(request)
Expand Down
239 changes: 239 additions & 0 deletions packages/platform/test/HttpApiBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import type { HttpApiEndpoint } from "@effect/platform"
import { HttpApiBuilder } from "@effect/platform"
import { describe, it } from "@effect/vitest"
import { identity, Schema } from "effect"
import { deepStrictEqual } from "effect/test/util"

const assertNormalizedUrlParams = <UrlParams extends Schema.Schema.Any>(
schema: UrlParams & HttpApiEndpoint.HttpApiEndpoint.ValidateUrlParams<UrlParams>,
params: Record<string, string | Array<string>>,
expected: Record<string, string | Array<string>>
) => {
deepStrictEqual(HttpApiBuilder.normalizeUrlParams(params, schema.ast), expected)
}

describe("HttpApiBuilder", () => {
describe("normalizeUrlParams", () => {
describe("Property Signatures", () => {
it("String", () => {
const schema = Schema.Struct({ a: Schema.String })
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Array(String)", () => {
const schema = Schema.Struct({ a: Schema.Array(Schema.String) })
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Array(String) + minItems", () => {
const schema = Schema.Struct({ a: Schema.Array(Schema.String).pipe(Schema.minItems(2)) })
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("NonEmptyArray(String)", () => {
const schema = Schema.Struct({ a: Schema.NonEmptyArray(Schema.String) })
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("optional(NonEmptyArray(String))", () => {
const schema = Schema.Struct({ a: Schema.optional(Schema.NonEmptyArray(Schema.String)) })
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Tuple", () => {
const schema = Schema.Struct({ a: Schema.Tuple(Schema.String) })
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("NonEmptyArrayEnsure", () => {
const schema = Schema.Struct({
a: Schema.NonEmptyArrayEnsure(Schema.String)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("ArrayEnsure", () => {
const schema = Schema.Struct({
a: Schema.ArrayEnsure(Schema.String)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

describe("Union", () => {
it("TemplateLiteral + Tuple", () => {
const schema = Schema.Struct({
a: Schema.Union(Schema.TemplateLiteral("a", Schema.String), Schema.Tuple(Schema.String))
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Literal + Tuple", () => {
const schema = Schema.Struct({ a: Schema.Union(Schema.Literal("a"), Schema.Tuple(Schema.String)) })
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("String + Tuple", () => {
const schema = Schema.Struct({ a: Schema.Union(Schema.String, Schema.Tuple(Schema.String)) })
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Tuple + Tuple", () => {
const schema = Schema.Struct({
a: Schema.Union(
Schema.Tuple(Schema.NumberFromString),
Schema.Tuple(Schema.BooleanFromString)
)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})
})
})

describe("Index Signatures", () => {
it("String", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.String
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
})

it("Array(String)", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.Array(Schema.String)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Array(String) + minItems", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.Array(Schema.String).pipe(Schema.minItems(2))
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("NonEmptyArray(String)", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.NonEmptyArray(Schema.String)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Tuple", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.Tuple(Schema.String)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("ArrayEnsure", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.ArrayEnsure(Schema.String)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("NonEmptyArrayEnsure", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.NonEmptyArrayEnsure(Schema.String)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

describe("Union", () => {
it("TemplateLiteral + Tuple", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.Union(Schema.TemplateLiteral("a", Schema.String), Schema.Tuple(Schema.String))
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Literal + Tuple", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.Union(Schema.Literal("a"), Schema.Tuple(Schema.String))
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("String + Tuple", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.Union(Schema.String, Schema.Tuple(Schema.String))
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Tuple + Tuple", () => {
const schema = Schema.Record({
key: Schema.String,
value: Schema.Union(
Schema.Tuple(Schema.NumberFromString),
Schema.Tuple(Schema.BooleanFromString)
)
})
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})
})
})

it("Property Signatures + Index Signatures", () => {
const schema = Schema.Struct({
a: Schema.Array(Schema.String).pipe(Schema.minItems(2)),
b: Schema.Tuple(Schema.String, Schema.String)
}, { key: Schema.String, value: Schema.Array(Schema.String) })
assertNormalizedUrlParams(schema, { a: "a", b: "b", c: "c" }, { a: ["a"], b: ["b"], c: ["c"] })
})

it("Union", () => {
const schema = Schema.Union(
Schema.Struct({ a: Schema.String }),
Schema.Struct({ a: Schema.Array(Schema.String) })
)
assertNormalizedUrlParams(schema, { a: "a" }, { a: "a" })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Refinement", () => {
const schema = Schema.Struct({ a: Schema.Array(Schema.String) }).pipe(Schema.filter(() => true))
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})

it("Transformation", () => {
const struct = Schema.Struct({ a: Schema.Array(Schema.String) })
const schema = Schema.transform(struct, struct, { strict: true, decode: identity, encode: identity })
assertNormalizedUrlParams(schema, { a: "a" }, { a: ["a"] })
assertNormalizedUrlParams(schema, { a: ["a"] }, { a: ["a"] })
})
})
})

0 comments on commit c407726

Please sign in to comment.