0.0.5 • Published 5 months ago

fp-ts-fetch v0.0.5

Weekly downloads
-
License
ISC
Repository
github
Last release
5 months ago

FP-TS Fetch

Fetch wrapper for fp-ts users, inspired by fluture-node.

Design Philosophy

Because this library offers yet another HTTP client, you must be wondering what sets it aside from the others.

  • Composition before configuration: Most HTTP clients offer an interface like request({...tons_of_options}). Content negotation, response body decoding, redirection following, error retries, etc. are all often configured via this one complicated structure of interacting options. FP-TS Fetch leverages function composition to give you as much control over HTTP requests and responses as possible, while still keeping boilerplate relatively low. Furthermore, features such as retrying or JSON decoding that are better handled by specialized libraries such as retry-ts or io-ts incorporate seamlessly into the composition approach, and allow the footprint of this library to remain small.
  • Simplicity before ease of use: Many HTTP clients attempt to make interaction with HTTP servers easier by making assumptions about how these servers will likely act, and making decisions for you. A typical example would be that Axios rejects the returned Promise when the server issues certain status codes. FP-TS Fetch makes no assumptions about the HTTP server you are interacting with. This means developers need to handle everything explicitly, but the library is a lot more predictable in return, and better suited to deal with HTTP servers that do things differently from the norm.
  • Native types before custom types: As much as possible, this library tries to leverage JavaScript's built-in types and avoid inventing anything that already exists. This means that the library is easy to mix with vanilla code or other libraries that leverage these same types.

Usage

Simple Example

The following example sends a GET request for example.com and prints the response text to the console. If a network error occurs, its message is printed to the console instead.

import * as Fetch from 'fp-ts-fetch';
import * as Req from 'fp-ts-fetch/Request';
import {identity, pipe} from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';

const task = pipe(
  Req.get('https://example.com'),
  Fetch.transfer,
  TE.chain(Fetch.text),
  TE.match(e => e.message, identity)
);

task().then(console.log);

Extended Example

The following snippet shows a very extended example of using the library together with other libraries in the fp-ts ecosystem. It uses the Node FileSystem to get the README contents, and the GitHub API to render them. It features:

!CAUTION

This example only works on Node 20 and up, or other runtimes that impelement the node:fs/promises module.

import * as Fetch from 'fp-ts-fetch';
import * as Req from 'fp-ts-fetch/Request';
import * as FS from 'node:fs/promises';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import * as t from 'io-ts';
import * as Retry from 'retry-ts';
import * as RetryTask from 'retry-ts/Task';
import * as Console from 'fp-ts/Console'
import * as O from 'fp-ts/Option'
import * as PR from 'io-ts/PathReporter';
import {pipe, identity, flow, constVoid} from 'fp-ts/function';

// Don't forget to put your own API token here:
const myGitHubToken = '<YOUR_TOKEN>';

// We can prebuild a Request with some of the common options.
const markdownReq = pipe(
  Req.post('https://api.github.com/markdown/raw'),
  Req.header('Accept', 'application/vnd.github+json'),
  Req.header('Authorization', `Bearer ${myGitHubToken}`),
  Req.header('X-GitHub-Api-Version', '2022-11-28'),
  Req.header('Content-Type', 'text/plain'),
);

// Specify the shape of an error returned from the GitHub API.
const GitHubError = t.type({
  message: t.string,
  documentation_url: t.string,
}, 'GitHubError');

// Any error response can be handled by parsing JSON and then decoding
// with the GitHubError codec. We also specify what happens if the error
// couldn't be decoded.
const handleGitHubErrorResponse = flow(
  Fetch.json,
  TE.map(GitHubError.decode),
  TE.chainEitherK(E.mapLeft(e => new Error(
    `Unexpected GitHub Error format: ${PR.failure(e).join("; ")}`
  )))
);

// Define a retry policy to use.
const retryPolicy = Retry.capDelay(2000, Retry.Monoid.concat(
  Retry.exponentialBackoff(200),
  Retry.limitRetries(5)
));

// Define an approach to logging request retries.
const logRetry = (status: Retry.RetryStatus) => pipe(
  status.previousDelay,
  O.map((delay) => `Retrying in ${delay} milliseconds...`),
  O.fold(() => constVoid, Console.log),
  TE.rightIO
);

const task = pipe(
  // Get the README.md contents
  TE.tryCatch(() => FS.readFile('./README.md'), E.toError),

  // Finalize our Request by supplying it with a body
  TE.map(body => pipe(markdownReq, Req.body(body))),

  // Transfer the request
  TE.chain(Fetch.transfer),

  // Enable following redirects with a custom strategy
  TE.chain(Fetch.followRedirectsWith(Fetch.aggressiveRedirectionStrategy)(20)),

  // Handle 200 responses as text, 401 with special handling, and everything
  // else as an error
  TE.chain(Fetch.matchStatus(Fetch.error, {
    200: Fetch.text,
    401: flow(handleGitHubErrorResponse, TE.chain(e => TE.left(new Error(
      `Unauthorized: ${e.message} - See ${e.documentation_url}; ` +
      'Maybe you forgot to replace the contents of myGitHubToken in the code?'
    ))))
  })),

  // In case errors happened, retry the whole thing
  task => RetryTask.retrying(
    retryPolicy,
    flow(logRetry, TE.apSecond(task)),
    E.isLeft
  ),

  // Fold into a String for logging
  TE.match(e => `<h1>Something went wrong</h1>\n<p>${e.message}</p>`, identity)
);

// Run the task 🚀
task().then(console.log);

API

This package exports five modules:

!TIP

In most cases you'll only need the Request module for creating Requests, and the Fetch module for transferring those requests and processing their Result.

The Headers module

Utilities for creation and immutable transformations of Headers instances. You will likely only use this module indirectly via the Request module.

import * as Headers from 'fp-ts-fetch/Headers';

Headers.Eq

declare const Eq: Eq<Headers>

An Eq instance for Headers. Two Headers collections are considered equal if and only if they have the same amount of keys, and the same value at each corresponding key. The insertion order of keys is not considered.

Headers.from

declare const from: (xs: Record<string, string>) => Headers

Constructs a new Headers from a string-map of keys and values.

Headers.lookup

declare const lookup: const lookup: (name: string) => (headers: Headers) => O.Option<string>

Obtain the value corresponding to the given header name from the given Headers. The name is case insensitive.

Headers.set

declare const set: (name: string, value: string) => (headers: Headers) => Headers

Set a header to the given value in the given Headers. This overrides previous values if they were present.

!IMPORTANT

The comma symbol (,) has special meaning to many servers as a separator of values for headers that have multiple values. Any commas in the value provided are not automatically escaped. See also append.

Headers.append

declare const append: (name: string, value: string) => (headers: Headers) => Headers

Appends the given value to any potentially existing value corresponding to the given key in the given Headers. This is done by adding a comma at the end of the existing value, and concatenating the given value. If the given value also contains commas, then these are not escaped, and so might be treated by a server as multiple values.

Headers.unset

declare const unset: (name: string) => (headers: Headers) => Headers

Remove the header of the given name from the given Headers.

Headers.omitConfidential

declare const omitConfidential: (headers: Headers) => Headers

Removes authorization and cookie headers from the given Headers. This is used by Fetch.followRedirects to avoid CVE-2022-0155.

Headers.omitConditional

declare const omitConditional: (headers: Headers) => Headers

Removes any client-side conditional headers from the given Headers. This is used by the aggressive redirection strategy to cache-bust out of a 304 response.

The Url module

Utilities for creation and immutable transformations of URL instances. You will likely only use this module indirectly via the Request module.

import * as Url from 'fp-ts-fetch/Url';

Url.parse

declare const parse: (url: string) => Option<URL>

Safely parse a string to a URL.

Url.unsafeParse

declare const unsafeParse: (url: string) => URL

Parse a string to a URL. Throws a TypeError if the string could not be parsed. This function can be useful when parsing strings that you already know are valid URLs, like for example the request URL property. For all other cases, we recommend using Url.parse.

Url.navigate

declare const navigate: (location: string) => (base: URL) => Option<URL>

"Navigate" from the given URL to a given location, returning the URL that represents the fully qualified new location.

import * as Url from 'fp-ts-fetch/Url';
import * as O from 'fp-ts/Option';
import {pipe} from 'fp-ts/function';

assert.deepStrictEqual(
  pipe(
    Url.parse('https://example.com'),
    O.chain(Url.navigate('/test.html')),
    O.map(String)
  ),
  O.some('https://example.com/test.html'),
);

Url.params

declare const params: (params: URLSearchParams) => (url: URL) => URL

Override the searchParams property of a URL with the provided one.

Url.param

declare const param: (key: string, value: string) => (url: URL) => URL

Set the search parameter identified by the given key to the given value on a URL.

Url.unsetParam

declare const unsetParam: (key: string) => (url: URL) => URL

Remove the search parameter identified by the given key from a URL.

Url.sameOrigin

declare const sameOrigin: (origin: URL) => (dest: URL) => boolean

Returns true if the given destination URL is considered to be on the same origin as a given origin URL. A protocol downgrade (from https to http) is also considered a different origin.

The Request module

Immutable utilities for the Request type.

import * as Req from 'fp-ts-fetch/Request';

Req.to

declare const to: (url: string | URL) => Request

Construct a Request from a URL. Sets the redirect mode to manual to favour manual redirection via Fetch.followRedirects. All other request options are left on their default values.

Req.get

declare const get: (url: string | URL) => Request

Alternative to Req.to that sets the request method to GET.

Req.put

declare const put: (url: string | URL) => Request

Alternative to Req.to that sets the request method to PUT.

Req.post

declare const post: (url: string | URL) => Request

Alternative to Req.to that sets the request method to POST.

Req.method

declare const method: (method: string) => (request: Request) => Request

Sets the request method of a request to the given value.

Req.url

declare const url: (url: URL | string) => (request: Request) => Request

Sets the request URL to the given URL or string.

Req.params

declare const params: (params: URLSearchParams) => (request: Request) => Request

Override the request URL parameters with the given URLSearchParams.

Req.param

declare const param: (key: string, value: string) => (request: Request) => Request

Set the request URL parameter of the given key to the given value.

Req.unsetParam

declare const unsetParam: (key: string) => (request: Request) => Request

Remove a given search parameter from the request URL.

Req.headers

declare const headers: (headers: Headers) => (request: Request) => Request

Override all of the request headers on a request with the given Headers.

Req.header

declare const header: (name: string, value: string) => (request: Request) => Request

Sets one of the request headers of a request to the given value. Uses Headers.set so be aware of its gotchas.

Req.append

declare const append: (name: string, value: string) => (request: Request) => Request

Appends a second value to one of the request headers of a request. Uses Headers.append so be aware of its gotchas.

Req.unset

declare const unset: (name: string) => (request: Request) => Request

Removes one of the request headers from a request via Headers.unset.

Req.body

declare const body: (body: BodyInit) => (request: Request) => Request

Sets the request body of a given request using the given "request body initializer". This can be a Blob, an ArrayBuffer, a TypedArray, a DataView, a FormData, a URLSearchParams, a string, or a ReadableStream object.

Req.json

declare const json: (json: Json) => (request: Request) => Request

Sets the request body of a request to the stringified result of the given Json value. Also updates the request headers to include a Content-Type with value application/json.

Req.equivalent

delcare const equivalent: (left: Request) => (right: Request) => boolean

Returns true if two given Requests are equivalent. Two requests are considered equivalent if all properties except for the body are the same.

The Response module

Utilities for working with the Response type. You will likely only use this module indirectly via the Fetch module, working with the Result type.

import * as Res from 'fp-ts-fetch/Response';

Response.blob

declare const blob: (result: Result) => TaskEither<Error, Blob>

Convert a Response to a Blob using Response#blob().

Response.text

declare const text: (result: Result) => TaskEither<Error, string>

Convert a Response to a string using Response#text().

Response.json

declare const json: (result: Result) => TaskEither<Error, Json>

Convert a Response to Json using Response#json().

Response.buffer

declare const buffer: (result: Result) => TaskEither<Error, ArrayBuffer>

Convert a Response to an ArrayBuffer using Response#arrayBuffer().

Response.error

declare const error: (result: Result) => TaskEither<Error, never>

Convert a Response to an Error formatted like:

Unexpected <statusText> (<statusCode>) response. Response body:

  <body_as_text>

The resulting TaskEither is always rejected with the resulting Error.

This function is a convenience function to use as a default handler for unexpected cases in, for example, matchStatus.

The Fetch module

Functional alternative to the Fetch API.

import * as Fetch from 'fp-ts-fetch';

Fetch.Result

declare type Result = readonly [Response, Request];

The Result type is the type that the library is built around. It's simply a Tuple containing a Response and the (typically) associated Request.

Having these paired allows for things like retries and following redirects.

You'll typically want to Tuple.mapFst over it to get at the Response.

Fetch.request

declare const request = (request: Request) => TaskEither<Error, Result>

Given a Request, returns a TaskEither which makes an HTTP request and resolves with the Result. The TaskEither only rejects if a network error was encountered, and always resolves if an HTTP response was successfully obtained.

!NOTE

See the simple usage example for usage.

Fetch.matchStatus

declare type Transform<A> = (result: Result) => A

declare type Pattern<T> = Record<number, Transform<T>>

declare const matchStatus = (
  <T>(onMismatch: Transform<T>, pattern: Pattern<T>) => (result: Result) => T
)

Case-analysis of a Result using the Response's status code as the differentiator. This makes it easy to handle different response status codes in different ways.

The first argument is used to transform any results that didn't match the given pattern. The error function is provided as a convenient value to use here for catching unexpected cases.

!NOTE

See the extended usage example for usage.

Fetch.matchStatusW

declare type Transform<A> = (result: Result) => A

declare type Pattern<T> = Record<number, Transform<T>>

declare const matchStatus = (
  <A, B>(onMismatch: Transform<A>, pattern: Pattern<B>) => (
    (result: Result) => A | B
  )
)

A type-widening version of matchStatus.

Fetch.acceptStatus

declare const acceptStatus = (code: number) => (result: Result) => (
  Either<Result, Result>
)

Tags a Result by its Response's status code. Enables easy code branching based on the status code of a response.

The example below extends the simple usage example so that non-200 responses are no longer handled the same way as 200 responses.

import * as Fetch from 'fp-ts-fetch';
import {identity, pipe} from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';

const task = pipe(
  Fetch.retrieve('https://example.com')(new Headers),
  Fetch.transfer,
  TE.map(Fetch.acceptStatus(200)),
  TE.chainEitherK(E.mapLeft(([res]) => (
    new Error(`Unexpected ${res.status} response`)
  ))),
  TE.chain(Fetch.text),
  TE.match(e => e.message, identity)
);

task().then(console.log);

Fetch.followRedirects

declare const followRedirects: (max: number) => (result: Result) => (
  TaskEither<Error, Result>
)

A default way to follow redirects up to a given number of redirections. Uses the default redirection strategy. See followRedirectsWith for more information.

The example below extends the simple usage example so that redirects are automatically followed, up to a maximum of 20 redirections.

import * as Fetch from 'fp-ts-fetch';
import {identity, pipe} from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';

const task = pipe(
  Fetch.retrieve('https://example.com')(new Headers),
  Fetch.transfer,
  TE.chain(Fetch.followRedirects(20)),
  TE.chain(Fetch.text),
  TE.match(e => e.message, identity)
);

task().then(console.log);

Fetch.RedirectionStrategy

declare type Transform<A> = (result: Result) => A

declare type RedirectionStrategy = Transform<Request>

The RedirectionStrategy type alias embodies what it means to redirect. It's just a transformation of a Result to a new Request. Redirection Strategies are used by followRedirectsWith to determine its redirection behaviour.

Fetch.redirectAnyRequest

A Redirection Strategy that will indiscriminately follow redirects as long as the response contains a Location header.

If the new location is on an external host (according to Url.sameOrigin), then any confidential headers will be dropped from the new request (using Headers.omitConfidential).

Used in the defaultRedirectionStrategy and the aggressiveRedirectionStrategy.

Fetch.redirectIfGetMethod

A Redirection Strategy that will follow redirects as long as the response contains a Location header and the request was issued using the GET method.

If the new location is on an external host, then any confidential headers (such as the cookie header) will be dropped from the new request.

Used in the defaultRedirectionStrategy.

Fetch.redirectUsingGetMethod

A Redirection Strategy that sends a new GET request based on the original request to the Location specified in the given Response. If the response does not contain a location, the request is not redirected.

The original request method and body are discarded, but other properties are preserved. If the new location is on an external host, then any confidential headers (such as the cookie header) will be dropped from the new request.

Used in the defaultRedirectionStrategy and the aggressiveRedirectionStrategy.

Fetch.retryWithoutCondition

A Redirection Strategy that will retry the same request but without any conditional headers, to ensure that caching layers are skipped.

Used in the aggressiveRedirectionStrategy.

Fetch.defaultRedirectionStrategy

A Redirection Strategy that carefully follows redirects in strict accordance with RFC2616 Section 10.3.

Redirections with status codes 301, 302, and 307 are only followed if the original request used the GET method, and redirects with status code 304 are left alone for a caching layer to deal with.

This redirection strategy is used by the simple followRedirects function.

If you want to modify or extend its behaviour for specific status codes, you can use the matchStatus function. In the example below, we override the behaviour for 301 responses to never redirect and for 307 responses to always redirect:

import * as Fetch from 'fp-ts-fetch';
import * as Tuple from 'fp-ts/Tuple';

const myRedirectionStrategy = (
  Fetch.matchStatus(Fetch.defaultRedirectionStrategy)({
    301: Tuple.snd,
    307: Fetch.redirectAnyRequest,
  })
);

See also the aggressiveRedirectionStrategy.

Fetch.aggressiveRedirectionStrategy

A Redirection Strategy that aggressively follows redirects in mild violation of RFC2616 Section 10.3. In particular, anywhere that a redirection should be interrupted for user confirmation or caching, this policy follows the redirection nonetheless.

Redirections with status codes 301, 302, and 307 are always followed without user intervention, and redirects with status code 304 are retried without conditions if the original request had any conditional headers.

See also the defaultRedirectionStrategy. The aggressive strategy can be extended/customized in the same way that the default one can.

Fetch.followRedirectsWith

declare const followRedirectsWith: (strategy: RedirectionStrategy) => (
  (max: number) => (result: Result) => TaskEither<Error, Result>
)

Given a Redirection Strategy, a maximum number of redirects, and a Result, returns a TaskEither that will transfer the new requests provided by the given strategy for as long as some conditions hold:

  1. The maximum number of transferred requests has not been exceeded; and
  2. an equivalent request has not been sent before.

This means that a Redirection Strategy can signal that it's done redirecting by simply returning the original request.

It also means that exceeding the maximum number of redirects is not seen as an error, and won't reject any tasks. Instead, the 3xx response is returned normally as part of the final Result. Users are expected to handle redirects that couldn't be followed by observing a 3xx response status code after attempting to follow redirects. Thankfully, this will typically happen automatically as a result of using acceptStatus or matchStatus.

!NOTE

See the extended usage example for usage.

Fetch.blob

declare const blob: (result: Result) => TaskEither<Error, Blob>

Shorthand for using Response.blob on the Response of a Result.

Fetch.text

declare const text: (result: Result) => TaskEither<Error, string>

Shorthand for using Response.text on the Response of a Result.

Fetch.json

declare const json: (result: Result) => TaskEither<Error, Json>

Shorthand for using Response.json on the Response of a Result.

Fetch.buffer

declare const buffer: (result: Result) => TaskEither<Error, ArrayBuffer>

Shorthand for using Response.buffer on the Response of a Result.

Fetch.error

declare const error: (result: Result) => TaskEither<Error, never>

Shorthand for using Response.error on the Response of a Result.

0.0.3

5 months ago

0.0.2

5 months ago

0.0.5

5 months ago

0.0.4

5 months ago

0.0.1

5 months ago