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