diff --git a/src/gleam/fetch.gleam b/src/gleam/fetch.gleam index 2bf80c0..956a49c 100644 --- a/src/gleam/fetch.gleam +++ b/src/gleam/fetch.gleam @@ -41,7 +41,45 @@ pub type FetchResponse /// |> fetch.raw_send /// ``` @external(javascript, "../gleam_fetch_ffi.mjs", "raw_send") -pub fn raw_send(a: FetchRequest) -> Promise(Result(FetchResponse, FetchError)) +pub fn raw_send( + request: FetchRequest, +) -> Promise(Result(FetchResponse, FetchError)) + +/// Bridge for Request with FetchOptions between Gleam and JavaScript. +@external(javascript, "../gleam_fetch_ffi.mjs", "raw_send") +fn raw_send_converted_options( + request: FetchRequest, + options: FetchOptionsNative, +) -> Promise(Result(FetchResponse, FetchError)) + +/// Call directly `fetch` with a `Request` and `FetchOptions`, +/// then convert the result back to Gleam. +/// Let you get back a `FetchResponse` instead of the Gleam +/// `gleam/http/response.Response` data. +/// +/// ```gleam +/// request.new() +/// |> request.set_host("example.com") +/// |> request.set_path("/example") +/// |> fetch.to_fetch_request +/// |> fetch.raw_send_options(fetch_options.new()) +/// ``` +pub fn raw_send_options( + request: FetchRequest, + options: FetchOptions, +) -> Promise(Result(FetchResponse, FetchError)) { + raw_send_converted_options( + request, + FetchOptionsNative( + cache: cache_to_string(options.cache), + credentials: credentials_to_string(options.credentials), + keepalive: options.keepalive, + mode: cors_to_string(options.mode), + priority: priority_to_string(options.priority), + redirect: redirect_to_string(options.redirect), + ), + ) +} /// Call `fetch` with a Gleam `Request(String)`, and convert the result back /// to Gleam. Use it to send strings or JSON stringified. @@ -69,6 +107,34 @@ pub fn send( }) } +/// Call `fetch` with a Gleam `Request(String)` and `FetchOptions`, +/// then convert the result back to Gleam. +/// Use it to send strings or JSON stringified. +/// +/// If you're looking for something more low-level, take a look at +/// [`raw_send_options`](#raw_send_options). +/// +/// ```gleam +/// let my_data = json.object([#("field", "value")]) +/// request.new() +/// |> request.set_host("example.com") +/// |> request.set_path("/example") +/// |> request.set_body(json.to_string(my_data)) +/// |> request.set_header("content-type", "application/json") +/// |> fetch.send_options(fetch_options.new()) +/// ``` +pub fn send_options( + request: Request(String), + options: FetchOptions, +) -> Promise(Result(Response(FetchBody), FetchError)) { + request + |> to_fetch_request + |> raw_send_options(options) + |> promise.try_await(fn(resp) { + promise.resolve(Ok(from_fetch_response(resp))) + }) +} + /// Call `fetch` with a Gleam `Request(FormData)`, and convert the result back /// to Gleam. Request will be sent as a `multipart/form-data`, and should be /// decoded as-is on servers. @@ -97,6 +163,36 @@ pub fn send_form_data( }) } +/// Call `fetch` with a Gleam `Request(FormData)` and `FetchOptions`, +/// then convert the result back to Gleam. +/// Request will be sent as a `multipart/form-data`, and should be +/// decoded as-is on servers. +/// +/// If you're looking for something more low-level, take a look at +/// [`raw_send_options`](#raw_send_options). +/// +/// ```gleam +/// request.new() +/// |> request.set_host("example.com") +/// |> request.set_path("/example") +/// |> request.set_body({ +/// form_data.new() +/// |> form_data.append("key", "value") +/// }) +/// |> fetch.send_form_data_options(fetch_options.new()) +/// ``` +pub fn send_form_data_options( + request: Request(FormData), + options: FetchOptions, +) -> Promise(Result(Response(FetchBody), FetchError)) { + request + |> form_data_to_fetch_request + |> raw_send_options(options) + |> promise.try_await(fn(resp) { + promise.resolve(Ok(from_fetch_response(resp))) + }) +} + /// Call `fetch` with a Gleam `Request(FormData)`, and convert the result back /// to Gleam. Binary will be sent as-is, and you probably want a proper /// content-type added. @@ -110,7 +206,7 @@ pub fn send_form_data( /// |> request.set_path("/example") /// |> request.set_body(<<"data">>) /// |> request.set_header("content-type", "application/octet-stream") -/// |> fetch.send_form_data +/// |> fetch.send_bits /// ``` pub fn send_bits( request: Request(BitArray), @@ -123,6 +219,33 @@ pub fn send_bits( }) } +/// Call `fetch` with a Gleam `Request(FormData)` and `FetchOptions`, +/// then convert the result back to Gleam. Binary will be sent as-is, +/// and you probably want a proper content-type added. +/// +/// If you're looking for something more low-level, take a look at +/// [`raw_send_options`](#raw_send_options). +/// +/// ```gleam +/// request.new() +/// |> request.set_host("example.com") +/// |> request.set_path("/example") +/// |> request.set_body(<<"data">>) +/// |> request.set_header("content-type", "application/octet-stream") +/// |> fetch.send_bits_options(fetch_options.new()) +/// ``` +pub fn send_bits_options( + request: Request(BitArray), + options: FetchOptions, +) -> Promise(Result(Response(FetchBody), FetchError)) { + request + |> bitarray_request_to_fetch_request + |> raw_send_options(options) + |> promise.try_await(fn(resp) { + promise.resolve(Ok(from_fetch_response(resp))) + }) +} + /// Convert a Gleam `Request(String)` to a JavaScript /// [`Request`](https://developer.mozilla.org/docs/Web/API/Request), where /// `body` is a string. @@ -257,3 +380,250 @@ pub fn read_text_body( pub fn read_json_body( a: Response(FetchBody), ) -> Promise(Result(Response(Dynamic), FetchError)) + +/// Gleam equivalent of JavaScript +/// [`RequestInit`](https://developer.mozilla.org/docs/Web/API/RequestInit). +/// +/// The Node target supports only the `redirect` and `priority` options. +pub opaque type FetchOptions { + Builder( + cache: Cache, + credentials: Credentials, + keepalive: Bool, + mode: Cors, + priority: Priority, + redirect: Redirect, + ) +} + +// Converted internal FetchOptions to be send via JavaScript. +type FetchOptionsNative { + FetchOptionsNative( + cache: String, + credentials: String, + keepalive: Bool, + mode: String, + priority: String, + redirect: String, + ) +} + +/// Cache options, for details see +/// [`cache`](https://developer.mozilla.org/docs/Web/API/RequestInit#cache). +/// +/// Change how responses are stored and retrieved from cache. +pub type Cache { + /// Default cache behaviour. + /// + /// Fresh record will be returned from the cache. + /// If the record in cache is stale and server responds with not + /// changed, then the value from cache is used. Otherwise makes normal + /// request and updates the cache. + Default + /// Response is not fetched from the cache and not stored in the cache. + NoStore + /// Response is not fetched from the cache but gets stored. + Reload + /// If the record in cache is fresh or stale and server responds with not + /// changed, then the value from cache is used. Otherwise makes normal + /// request and updates the cache. + NoCache + /// If record is in cache, it is always used. Otherwise makes normal + /// request. + ForceCache +} + +fn cache_to_string(cache: Cache) -> String { + case cache { + Default -> "default" + NoStore -> "no-store" + Reload -> "reload" + NoCache -> "no-cache" + ForceCache -> "force-cache" + } +} + +/// Credentials options, for details see +/// [`credentials`](https://developer.mozilla.org/docs/Web/API/RequestInit#credentials). +/// +/// Control whether browser sends credentials with the request and whether +/// Set-Cookie response headers are respected. +pub type Credentials { + /// Never send credentials or include credentials in the response. + CredentialsOmit + /// Only send and include credentials for same-origin requests. + CredentialsSameOrigin + /// Always include credentials. + CredentialsInclude +} + +fn credentials_to_string(credentials: Credentials) -> String { + case credentials { + CredentialsOmit -> "omit" + CredentialsSameOrigin -> "same-origin" + CredentialsInclude -> "include" + } +} + +/// CORS options, for details see +/// [`mode`](https://developer.mozilla.org/docs/Web/API/RequestInit#mode). +/// +/// Set cross-origin behaviour of a request. +pub type Cors { + /// Disallows cross-origin requests. + SameOrigin + /// Defaults to + /// [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) + /// mechanism. + Cors + /// Disables CORS for cross-origin requests. + NoCors + /// Used only by HTML navigation. + Navigate +} + +fn cors_to_string(cors: Cors) -> String { + case cors { + SameOrigin -> "same-origin" + Cors -> "cors" + NoCors -> "no-cors" + Navigate -> "navigate" + } +} + +/// Priority options, for details see +/// [`priority`](https://developer.mozilla.org/docs/Web/API/RequestInit#priority). +/// +/// Increase priority of a request relative to other requests. +pub type Priority { + /// Higher priority. + High + /// Lower priority. + Low + /// No preference of priority. + Auto +} + +fn priority_to_string(priority: Priority) -> String { + case priority { + High -> "high" + Low -> "low" + Auto -> "auto" + } +} + +/// Redirect options, for details see +/// [`redirect`](https://developer.mozilla.org/docs/Web/API/RequestInit#redirect). +/// +/// Change the redirect behaviour of a request. +pub type Redirect { + /// Automatically redirects request. + Follow + /// Errors out on redirect. + Error + /// Expects user to handle redirects manually. + Manual +} + +fn redirect_to_string(redirect: Redirect) -> String { + case redirect { + Follow -> "follow" + Error -> "error" + Manual -> "manual" + } +} + +/// Creates new `FetchOptions` object with default values. +/// +/// Useful if more precise control over fetch is required, such as using +/// signals, cache options and so on. +/// +/// ```gleam +/// let options = fetch_options.new() +/// |> fetch_options.cache(fetch_options.NoStore) +/// ``` +pub fn fetch_options() -> FetchOptions { + Builder( + cache: Default, + credentials: CredentialsSameOrigin, + keepalive: False, + mode: Cors, + priority: Auto, + redirect: Follow, + ) +} + +/// Set the +/// [`cache`](https://developer.mozilla.org/docs/Web/API/RequestInit#cache) +/// option of `FetchOptions`. +/// +/// ```gleam +/// let options = fetch_options.new() +/// |> fetch_options.cache(fetch_options.NoStore) +/// ``` +pub fn cache(fetch_options: FetchOptions, which: Cache) -> FetchOptions { + Builder(..fetch_options, cache: which) +} + +/// Set the +/// [`credentials`](https://developer.mozilla.org/docs/Web/API/RequestInit#credentials) +/// option of `FetchOptions`. +/// +/// ```gleam +/// let options = fetch_options.new() +/// |> fetch_options.credentials(fetch_options.CredentialsOmit) +/// ``` +pub fn credentials( + fetch_options: FetchOptions, + which: Credentials, +) -> FetchOptions { + Builder(..fetch_options, credentials: which) +} + +/// Set the +/// [`keepalive`](https://developer.mozilla.org/docs/Web/API/RequestInit#keepalive) +/// option of `FetchOptions`. +/// +/// ```gleam +/// let options = fetch_options.new() +/// |> fetch_options.keepalive(True) +/// ``` +pub fn keepalive(fetch_options: FetchOptions, keepalive: Bool) -> FetchOptions { + Builder(..fetch_options, keepalive: keepalive) +} + +/// Set the +/// [`cors`](https://developer.mozilla.org/docs/Web/API/RequestInit#mode) +/// option of `FetchOptions`. +/// +/// ```gleam +/// let options = fetch_options.new() +/// |> fetch_options.cors(fetch_options.SameOrigin) +/// ``` +pub fn cors(fetch_options: FetchOptions, which: Cors) -> FetchOptions { + Builder(..fetch_options, mode: which) +} + +/// Set the +/// [`priority`](https://developer.mozilla.org/docs/Web/API/RequestInit#priority) +/// option of `FetchOptions`. +/// +/// ```gleam +/// let options = fetch_options.new() +/// |> fetch_options.cors(fetch_options.High) +/// ``` +pub fn priority(fetch_options: FetchOptions, which: Priority) -> FetchOptions { + Builder(..fetch_options, priority: which) +} + +/// Set the +/// [`redirect`](https://developer.mozilla.org/docs/Web/API/RequestInit#redirect) +/// option of `FetchOptions`. +/// +/// ```gleam +/// let options = fetch_options.new() +/// |> fetch_options.redirect(fetch_options.Follow) +/// ``` +pub fn redirect(fetch_options: FetchOptions, which: Redirect) -> FetchOptions { + Builder(..fetch_options, redirect: which) +} diff --git a/src/gleam_fetch_ffi.mjs b/src/gleam_fetch_ffi.mjs index 0bc8944..0f7287b 100644 --- a/src/gleam_fetch_ffi.mjs +++ b/src/gleam_fetch_ffi.mjs @@ -9,9 +9,9 @@ import { UnableToReadBody, } from "../gleam_fetch/gleam/fetch.mjs"; -export async function raw_send(request) { +export async function raw_send(request, options) { try { - return new Ok(await fetch(request)); + return new Ok(await fetch(request, options)); } catch (error) { return new Error(new NetworkError(error.toString())); } diff --git a/test/gleam_fetch_test.gleam b/test/gleam_fetch_test.gleam index 68a6d30..495b6a6 100644 --- a/test/gleam_fetch_test.gleam +++ b/test/gleam_fetch_test.gleam @@ -194,3 +194,281 @@ fn setup_form_data() { |> form_data.append("second-key", "second-value") |> form_data.append_bits("second-key", <<"second-value-bits":utf8>>) } + +// Node only supports `redirect` option. +pub fn complex_fetch_options_test() { + let req = + request.new() + |> request.set_method(Get) + |> request.set_host("test-api.service.hmrc.gov.uk") + |> request.set_path("/hello/world") + |> request.prepend_header("accept", "application/vnd.hmrc.1.0+json") + + let options = + fetch.fetch_options() + |> fetch.cache(fetch.NoStore) + |> fetch.cors(fetch.Cors) + |> fetch.credentials(fetch.CredentialsOmit) + |> fetch.keepalive(True) + |> fetch.priority(fetch.High) + |> fetch.redirect(fetch.Follow) + + use result <- promise.await(fetch.send_options(req, options)) + + let assert Ok(resp) = result + let assert 200 = resp.status + + promise.resolve(Nil) +} + +// Node doesn't support `cache` option, let's check that it passes at least. +// For further reference see: +// https://github.com/node-fetch/node-fetch#class-request +pub fn fetch_cache_test() { + let req = + request.new() + |> request.set_method(Get) + |> request.set_host("test-api.service.hmrc.gov.uk") + |> request.set_path("/hello/world") + |> request.prepend_header("accept", "application/vnd.hmrc.1.0+json") + + let options_default = + fetch.fetch_options() + |> fetch.cache(fetch.Default) + + let options_no_store = + fetch.fetch_options() + |> fetch.cache(fetch.NoStore) + + let options_reload = + fetch.fetch_options() + |> fetch.cache(fetch.Reload) + + let options_no_cache = + fetch.fetch_options() + |> fetch.cache(fetch.NoCache) + + let options_force_cache = + fetch.fetch_options() + |> fetch.cache(fetch.ForceCache) + + use result_default <- promise.await(fetch.send_options(req, options_default)) + use result_no_store <- promise.await(fetch.send_options(req, options_no_store)) + use result_reload <- promise.await(fetch.send_options(req, options_reload)) + use result_no_cache <- promise.await(fetch.send_options(req, options_no_cache)) + use result_force_cache <- promise.await(fetch.send_options( + req, + options_force_cache, + )) + + let assert Ok(resp) = result_default + let assert 200 = resp.status + let assert Ok(resp) = result_no_store + let assert 200 = resp.status + let assert Ok(resp) = result_reload + let assert 200 = resp.status + let assert Ok(resp) = result_no_cache + let assert 200 = resp.status + let assert Ok(resp) = result_force_cache + let assert 200 = resp.status + + promise.resolve(Nil) +} + +// Node doesn't support `credentials` option, let's check that it passes at least. +// For further reference see: +// https://github.com/node-fetch/node-fetch#class-request +pub fn fetch_credentials_test() { + let req = + request.new() + |> request.set_method(Get) + |> request.set_host("test-api.service.hmrc.gov.uk") + |> request.set_path("/hello/world") + |> request.prepend_header("accept", "application/vnd.hmrc.1.0+json") + + let options_omit = + fetch.fetch_options() + |> fetch.credentials(fetch.CredentialsOmit) + + let options_same_origin = + fetch.fetch_options() + |> fetch.credentials(fetch.CredentialsSameOrigin) + + let options_include = + fetch.fetch_options() + |> fetch.credentials(fetch.CredentialsInclude) + + use result_omit <- promise.await(fetch.send_options(req, options_omit)) + use result_same_origin <- promise.await(fetch.send_options( + req, + options_same_origin, + )) + use result_include <- promise.await(fetch.send_options(req, options_include)) + + let assert Ok(resp) = result_omit + let assert 200 = resp.status + let assert Ok(resp) = result_same_origin + let assert 200 = resp.status + let assert Ok(resp) = result_include + let assert 200 = resp.status + + promise.resolve(Nil) +} + +// Node doesn't support `keepalive` option, let's check that it passes at least. +// For further reference see: +// https://github.com/node-fetch/node-fetch#class-request +pub fn fetch_keepalive_test() { + let req = + request.new() + |> request.set_method(Get) + |> request.set_host("test-api.service.hmrc.gov.uk") + |> request.set_path("/hello/world") + |> request.prepend_header("accept", "application/vnd.hmrc.1.0+json") + + let options_keepalive = + fetch.fetch_options() + |> fetch.keepalive(True) + + let options_no_keepalive = + fetch.fetch_options() + |> fetch.keepalive(False) + + use result_keepalive <- promise.await(fetch.send_options( + req, + options_keepalive, + )) + use result_no_keepalive <- promise.await(fetch.send_options( + req, + options_no_keepalive, + )) + + let assert Ok(resp) = result_keepalive + let assert 200 = resp.status + let assert Ok(resp) = result_no_keepalive + let assert 200 = resp.status + + promise.resolve(Nil) +} + +// Node doesn't support `mode` option, let's check that it passes at least. +// For further reference see: +// https://github.com/node-fetch/node-fetch#class-request +pub fn fetch_mods_test() { + let req = + request.new() + |> request.set_method(Get) + |> request.set_host("test-api.service.hmrc.gov.uk") + |> request.set_path("/hello/world") + |> request.prepend_header("accept", "application/vnd.hmrc.1.0+json") + + let options_default = + fetch.fetch_options() + |> fetch.cache(fetch.Default) + + let options_no_store = + fetch.fetch_options() + |> fetch.cache(fetch.NoStore) + + let options_reload = + fetch.fetch_options() + |> fetch.cache(fetch.Reload) + + let options_no_cache = + fetch.fetch_options() + |> fetch.cache(fetch.NoCache) + + let options_force_cache = + fetch.fetch_options() + |> fetch.cache(fetch.ForceCache) + + use result_default <- promise.await(fetch.send_options(req, options_default)) + use result_no_store <- promise.await(fetch.send_options(req, options_no_store)) + use result_reload <- promise.await(fetch.send_options(req, options_reload)) + use result_no_cache <- promise.await(fetch.send_options(req, options_no_cache)) + use result_force_cache <- promise.await(fetch.send_options( + req, + options_force_cache, + )) + + let assert Ok(resp) = result_default + let assert 200 = resp.status + let assert Ok(resp) = result_no_store + let assert 200 = resp.status + let assert Ok(resp) = result_reload + let assert 200 = resp.status + let assert Ok(resp) = result_no_cache + let assert 200 = resp.status + let assert Ok(resp) = result_force_cache + let assert 200 = resp.status + + promise.resolve(Nil) +} + +// It's difficult to test `priority` as it just increases the probability +// of finishing first +pub fn fetch_priority_test() { + let req = + request.new() + |> request.set_method(Get) + |> request.set_host("test-api.service.hmrc.gov.uk") + |> request.set_path("/hello/world") + |> request.prepend_header("accept", "application/vnd.hmrc.1.0+json") + + let options_high = + fetch.fetch_options() + |> fetch.priority(fetch.High) + + let options_low = + fetch.fetch_options() + |> fetch.priority(fetch.Low) + + let options_auto = + fetch.fetch_options() + |> fetch.priority(fetch.Auto) + + use result_high <- promise.await(fetch.send_options(req, options_high)) + use result_low <- promise.await(fetch.send_options(req, options_low)) + use result_auto <- promise.await(fetch.send_options(req, options_auto)) + + let assert Ok(resp) = result_high + let assert 200 = resp.status + let assert Ok(resp) = result_low + let assert 200 = resp.status + let assert Ok(resp) = result_auto + let assert 200 = resp.status + + promise.resolve(Nil) +} + +pub fn fetch_redirect_test() { + // Initiates redirect + let req = + request.new() + |> request.set_method(Get) + |> request.set_host("postman-echo.com") + + let options_follow = + fetch.fetch_options() + |> fetch.redirect(fetch.Follow) + + let options_error = + fetch.fetch_options() + |> fetch.redirect(fetch.Error) + + let options_manual = + fetch.fetch_options() + |> fetch.redirect(fetch.Manual) + + use result_follow <- promise.await(fetch.send_options(req, options_follow)) + use result_error <- promise.await(fetch.send_options(req, options_error)) + use result_manual <- promise.await(fetch.send_options(req, options_manual)) + + let assert Ok(resp) = result_follow + let assert 200 = resp.status + let assert Error(_) = result_error + let assert Ok(resp) = result_manual + let assert 302 = resp.status + + promise.resolve(Nil) +}