Fetch.res

  1type bodyInit
  2external bodyInitFromString: string => bodyInit = "%identity"
  3
  4type headersInit
  5external headersInitFromKeyValueArray: array<(string, string)> => headersInit = "%identity"
  6
  7type credentials =
  8  | @as("omit") Omit
  9  | @as("same-origin") SameOrigin
 10  | @as("include") Include
 11
 12type response = {
 13  ok: bool,
 14  status: int,
 15  statusText: string,
 16}
 17
 18@send external responseJson: response => promise<JSON.t> = "json"
 19
 20// AbortController bindings
 21module AbortController = {
 22  type t
 23  type signal
 24
 25  @new external make: unit => t = "AbortController"
 26  @send external abort: t => unit = "abort"
 27  @get external signal: t => signal = "signal"
 28}
 29
 30type requestInit = {
 31  method?: string,
 32  headers?: headersInit,
 33  body?: bodyInit,
 34  credentials?: string,
 35  signal?: AbortController.signal,
 36}
 37
 38@val external fetch: (string, requestInit) => promise<response> = "fetch"
 39
 40// Builder tracks method and return type 'a (which gets locked by withAbortController)
 41type builder<'method, 'a> = {
 42  url: string,
 43  method: string,
 44  headers: Dict.t<string>,
 45  body: option<JSON.t>,
 46  credentials: credentials,
 47  abortController: option<AbortController.t>,
 48  onAbort: option<unit => 'a>,
 49}
 50
 51type executable<'a> = {
 52  url: string,
 53  method: string,
 54  headers: Dict.t<string>,
 55  body: option<JSON.t>,
 56  credentials: credentials,
 57  abortController: option<AbortController.t>,
 58  onAbort: option<unit => 'a>,
 59  onSuccess: JSON.t => 'a,
 60  onError: (int, string) => 'a,
 61}
 62
 63let get = (url): builder<[#get], _> => {
 64  url,
 65  method: "GET",
 66  headers: Dict.make(),
 67  body: None,
 68  credentials: SameOrigin,
 69  abortController: None,
 70  onAbort: None,
 71}
 72
 73let post = (url): builder<[#post], _> => {
 74  url,
 75  method: "POST",
 76  headers: Dict.make(),
 77  body: None,
 78  credentials: SameOrigin,
 79  abortController: None,
 80  onAbort: None,
 81}
 82
 83let delete = (url): builder<[#delete], _> => {
 84  url,
 85  method: "DELETE",
 86  headers: Dict.make(),
 87  body: None,
 88  credentials: SameOrigin,
 89  abortController: None,
 90  onAbort: None,
 91}
 92
 93let setHeader = (req: builder<'m, 'a>, name, value): builder<'m, 'a> => {
 94  let headers = req.headers->Dict.copy
 95  headers->Dict.set(name, value)
 96  {...req, headers}
 97}
 98
 99let setBody = (req: builder<[#post], 'a>, body: JSON.t): builder<[#post], 'a> => {
100  let headers = req.headers->Dict.copy
101  let hasContentType =
102    headers->Dict.keysToArray->Array.some(k => k->String.toLowerCase == "content-type")
103  if !hasContentType {
104    headers->Dict.set("Content-Type", "application/json")
105  }
106  {...req, body: Some(body), headers}
107}
108
109let credentials = (req: builder<'m, 'a>, creds: credentials): builder<'m, 'a> => {
110  {...req, credentials: creds}
111}
112
113// withAbortController locks the return type 'a by providing the onAbort handler
114let withAbortController = (
115  req: builder<'m, _>,
116  controller: AbortController.t,
117  onAbort: unit => 'a,
118): builder<'m, 'a> => {
119  {...req, abortController: Some(controller), onAbort: Some(onAbort)}
120}
121
122let onResponse = (
123  req: builder<'m, 'a>,
124  ~onSuccess: JSON.t => 'a,
125  ~onError: (int, string) => 'a,
126): executable<'a> => {
127  url: req.url,
128  method: req.method,
129  headers: req.headers,
130  body: req.body,
131  credentials: req.credentials,
132  abortController: req.abortController,
133  onAbort: req.onAbort,
134  onSuccess,
135  onError,
136}
137
138// Exception type for abort errors
139type exn += AbortError
140
141let execute = async (req: executable<'a>): 'a => {
142  try {
143    let bodyInit = req.body->Option.map(b => b->JSON.stringify->bodyInitFromString)
144    let headersInit = req.headers->Dict.toArray->headersInitFromKeyValueArray
145    let credsString = switch req.credentials {
146    | Omit => "omit"
147    | SameOrigin => "same-origin"
148    | Include => "include"
149    }
150    let signal = req.abortController->Option.map(AbortController.signal)
151    let init = switch (bodyInit, signal) {
152    | (Some(body), Some(sig)) => {
153        method: req.method,
154        headers: headersInit,
155        body,
156        credentials: credsString,
157        signal: sig,
158      }
159    | (Some(body), None) => {
160        method: req.method,
161        headers: headersInit,
162        body,
163        credentials: credsString,
164      }
165    | (None, Some(sig)) => {
166        method: req.method,
167        headers: headersInit,
168        credentials: credsString,
169        signal: sig,
170      }
171    | (None, None) => {method: req.method, headers: headersInit, credentials: credsString}
172    }
173    let response = await fetch(req.url, init)
174    if response.ok {
175      let json = await response->responseJson
176      req.onSuccess(json)
177    } else {
178      let status = response.status
179      let message = try {
180        let json = await response->responseJson
181        switch json->JSON.Decode.object {
182        | Some(dict) =>
183          dict->Dict.get("error")
184          ->Option.flatMap(JSON.Decode.string)
185          ->Option.getOr(response.statusText)
186        | None => response.statusText
187        }
188      } catch {
189      | _ => response.statusText
190      }
191      req.onError(status, message)
192    }
193  }   catch {
194  | JsExn(err) => {
195      // Check if this is an abort error
196      let isAbort = switch req.abortController {
197      | Some(_) => {
198          let name = JsExn.name(err)
199          name == Some("AbortError")
200        }
201      | None => false
202      }
203      if isAbort {
204        switch req.onAbort {
205        | Some(onAbort) => onAbort()
206        | None => req.onError(0, "Request aborted")
207        }
208      } else {
209        // Log detailed error information for debugging
210        let errorMessage = JsExn.message(err)->Option.getOr("Unknown error")
211        let errorName = JsExn.name(err)->Option.getOr("Unknown")
212        Console.error(`[Fetch] Network error: ${errorName} - ${errorMessage}`)
213        Console.error(`[Fetch] Request URL: ${req.url}`)
214        Console.error2("[Fetch] Error object:", err)
215        req.onError(0, `Network error: ${errorMessage}`)
216      }
217    }
218  | exn => {
219      Console.error2("[Fetch] Unknown exception:", exn)
220      Console.error(`[Fetch] Request URL: ${req.url}`)
221      req.onError(0, "Network error: unknown exception")
222    }
223  }
224}
225
226let send = execute
227let promise = execute