1/**
2 * Models URL query parameters as ordered string pairs.
3 *
4 * `UrlParams` is used for HTTP client query strings, URL-encoded form bodies,
5 * and server-side decoding. Values can be built from records, iterables, or
6 * native `URLSearchParams`, then updated, serialized, converted to a `URL`, or
7 * decoded with schemas.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as Arr from "../Array.ts"
13import * as Equal from "../Equal.ts"
14import * as Equ from "../Equivalence.ts"
15import { dual } from "../Function.ts"
16import * as Hash from "../Hash.ts"
17import type { Inspectable } from "../Inspectable.ts"
18import { PipeInspectableProto } from "../internal/core.ts"
19import * as InternalRecord from "../internal/record.ts"
20import * as Option from "../Option.ts"
21import type { Pipeable } from "../Pipeable.ts"
22import { hasProperty } from "../Predicate.ts"
23import type { ReadonlyRecord } from "../Record.ts"
24import * as Tuple from "../Tuple.ts"
25
26const TypeId = "~effect/http/UrlParams"
27
28/**
29 * Immutable collection of URL query parameters.
30 *
31 * **Details**
32 *
33 * Parameters are stored as ordered string key-value pairs and can contain multiple
34 * values for the same key.
35 *
36 * @stability unstable
37 * @category models
38 * @since 4.0.0
39 */
40export interface UrlParams extends Pipeable, Inspectable, Iterable<readonly [string, string]> {
41 readonly [TypeId]: typeof TypeId
42 readonly params: ReadonlyArray<readonly [string, string]>
43}
44
45/**
46 * Returns `true` when a value is a `UrlParams` instance.
47 *
48 * @stability unstable
49 * @category guards
50 * @since 4.0.0
51 */
52export const isUrlParams = (u: unknown): u is UrlParams => hasProperty(u, TypeId)
53
54/**
55 * Input accepted when constructing `UrlParams`.
56 *
57 * **Details**
58 *
59 * Values can be provided as a coercible record, an iterable of key-value pairs, or
60 * a native `URLSearchParams` value.
61 *
62 * @stability unstable
63 * @category models
64 * @since 4.0.0
65 */
66export type Input =
67 | UrlParams
68 | CoercibleRecordInput
69 | Iterable<readonly [string, Coercible]>
70 | URLSearchParams
71
72type CoercibleRecordInput = CoercibleRecord & {
73 readonly [Symbol.iterator]?: never
74}
75
76/**
77 * Primitive value that can be converted into a URL parameter string.
78 *
79 * **Gotchas**
80 *
81 * `undefined` values are skipped when constructing from input.
82 *
83 * @stability unstable
84 * @category models
85 * @since 4.0.0
86 */
87export type Coercible = string | number | bigint | boolean | null | undefined
88
89/**
90 * @stability unstable
91 * @category models
92 * @since 4.0.0
93 */
94type CoercibleRecordField<A> = A extends Coercible ? A
95 : A extends ReadonlyArray<infer Item> ? ReadonlyArray<Item extends Coercible ? Item : never>
96 : A extends object ? CoercibleRecord<A>
97 : never
98
99/**
100 * Record input whose fields can be coerced into URL parameter values.
101 *
102 * **Details**
103 *
104 * Nested records are rendered using bracket notation, and arrays produce repeated
105 * parameters.
106 *
107 * @stability unstable
108 * @category models
109 * @since 4.0.0
110 */
111export type CoercibleRecord<A extends object = any> = {
112 readonly [K in keyof A]: CoercibleRecordField<A[K]>
113}
114
115const Proto = {
116 ...PipeInspectableProto,
117 [TypeId]: TypeId,
118 [Symbol.iterator](this: UrlParams) {
119 return this.params[Symbol.iterator]()
120 },
121 toJSON(this: UrlParams): unknown {
122 return {
123 _id: "UrlParams",
124 params: Object.fromEntries(this.params)
125 }
126 },
127 [Equal.symbol](this: UrlParams, that: UrlParams): boolean {
128 return Equivalence(this, that)
129 },
130 [Hash.symbol](this: UrlParams): number {
131 return Hash.array(this.params.flat())
132 }
133}
134
135/**
136 * Creates `UrlParams` from ordered string key-value pairs.
137 *
138 * **Details**
139 *
140 * The input pairs are used as-is and are not coerced or normalized.
141 *
142 * @stability unstable
143 * @category constructors
144 * @since 4.0.0
145 */
146export const make = (params: ReadonlyArray<readonly [string, string]>): UrlParams => {
147 const self = Object.create(Proto)
148 self.params = params
149 return self
150}
151
152/**
153 * Creates `UrlParams` from a supported input shape.
154 *
155 * **Details**
156 *
157 * Primitive values are converted to strings, arrays produce repeated parameters,
158 * nested records use bracket notation, and `undefined` values are omitted.
159 *
160 * @stability unstable
161 * @category constructors
162 * @since 4.0.0
163 */
164export const fromInput = (input: Input): UrlParams => {
165 if (isUrlParams(input)) {
166 return input
167 }
168 const parsed = fromInputNested(input)
169 const out: Array<[string, string]> = []
170 for (let i = 0; i < parsed.length; i++) {
171 if (Array.isArray(parsed[i][0])) {
172 const [keys, value] = parsed[i] as [Array<string>, string]
173 out.push([`${keys[0]}[${keys.slice(1).join("][")}]`, value])
174 } else {
175 out.push(parsed[i] as [string, string])
176 }
177 }
178 return make(out)
179}
180
181const fromInputNested = (input: Input): Array<[string | Array<string>, any]> => {
182 const entries = typeof (input as any)[Symbol.iterator] === "function"
183 ? Arr.fromIterable(input as Iterable<readonly [string, Coercible]>)
184 : Object.entries(input)
185 const out: Array<[string | Array<string>, string]> = []
186 for (const [key, value] of entries) {
187 if (Array.isArray(value)) {
188 for (let i = 0; i < value.length; i++) {
189 if (value[i] !== undefined) {
190 out.push([key, String(value[i])])
191 }
192 }
193 } else if (value !== null && typeof value === "object") {
194 const nested = fromInputNested(value as CoercibleRecord)
195 for (const [k, v] of nested) {
196 out.push([[key, ...(typeof k === "string" ? [k] : k)], v])
197 }
198 } else if (value !== undefined) {
199 out.push([key, String(value)])
200 }
201 }
202 return out
203}
204
205/**
206 * Provides an order-sensitive `Equivalence` instance for `UrlParams`.
207 *
208 * **Details**
209 *
210 * Two values are equivalent when they contain the same key-value pairs in the same
211 * order.
212 *
213 * @stability unstable
214 * @category instances
215 * @since 4.0.0
216 */
217export const Equivalence: Equ.Equivalence<UrlParams> = Equ.make<UrlParams>((a, b) =>
218 arrayEquivalence(a.params, b.params)
219)
220
221const arrayEquivalence = Arr.makeEquivalence(
222 Tuple.makeEquivalence([Equ.strictEqual<string>(), Equ.strictEqual<string>()])
223)
224
225/**
226 * An empty `UrlParams` value.
227 *
228 * @stability unstable
229 * @category constructors
230 * @since 4.0.0
231 */
232export const empty: UrlParams = make([])
233
234/**
235 * Returns all values for a query parameter key in insertion order.
236 *
237 * **Details**
238 *
239 * Returns an empty array when the key is absent.
240 *
241 * @stability unstable
242 * @category combinators
243 * @since 4.0.0
244 */
245export const getAll: {
246 (key: string): (self: UrlParams) => ReadonlyArray<string>
247 (self: UrlParams, key: string): ReadonlyArray<string>
248} = dual(
249 2,
250 (self: UrlParams, key: string): ReadonlyArray<string> =>
251 Arr.reduce(self.params, [] as Array<string>, (acc, [k, value]) => {
252 if (k === key) {
253 acc.push(value)
254 }
255 return acc
256 })
257)
258
259/**
260 * Returns the first value for a query parameter key safely.
261 *
262 * **When to use**
263 *
264 * Use when duplicate query parameters are ordered and the first occurrence has
265 * precedence.
266 *
267 * **Details**
268 *
269 * Returns `Option.none` when the key is absent.
270 *
271 * @stability unstable
272 * @category combinators
273 * @since 4.0.0
274 */
275export const getFirst: {
276 (key: string): (self: UrlParams) => Option.Option<string>
277 (self: UrlParams, key: string): Option.Option<string>
278} = dual(
279 2,
280 (self: UrlParams, key: string): Option.Option<string> =>
281 Arr.findFirst(self.params, ([k]) => k === key).pipe(
282 Option.map(([, value]) => value)
283 )
284)
285
286/**
287 * Returns the last value for a query parameter key safely.
288 *
289 * **When to use**
290 *
291 * Use when duplicate query parameters are ordered and the last occurrence has
292 * precedence.
293 *
294 * **Details**
295 *
296 * Returns `Option.none` when the key is absent.
297 *
298 * @stability unstable
299 * @category combinators
300 * @since 4.0.0
301 */
302export const getLast: {
303 (key: string): (self: UrlParams) => Option.Option<string>
304 (self: UrlParams, key: string): Option.Option<string>
305} = dual(2, (self: UrlParams, key: string): Option.Option<string> =>
306 Arr.findLast(self.params, ([k]) => k === key).pipe(
307 Option.map(([, value]) => value)
308 ))
309
310/**
311 * Sets a query parameter to a single value.
312 *
313 * **Details**
314 *
315 * Existing values for the same key are removed, and the new value is appended to
316 * the end.
317 *
318 * @stability unstable
319 * @category combinators
320 * @since 4.0.0
321 */
322export const set: {
323 (key: string, value: Coercible): (self: UrlParams) => UrlParams
324 (self: UrlParams, key: string, value: Coercible): UrlParams
325} = dual(3, (self: UrlParams, key: string, value: Coercible): UrlParams =>
326 make(
327 Arr.append(
328 Arr.filter(self.params, ([k]) => k !== key),
329 [key, String(value)]
330 )
331 ))
332
333/**
334 * Transforms the underlying ordered key-value pairs of `UrlParams`.
335 *
336 * **Details**
337 *
338 * The result is wrapped in a new `UrlParams` value.
339 *
340 * @stability unstable
341 * @category combinators
342 * @since 4.0.0
343 */
344export const transform: {
345 (f: (params: UrlParams["params"]) => UrlParams["params"]): (self: UrlParams) => UrlParams
346 (self: UrlParams, f: (params: UrlParams["params"]) => UrlParams["params"]): UrlParams
347} = dual(
348 2,
349 (self: UrlParams, f: (params: UrlParams["params"]) => UrlParams["params"]): UrlParams => make(f(self.params))
350)
351
352/**
353 * Sets multiple query parameters from input.
354 *
355 * **Details**
356 *
357 * Keys present in the input replace existing values for those keys, while
358 * unmentioned existing parameters are preserved.
359 *
360 * @stability unstable
361 * @category combinators
362 * @since 4.0.0
363 */
364export const setAll: {
365 (input: Input): (self: UrlParams) => UrlParams
366 (self: UrlParams, input: Input): UrlParams
367} = dual(2, (self: UrlParams, input: Input): UrlParams => {
368 const params = fromInput(input).params.slice()
369 const keys = new Set()
370 for (let i = 0; i < params.length; i++) {
371 keys.add(params[i][0])
372 }
373 for (let i = 0; i < self.params.length; i++) {
374 if (keys.has(self.params[i][0])) continue
375 params.push(self.params[i])
376 }
377 return make(params)
378})
379
380/**
381 * Appends a query parameter value without removing existing values for the key.
382 *
383 * @stability unstable
384 * @category combinators
385 * @since 4.0.0
386 */
387export const append: {
388 (key: string, value: Coercible): (self: UrlParams) => UrlParams
389 (self: UrlParams, key: string, value: Coercible): UrlParams
390} = dual(3, (self: UrlParams, key: string, value: Coercible): UrlParams =>
391 make(Arr.append(
392 self.params,
393 [key, String(value)]
394 )))
395
396/**
397 * Appends all query parameters produced from the supplied input.
398 *
399 * **Details**
400 *
401 * Existing parameters are preserved.
402 *
403 * @stability unstable
404 * @category combinators
405 * @since 4.0.0
406 */
407export const appendAll: {
408 (input: Input): (self: UrlParams) => UrlParams
409 (self: UrlParams, input: Input): UrlParams
410} = dual(2, (self: UrlParams, input: Input): UrlParams => transform(self, Arr.appendAll(fromInput(input).params)))
411
412/**
413 * Removes all query parameter values for the specified key.
414 *
415 * @stability unstable
416 * @category combinators
417 * @since 4.0.0
418 */
419export const remove: {
420 (key: string): (self: UrlParams) => UrlParams
421 (self: UrlParams, key: string): UrlParams
422} = dual(2, (self: UrlParams, key: string): UrlParams => transform(self, Arr.filter(([k]) => k !== key)))
423
424/**
425 * Serializes `UrlParams` to a URL query string without a leading question mark.
426 *
427 * @stability unstable
428 * @category converting
429 * @since 4.0.0
430 */
431export const toString = (input: Input): string => new URLSearchParams(fromInput(input).params as any).toString()
432
433/**
434 * Builds a `Record` containing all the key-value pairs in the given `UrlParams`
435 * as `string` (if only one value for a key) or a `NonEmptyArray<string>`
436 * (when more than one value for a key)
437 *
438 * **Example** (Converting parameters to a record)
439 *
440 * ```ts import.meta.vitest
441 * import { UrlParams } from "effect/http"
442 *
443 * const urlParams = UrlParams.fromInput({
444 * a: 1,
445 * b: true,
446 * c: "string",
447 * e: [1, 2, 3]
448 * })
449 * UrlParams.toRecord(urlParams) // => { a: "1", b: "true", c: "string", e: ["1", "2", "3"] }
450 * ```
451 *
452 * @stability unstable
453 * @category converting
454 * @since 4.0.0
455 */
456export const toRecord = (self: UrlParams): Record<string, string | Arr.NonEmptyArray<string>> => {
457 const out: Record<string, string | Arr.NonEmptyArray<string>> = {}
458 for (const [k, value] of self.params) {
459 if (!Object.hasOwn(out, k)) {
460 InternalRecord.assignProperty(out, k, value)
461 } else {
462 const current = out[k]
463 if (typeof current === "string") {
464 InternalRecord.assignProperty(out, k, [current, value])
465 } else {
466 current.push(value)
467 }
468 }
469 }
470 return out
471}
472
473/**
474 * Builds a readonly record from `UrlParams`.
475 *
476 * **Details**
477 *
478 * Keys with one value map to a string, and keys with multiple values map to a
479 * non-empty readonly array of strings.
480 *
481 * @stability unstable
482 * @category converting
483 * @since 4.0.0
484 */
485export const toReadonlyRecord: (self: UrlParams) => ReadonlyRecord<string, string | Arr.NonEmptyReadonlyArray<string>> =
486 toRecord as any