1/**
2 * Parses and edits platform `URL` values.
3 *
4 * The HTTP modules use the standard `URL` object as their URL representation.
5 * This module adds safe parsing and helpers that return updated copies when
6 * changing credentials, host, path, protocol, query, or hash parts. Query
7 * strings can also be read or updated through `UrlParams`.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as Cause from "../Cause.ts"
13import * as Data from "../Data.ts"
14import { dual } from "../Function.ts"
15import * as Redacted from "../Redacted.ts"
16import * as Result from "../Result.ts"
17import * as UrlParams from "./UrlParams.ts"
18
19/**
20 * Error returned when constructing a `URL` fails.
21 *
22 * @stability unstable
23 * @category errors
24 * @since 4.0.0
25 */
26export class UrlError extends Data.TaggedError("UrlError")<{
27 readonly cause: unknown
28}> {}
29
30/**
31 * Creates a `URL` safely by appending `UrlParams` and an optional hash to a URL string.
32 *
33 * **Details**
34 *
35 * Returns a `Result` that fails with `UrlError` if the URL cannot be constructed.
36 *
37 * @stability unstable
38 * @category constructors
39 * @since 4.0.0
40 */
41export const make = (
42 url: string,
43 params: UrlParams.UrlParams,
44 hash: string | undefined
45): Result.Result<URL, UrlError> =>
46 Result.try({
47 try: () => {
48 const urlInstance = new URL(url, baseUrl())
49 for (let i = 0; i < params.params.length; i++) {
50 const [key, value] = params.params[i]
51 if (value !== undefined) {
52 urlInstance.searchParams.append(key, value)
53 }
54 }
55 if (hash !== undefined) {
56 urlInstance.hash = hash
57 }
58 return urlInstance
59 },
60 catch: (cause) => new UrlError({ cause })
61 })
62
63const baseUrl = (): string | undefined => {
64 if (
65 "location" in globalThis &&
66 globalThis.location !== undefined &&
67 globalThis.location.origin !== undefined &&
68 globalThis.location.pathname !== undefined
69 ) {
70 return location.origin + location.pathname
71 }
72 return undefined
73}
74
75/**
76 * Parses a URL string safely into a `URL` object, returning a `Result` type for
77 * error handling.
78 *
79 * **Details**
80 *
81 * This function converts a string into a `URL` object, enabling safe URL
82 * parsing with built-in error handling. If the string is invalid or fails to
83 * parse, this function does not throw an error; instead, it wraps the error in
84 * a `IllegalArgumentError` and returns it as the `Failure` value of an
85 * `Result`. The `Success` value contains the successfully parsed `URL`.
86 *
87 * An optional `base` parameter can be provided to resolve relative URLs. If
88 * specified, the function interprets the input `url` as relative to this
89 * `base`. This is especially useful when dealing with URLs that might not be
90 * fully qualified.
91 *
92 * **Example** (Parsing absolute and relative URLs)
93 *
94 * ```ts import.meta.vitest
95 * import { Result } from "effect"
96 * import { Url } from "effect/http"
97 *
98 * // Parse an absolute URL
99 * //
100 * // ┌─── Result<URL, IllegalArgumentError>
101 * // ▼
102 * const parsed = Url.fromString("https://example.com/path")
103 *
104 * Result.map(parsed, (url) => url.toString()) // => Result.succeed("https://example.com/path")
105 *
106 * // Parse a relative URL with a base
107 * const relativeParsed = Url.fromString("/relative-path", "https://example.com")
108 *
109 * Result.map(relativeParsed, (url) => url.toString()) // => Result.succeed("https://example.com/relative-path")
110 * ```
111 *
112 * @stability unstable
113 * @category constructors
114 * @since 4.0.0
115 */
116export const fromString: {
117 (url: string, base?: string | URL | undefined): Result.Result<URL, Cause.IllegalArgumentError>
118} = (url, base) =>
119 Result.try({
120 try: () => new URL(url, base),
121 catch: () =>
122 new Cause.IllegalArgumentError(`Invalid URL: "${url}"${base !== undefined ? ` with base "${base}"` : ""}`)
123 })
124
125/**
126 * Updates a cloned `URL` with a callback, allowing multiple changes at once.
127 *
128 * **Example** (Mutating URL credentials)
129 *
130 * ```ts import.meta.vitest
131 * import { Url } from "effect/http"
132 *
133 * const myUrl = new URL("https://example.com")
134 *
135 * const mutatedUrl = Url.mutate(myUrl, (url) => {
136 * url.username = "user"
137 * url.password = "pass"
138 * })
139 *
140 * mutatedUrl.toString() // => "https://user:pass@example.com/"
141 * ```
142 *
143 * @stability unstable
144 * @category transforming
145 * @since 4.0.0
146 */
147export const mutate: {
148 (f: (url: URL) => void): (self: URL) => URL
149 (self: URL, f: (url: URL) => void): URL
150} = dual(2, (self: URL, f: (url: URL) => void) => {
151 const copy = new URL(self)
152 f(copy)
153 return copy
154})
155
156/**
157 * @internal
158 */
159const immutableURLSetter = <P extends keyof URL, A = never>(property: P): {
160 (value: URL[P] | A): (url: URL) => URL
161 (url: URL, value: URL[P] | A): URL
162} =>
163 dual(2, (url: URL, value: URL[P]) =>
164 mutate(url, (url) => {
165 url[property] = value
166 }))
167
168/**
169 * Updates the hash fragment of the URL.
170 *
171 * @stability unstable
172 * @category setters
173 * @since 4.0.0
174 */
175export const setHash: {
176 (hash: string): (url: URL) => URL
177 (url: URL, hash: string): URL
178} = immutableURLSetter("hash")
179
180/**
181 * Updates the host (domain and port) of the URL.
182 *
183 * @stability unstable
184 * @category setters
185 * @since 4.0.0
186 */
187export const setHost: {
188 (host: string): (url: URL) => URL
189 (url: URL, host: string): URL
190} = immutableURLSetter("host")
191
192/**
193 * Updates the domain of the URL without modifying the port.
194 *
195 * @stability unstable
196 * @category setters
197 * @since 4.0.0
198 */
199export const setHostname: {
200 (hostname: string): (url: URL) => URL
201 (url: URL, hostname: string): URL
202} = immutableURLSetter("hostname")
203
204/**
205 * Replaces the entire URL string.
206 *
207 * @stability unstable
208 * @category setters
209 * @since 4.0.0
210 */
211export const setHref: {
212 (href: string): (url: URL) => URL
213 (url: URL, href: string): URL
214} = immutableURLSetter("href")
215
216/**
217 * Updates the password used for authentication.
218 *
219 * @stability unstable
220 * @category setters
221 * @since 4.0.0
222 */
223export const setPassword: {
224 (password: string | Redacted.Redacted): (url: URL) => URL
225 (url: URL, password: string | Redacted.Redacted): URL
226} = dual(2, (url: URL, password: string | Redacted.Redacted) =>
227 mutate(url, (url) => {
228 url.password = typeof password === "string"
229 ? password :
230 Redacted.value(password)
231 }))
232
233/**
234 * Updates the path of the URL.
235 *
236 * @stability unstable
237 * @category setters
238 * @since 4.0.0
239 */
240export const setPathname: {
241 (pathname: string): (url: URL) => URL
242 (url: URL, pathname: string): URL
243} = immutableURLSetter("pathname")
244
245/**
246 * Updates the port of the URL.
247 *
248 * @stability unstable
249 * @category setters
250 * @since 4.0.0
251 */
252export const setPort: {
253 (port: string | number): (url: URL) => URL
254 (url: URL, port: string | number): URL
255} = immutableURLSetter("port")
256
257/**
258 * Updates the protocol (e.g., `http`, `https`).
259 *
260 * @stability unstable
261 * @category setters
262 * @since 4.0.0
263 */
264export const setProtocol: {
265 (protocol: string): (url: URL) => URL
266 (url: URL, protocol: string): URL
267} = immutableURLSetter("protocol")
268
269/**
270 * Updates the query string of the URL.
271 *
272 * @stability unstable
273 * @category setters
274 * @since 4.0.0
275 */
276export const setSearch: {
277 (search: string): (url: URL) => URL
278 (url: URL, search: string): URL
279} = immutableURLSetter("search")
280
281/**
282 * Updates the username used for authentication.
283 *
284 * @stability unstable
285 * @category setters
286 * @since 4.0.0
287 */
288export const setUsername: {
289 (username: string): (url: URL) => URL
290 (url: URL, username: string): URL
291} = immutableURLSetter("username")
292
293/**
294 * Updates the query parameters of a URL.
295 *
296 * **Details**
297 *
298 * This function allows you to set or replace the query parameters of a `URL`
299 * object using the provided `UrlParams`. It creates a new `URL` object with the
300 * updated parameters, leaving the original object unchanged.
301 *
302 * **Example** (Replacing query parameters)
303 *
304 * ```ts import.meta.vitest
305 * import { Url, UrlParams } from "effect/http"
306 *
307 * const myUrl = new URL("https://example.com?foo=bar")
308 *
309 * // Write parameters
310 * const updatedUrl = Url.setUrlParams(
311 * myUrl,
312 * UrlParams.fromInput([["key", "value"]])
313 * )
314 *
315 * updatedUrl.toString() // => "https://example.com/?key=value"
316 * ```
317 *
318 * @stability unstable
319 * @category setters
320 * @since 4.0.0
321 */
322export const setUrlParams: {
323 (urlParams: UrlParams.Input): (url: URL) => URL
324 (url: URL, urlParams: UrlParams.Input): URL
325} = dual(2, (url: URL, urlParams: UrlParams.Input) =>
326 mutate(url, (url) => {
327 url.search = UrlParams.toString(UrlParams.fromInput(urlParams))
328 }))
329
330/**
331 * Retrieves the query parameters from a URL.
332 *
333 * **Details**
334 *
335 * This function extracts the query parameters from a `URL` object and returns
336 * them as `UrlParams`. The resulting structure can be easily manipulated or
337 * inspected.
338 *
339 * **Example** (Reading query parameters)
340 *
341 * ```ts import.meta.vitest
342 * import { Url, UrlParams } from "effect/http"
343 *
344 * const myUrl = new URL("https://example.com?foo=bar")
345 *
346 * // Read parameters
347 * const params = Url.urlParams(myUrl)
348 *
349 * UrlParams.toString(params) // => "foo=bar"
350 * ```
351 *
352 * @stability unstable
353 * @category getters
354 * @since 4.0.0
355 */
356export const urlParams = (url: URL): UrlParams.UrlParams => UrlParams.fromInput(url.searchParams)
357
358/**
359 * Reads the query parameters of a URL, modifies them, and updates the URL.
360 *
361 * **Details**
362 *
363 * This function provides a functional way to interact with query parameters by
364 * reading the current parameters, applying a transformation function, and then
365 * writing the updated parameters back to the URL. It returns a new `URL` object
366 * with the modified parameters, ensuring immutability.
367 *
368 * **Example** (Modifying query parameters)
369 *
370 * ```ts import.meta.vitest
371 * import { Url, UrlParams } from "effect/http"
372 *
373 * const myUrl = new URL("https://example.com?foo=bar")
374 *
375 * const changedUrl = Url.modifyUrlParams(myUrl, UrlParams.append("key", "value"))
376 *
377 * changedUrl.toString() // => "https://example.com/?foo=bar&key=value"
378 * ```
379 *
380 * @stability unstable
381 * @category transforming
382 * @since 4.0.0
383 */
384export const modifyUrlParams: {
385 (f: (urlParams: UrlParams.UrlParams) => UrlParams.Input): (url: URL) => URL
386 (url: URL, f: (urlParams: UrlParams.UrlParams) => UrlParams.Input): URL
387} = dual(2, (url: URL, f: (urlParams: UrlParams.UrlParams) => UrlParams.Input) =>
388 mutate(url, (url) => {
389 const params = f(UrlParams.fromInput(url.searchParams))
390 url.search = UrlParams.toString(params)
391 }))