diff --git a/.changeset/unlucky-dingos-search.md b/.changeset/unlucky-dingos-search.md new file mode 100644 index 00000000000..a0a911259f8 --- /dev/null +++ b/.changeset/unlucky-dingos-search.md @@ -0,0 +1,6 @@ +--- +"@effect/platform-node": minor +"@effect/platform": minor +--- + +support array of values in /platform url param schemas diff --git a/packages/platform-node/test/HttpServer.test.ts b/packages/platform-node/test/HttpServer.test.ts index 69c8bf19277..ee622e45969 100644 --- a/packages/platform-node/test/HttpServer.test.ts +++ b/packages/platform-node/test/HttpServer.test.ts @@ -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 }) diff --git a/packages/platform/src/HttpIncomingMessage.ts b/packages/platform/src/HttpIncomingMessage.ts index c59d3f8d1ce..9d37cbc17f6 100644 --- a/packages/platform/src/HttpIncomingMessage.ts +++ b/packages/platform/src/HttpIncomingMessage.ts @@ -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 @@ -56,13 +56,17 @@ export const schemaBodyJson = (schema: Schema.Schema, options? * @since 1.0.0 * @category schema */ -export const schemaBodyUrlParams = >, R>( +export const schemaBodyUrlParams = < + A, + I extends Readonly | undefined>>, + R +>( schema: Schema.Schema, options?: ParseOptions | undefined ) => { - const parse = Schema.decodeUnknown(schema, options) + const decode = UrlParams.schemaStruct(schema, options) return (self: HttpIncomingMessage): Effect.Effect => - Effect.flatMap(self.urlParamsBody, (_) => parse(Object.fromEntries(_))) + Effect.flatMap(self.urlParamsBody, decode) } /** diff --git a/packages/platform/src/HttpServerRequest.ts b/packages/platform/src/HttpServerRequest.ts index 27bc396554f..3382467ea27 100644 --- a/packages/platform/src/HttpServerRequest.ts +++ b/packages/platform/src/HttpServerRequest.ts @@ -183,7 +183,11 @@ export const schemaBodyForm: , R>( * @since 1.0.0 * @category schema */ -export const schemaBodyUrlParams: >, R>( +export const schemaBodyUrlParams: < + A, + I extends Readonly | undefined>>, + R +>( schema: Schema.Schema, options?: ParseOptions | undefined ) => Effect.Effect = internal.schemaBodyUrlParams diff --git a/packages/platform/src/Multipart.ts b/packages/platform/src/Multipart.ts index ee0dce2c453..d3b4a07896a 100644 --- a/packages/platform/src/Multipart.ts +++ b/packages/platform/src/Multipart.ts @@ -114,7 +114,7 @@ export const isPersistedFile: (u: unknown) => u is PersistedFile = internal.isPe * @category models */ export interface Persisted { - readonly [key: string]: ReadonlyArray | string + readonly [key: string]: ReadonlyArray | ReadonlyArray | string } /** diff --git a/packages/platform/src/UrlParams.ts b/packages/platform/src/UrlParams.ts index 1dd2269d361..69d3c25d821 100644 --- a/packages/platform/src/UrlParams.ts +++ b/packages/platform/src/UrlParams.ts @@ -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` - * (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> => { - const out: Record> = {} - 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 @@ -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): Either.Either => { try { @@ -232,6 +192,12 @@ export const makeUrl = (url: string, params: UrlParams, hash: Option.Option new URLSearchParams(self as any).toString() + const baseUrl = (): string | undefined => { if ( "location" in globalThis && @@ -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` + * (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> => { + const out: Record> = {} + 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 @@ -273,7 +273,7 @@ export const schemaJson = (schema: Schema.Schema, 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) @@ -287,9 +287,11 @@ export const schemaJson = (schema: Schema.Schema, options?: Pa * @since 1.0.0 * @category schema */ -export const extractSchema = - (schema: Schema.Schema, options?: ParseOptions | undefined) => - (self: UrlParams): Effect.Effect => { - const parse = Schema.decodeUnknown(schema, options) - return parse(extractAll(self)) - } +export const schemaStruct = | undefined>, R>( + schema: Schema.Schema, + options?: ParseOptions | undefined +) => +(self: UrlParams): Effect.Effect => { + const parse = Schema.decodeUnknown(schema, options) + return parse(toRecord(self)) +} diff --git a/packages/platform/src/internal/httpServerRequest.ts b/packages/platform/src/internal/httpServerRequest.ts index 56880eac11d..dc9d8eade58 100644 --- a/packages/platform/src/internal/httpServerRequest.ts +++ b/packages/platform/src/internal/httpServerRequest.ts @@ -122,7 +122,11 @@ export const schemaBodyForm = , R>( } /** @internal */ -export const schemaBodyUrlParams = >, R>( +export const schemaBodyUrlParams = < + A, + I extends Readonly | undefined>>, + R +>( schema: Schema.Schema, options?: ParseOptions | undefined ) => { diff --git a/packages/platform/src/internal/multipart.ts b/packages/platform/src/internal/multipart.ts index b73d6ac0d7b..c58b63a8f14 100644 --- a/packages/platform/src/internal/multipart.ts +++ b/packages/platform/src/internal/multipart.ts @@ -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>, - (persisted, part) => { - if (part._tag === "Field") { + const persisted: Record | Array | 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).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).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).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 })), diff --git a/packages/platform/test/UrlParams.test.ts b/packages/platform/test/UrlParams.test.ts index 30b719fa63b..30b7e103f09 100644 --- a/packages/platform/test/UrlParams.test.ts +++ b/packages/platform/test/UrlParams.test.ts @@ -85,24 +85,24 @@ 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"] } @@ -110,17 +110,17 @@ describe("UrlParams", () => { }) }) - 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, { @@ -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)