Skip to content

Commit

Permalink
support array of values in /platform url param schemas (#4015)
Browse files Browse the repository at this point in the history
  • Loading branch information
tim-smart committed Nov 27, 2024
1 parent 70363b7 commit 7e81971
Show file tree
Hide file tree
Showing 9 changed files with 109 additions and 84 deletions.
6 changes: 6 additions & 0 deletions .changeset/unlucky-dingos-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@effect/platform-node": minor
"@effect/platform": minor
---

support array of values in /platform url param schemas
1 change: 1 addition & 0 deletions packages/platform-node/test/HttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe("HttpServer", () => {
const part = formData.file
assert(typeof part !== "string")
const file = part[0]
assert(typeof file !== "string")
expect(file.path.endsWith("/test.txt")).toEqual(true)
expect(file.contentType).toEqual("text/plain")
return yield* HttpServerResponse.json({ ok: "file" in formData })
Expand Down
12 changes: 8 additions & 4 deletions packages/platform/src/HttpIncomingMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { ParseOptions } from "effect/SchemaAST"
import type * as Stream from "effect/Stream"
import * as FileSystem from "./FileSystem.js"
import type * as Headers from "./Headers.js"
import type * as UrlParams from "./UrlParams.js"
import * as UrlParams from "./UrlParams.js"

/**
* @since 1.0.0
Expand Down Expand Up @@ -56,13 +56,17 @@ export const schemaBodyJson = <A, I, R>(schema: Schema.Schema<A, I, R>, options?
* @since 1.0.0
* @category schema
*/
export const schemaBodyUrlParams = <A, I extends Readonly<Record<string, string | undefined>>, R>(
export const schemaBodyUrlParams = <
A,
I extends Readonly<Record<string, string | ReadonlyArray<string> | undefined>>,
R
>(
schema: Schema.Schema<A, I, R>,
options?: ParseOptions | undefined
) => {
const parse = Schema.decodeUnknown(schema, options)
const decode = UrlParams.schemaStruct(schema, options)
return <E>(self: HttpIncomingMessage<E>): Effect.Effect<A, E | ParseResult.ParseError, R> =>
Effect.flatMap(self.urlParamsBody, (_) => parse(Object.fromEntries(_)))
Effect.flatMap(self.urlParamsBody, decode)
}

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/platform/src/HttpServerRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ export const schemaBodyForm: <A, I extends Partial<Multipart.Persisted>, R>(
* @since 1.0.0
* @category schema
*/
export const schemaBodyUrlParams: <A, I extends Readonly<Record<string, string | undefined>>, R>(
export const schemaBodyUrlParams: <
A,
I extends Readonly<Record<string, string | ReadonlyArray<string> | undefined>>,
R
>(
schema: Schema.Schema<A, I, R>,
options?: ParseOptions | undefined
) => Effect.Effect<A, ParseResult.ParseError | Error.RequestError, R | HttpServerRequest> = internal.schemaBodyUrlParams
Expand Down
2 changes: 1 addition & 1 deletion packages/platform/src/Multipart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const isPersistedFile: (u: unknown) => u is PersistedFile = internal.isPe
* @category models
*/
export interface Persisted {
readonly [key: string]: ReadonlyArray<PersistedFile> | string
readonly [key: string]: ReadonlyArray<PersistedFile> | ReadonlyArray<string> | string
}

/**
Expand Down
98 changes: 50 additions & 48 deletions packages/platform/src/UrlParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,40 +84,6 @@ export const getAll: {
})
)

/**
* Builds a `Record` containing all the key-value pairs in the given `UrlParams`
* as `string` (if only one value for a key) or a `NonEmptyArray<string>`
* (when more than one value for a key)
*
* @example
* import { UrlParams } from "@effect/platform"
*
* const urlParams = UrlParams.fromInput({ a: 1, b: true, c: "string", e: [1, 2, 3] })
* const result = UrlParams.extractAll(urlParams)
*
* assert.deepStrictEqual(
* result,
* { "a": "1", "b": "true", "c": "string", "e": ["1", "2", "3"] }
* )
*
* @since 1.0.0
* @category combinators
*/
export const extractAll = (self: UrlParams): Record<string, string | Arr.NonEmptyArray<string>> => {
const out: Record<string, string | Arr.NonEmptyArray<string>> = {}
for (const [k, value] of self) {
const curr = out[k]
if (curr === undefined) {
out[k] = value
} else if (typeof curr === "string") {
out[k] = [curr, value]
} else {
curr.push(value)
}
}
return out
}

/**
* @since 1.0.0
* @category combinators
Expand Down Expand Up @@ -206,13 +172,7 @@ export const remove: {

/**
* @since 1.0.0
* @category combinators
*/
export const toString = (self: UrlParams): string => new URLSearchParams(self as any).toString()

/**
* @since 1.0.0
* @category constructors
* @category conversions
*/
export const makeUrl = (url: string, params: UrlParams, hash: Option.Option<string>): Either.Either<URL, Error> => {
try {
Expand All @@ -232,6 +192,12 @@ export const makeUrl = (url: string, params: UrlParams, hash: Option.Option<stri
}
}

/**
* @since 1.0.0
* @category conversions
*/
export const toString = (self: UrlParams): string => new URLSearchParams(self as any).toString()

const baseUrl = (): string | undefined => {
if (
"location" in globalThis &&
Expand All @@ -244,6 +210,40 @@ const baseUrl = (): string | undefined => {
return undefined
}

/**
* Builds a `Record` containing all the key-value pairs in the given `UrlParams`
* as `string` (if only one value for a key) or a `NonEmptyArray<string>`
* (when more than one value for a key)
*
* @example
* import { UrlParams } from "@effect/platform"
*
* const urlParams = UrlParams.fromInput({ a: 1, b: true, c: "string", e: [1, 2, 3] })
* const result = UrlParams.toRecord(urlParams)
*
* assert.deepStrictEqual(
* result,
* { "a": "1", "b": "true", "c": "string", "e": ["1", "2", "3"] }
* )
*
* @since 1.0.0
* @category conversions
*/
export const toRecord = (self: UrlParams): Record<string, string | Arr.NonEmptyArray<string>> => {
const out: Record<string, string | Arr.NonEmptyArray<string>> = {}
for (const [k, value] of self) {
const curr = out[k]
if (curr === undefined) {
out[k] = value
} else if (typeof curr === "string") {
out[k] = [curr, value]
} else {
curr.push(value)
}
}
return out
}

/**
* @since 1.0.0
* @category schema
Expand Down Expand Up @@ -273,7 +273,7 @@ export const schemaJson = <A, I, R>(schema: Schema.Schema<A, I, R>, options?: Pa
*
* Effect.gen(function* () {
* const urlParams = UrlParams.fromInput({ "a": [10, "string"], "b": false })
* const result = yield* UrlParams.extractSchema(Schema.Struct({
* const result = yield* UrlParams.schemaStruct(Schema.Struct({
* a: Schema.Tuple(Schema.NumberFromString, Schema.String),
* b: Schema.BooleanFromString
* }))(urlParams)
Expand All @@ -287,9 +287,11 @@ export const schemaJson = <A, I, R>(schema: Schema.Schema<A, I, R>, options?: Pa
* @since 1.0.0
* @category schema
*/
export const extractSchema =
<A, I, R>(schema: Schema.Schema<A, I, R>, options?: ParseOptions | undefined) =>
(self: UrlParams): Effect.Effect<A, ParseResult.ParseError, R> => {
const parse = Schema.decodeUnknown(schema, options)
return parse(extractAll(self))
}
export const schemaStruct = <A, I extends Record<string, string | ReadonlyArray<string> | undefined>, R>(
schema: Schema.Schema<A, I, R>,
options?: ParseOptions | undefined
) =>
(self: UrlParams): Effect.Effect<A, ParseResult.ParseError, R> => {
const parse = Schema.decodeUnknown(schema, options)
return parse(toRecord(self))
}
6 changes: 5 additions & 1 deletion packages/platform/src/internal/httpServerRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ export const schemaBodyForm = <A, I extends Partial<Multipart.Persisted>, R>(
}

/** @internal */
export const schemaBodyUrlParams = <A, I extends Readonly<Record<string, string | undefined>>, R>(
export const schemaBodyUrlParams = <
A,
I extends Readonly<Record<string, string | ReadonlyArray<string> | undefined>>,
R
>(
schema: Schema.Schema<A, I, R>,
options?: ParseOptions | undefined
) => {
Expand Down
46 changes: 25 additions & 21 deletions packages/platform/src/internal/multipart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,30 +397,34 @@ export const toPersisted = (
const fs = yield* FileSystem.FileSystem
const path_ = yield* Path.Path
const dir = yield* fs.makeTempDirectoryScoped()
return yield* Stream.runFoldEffect(
stream,
Object.create(null) as Record<string, Array<Multipart.PersistedFile> | string>,
(persisted, part) => {
if (part._tag === "Field") {
const persisted: Record<string, Array<Multipart.PersistedFile> | Array<string> | string> = Object.create(null)
yield* Stream.runForEach(stream, (part) => {
if (part._tag === "Field") {
if (!(part.key in persisted)) {
persisted[part.key] = part.value
return Effect.succeed(persisted)
} else if (typeof persisted[part.key] === "string") {
persisted[part.key] = [persisted[part.key] as string, part.value]
} else {
;(persisted[part.key] as Array<string>).push(part.value)
}
const file = part
const path = path_.join(dir, path_.basename(file.name).slice(-128))
if (!Array.isArray(persisted[part.key])) {
persisted[part.key] = []
}
;(persisted[part.key] as Array<Multipart.PersistedFile>).push(
new PersistedFileImpl(
file.key,
file.name,
file.contentType,
path
)
)
return Effect.as(writeFile(path, file), persisted)
return Effect.void
}
)
const file = part
const path = path_.join(dir, path_.basename(file.name).slice(-128))
const filePart = new PersistedFileImpl(
file.key,
file.name,
file.contentType,
path
)
if (Array.isArray(persisted[part.key])) {
;(persisted[part.key] as Array<Multipart.PersistedFile>).push(filePart)
} else {
persisted[part.key] = [filePart]
}
return writeFile(path, file)
})
return persisted
}).pipe(
Effect.catchTags({
SystemError: (cause) => Effect.fail(new MultipartError({ reason: "InternalError", cause })),
Expand Down
16 changes: 8 additions & 8 deletions packages/platform/test/UrlParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,42 +85,42 @@ describe("UrlParams", () => {
})
})

describe("extractAll", () => {
describe("toRecord", () => {
it("works when empty", () => {
assert.deepStrictEqual(
UrlParams.extractAll(UrlParams.empty),
UrlParams.toRecord(UrlParams.empty),
{}
)
})

it("builds non empty array from same keys", () => {
assert.deepStrictEqual(
UrlParams.extractAll(UrlParams.fromInput({ "a": [10, "string", false] })),
UrlParams.toRecord(UrlParams.fromInput({ "a": [10, "string", false] })),
{ a: ["10", "string", "false"] }
)
})

it("works with non-strings", () => {
const urlParams = UrlParams.fromInput({ a: 1, b: true, c: "string", e: [1, 2, 3] })
const result = UrlParams.extractAll(urlParams)
const result = UrlParams.toRecord(urlParams)
assert.deepStrictEqual(
result,
{ "a": "1", "b": "true", "c": "string", "e": ["1", "2", "3"] }
)
})
})

describe("extractSchema", () => {
describe("schemaStruct", () => {
it.effect("works when empty", () =>
Effect.gen(function*() {
const result = yield* UrlParams.extractSchema(Schema.Struct({}))(UrlParams.empty)
const result = yield* UrlParams.schemaStruct(Schema.Struct({}))(UrlParams.empty)
assert.deepStrictEqual(result, {})
}))

it.effect("parse original values", () =>
Effect.gen(function*() {
const urlParams = UrlParams.fromInput({ "a": [10, "string", false] })
const result = yield* UrlParams.extractSchema(Schema.Struct({
const result = yield* UrlParams.schemaStruct(Schema.Struct({
a: Schema.Tuple(Schema.NumberFromString, Schema.String, Schema.BooleanFromString)
}))(urlParams)
assert.deepStrictEqual(result, {
Expand All @@ -131,7 +131,7 @@ describe("UrlParams", () => {
it.effect("parse multiple keys", () =>
Effect.gen(function*() {
const urlParams = UrlParams.fromInput({ "a": [10, "string"], "b": false })
const result = yield* UrlParams.extractSchema(Schema.Struct({
const result = yield* UrlParams.schemaStruct(Schema.Struct({
a: Schema.Tuple(Schema.NumberFromString, Schema.String),
b: Schema.BooleanFromString
}))(urlParams)
Expand Down

0 comments on commit 7e81971

Please sign in to comment.