all repos

onasty @ dfbb8b461a9edec6e94d3657fd006125040096c9

a one-time notes service

onasty/web/src/Effect.elm (view raw)

Smirnov Oleksandr Smirnov Oleksandr
ss2316544@gmail.com
web: handle api errors (#138)..., 11 months ago
1
module Effect exposing
2
    ( Effect
3
    , none, batch
4
    , sendCmd, sendMsg
5
    , pushRoute, replaceRoute
6
    , pushRoutePath, replaceRoutePath
7
    , loadExternalUrl, back
8
    , sendApiRequest, refreshTokens
9
    , signin, logout, saveUser, clearUser
10
    , map, toCmd
11
    )
12
13
{-|
14
15
@docs Effect
16
17
@docs none, batch
18
@docs sendCmd, sendMsg
19
20
@docs pushRoute, replaceRoute
21
@docs pushRoutePath, replaceRoutePath
22
@docs loadExternalUrl, back
23
24
@docs sendApiRequest, refreshTokens
25
@docs signin, logout, saveUser, clearUser
26
27
@docs map, toCmd
28
29
-}
30
31
import Api
32
import Auth.User
33
import Browser.Navigation
34
import Data.Credentials exposing (Credentials)
35
import Data.Error
36
import Dict exposing (Dict)
37
import Http
38
import Json.Decode
39
import Json.Encode
40
import Ports exposing (sendToLocalStorage)
41
import Route
42
import Route.Path
43
import Shared.Model
44
import Shared.Msg
45
import Task
46
import Url exposing (Url)
47
48
49
type Effect msg
50
    = -- BASICS
51
      None
52
    | Batch (List (Effect msg))
53
    | SendCmd (Cmd msg)
54
      -- ROUTING
55
    | PushUrl String
56
    | ReplaceUrl String
57
    | LoadExternalUrl String
58
    | Back
59
      -- SHARED
60
    | SendSharedMsg Shared.Msg.Msg
61
    | SendToLocalStorage { key : String, value : Json.Encode.Value }
62
    | SendApiRequest (HttpRequestDetails msg)
63
64
65
type alias HttpRequestDetails msg =
66
    { endpoint : String
67
    , method : String
68
    , body : Http.Body
69
    , decoder : Json.Decode.Decoder msg
70
    , onHttpError : Api.Error -> msg
71
    }
72
73
74
75
-- BASICS
76
77
78
{-| Don't send any effect.
79
-}
80
none : Effect msg
81
none =
82
    None
83
84
85
{-| Send multiple effects at once.
86
-}
87
batch : List (Effect msg) -> Effect msg
88
batch =
89
    Batch
90
91
92
{-| Send a normal `Cmd msg` as an effect, something like `Http.get` or `Random.generate`.
93
-}
94
sendCmd : Cmd msg -> Effect msg
95
sendCmd =
96
    SendCmd
97
98
99
{-| Send a message as an effect. Useful when emitting events from UI components.
100
-}
101
sendMsg : msg -> Effect msg
102
sendMsg msg =
103
    Task.succeed msg
104
        |> Task.perform identity
105
        |> SendCmd
106
107
108
109
-- ROUTING
110
111
112
{-| Set the new route, and make the back button go back to the current route.
113
-}
114
pushRoute :
115
    { path : Route.Path.Path
116
    , query : Dict String String
117
    , hash : Maybe String
118
    }
119
    -> Effect msg
120
pushRoute route =
121
    PushUrl (Route.toString route)
122
123
124
{-| Same as `Effect.pushRoute`, but without `query` or `hash` support
125
-}
126
pushRoutePath : Route.Path.Path -> Effect msg
127
pushRoutePath path =
128
    PushUrl (Route.Path.toString path)
129
130
131
{-| Set the new route, but replace the previous one, so clicking the back
132
button **won't** go back to the previous route.
133
-}
134
replaceRoute :
135
    { path : Route.Path.Path
136
    , query : Dict String String
137
    , hash : Maybe String
138
    }
139
    -> Effect msg
140
replaceRoute route =
141
    ReplaceUrl (Route.toString route)
142
143
144
{-| Same as `Effect.replaceRoute`, but without `query` or `hash` support
145
-}
146
replaceRoutePath : Route.Path.Path -> Effect msg
147
replaceRoutePath path =
148
    ReplaceUrl (Route.Path.toString path)
149
150
151
{-| Redirect users to a new URL, somewhere external to your web application.
152
-}
153
loadExternalUrl : String -> Effect msg
154
loadExternalUrl =
155
    LoadExternalUrl
156
157
158
{-| Navigate back one page
159
-}
160
back : Effect msg
161
back =
162
    Back
163
164
165
166
-- SHARED
167
168
169
sendApiRequest :
170
    { endpoint : String
171
    , method : String
172
    , body : Http.Body
173
    , decoder : Json.Decode.Decoder value
174
    , onResponse : Result Api.Error value -> msg
175
    }
176
    -> Effect msg
177
sendApiRequest opts =
178
    let
179
        onSuccess : value -> msg
180
        onSuccess value =
181
            opts.onResponse (Ok value)
182
183
        onHttpError : Api.Error -> msg
184
        onHttpError err =
185
            opts.onResponse (Err err)
186
    in
187
    SendApiRequest
188
        { endpoint = opts.endpoint
189
        , method = opts.method
190
        , body = opts.body
191
        , onHttpError = onHttpError
192
        , decoder = Json.Decode.map onSuccess opts.decoder
193
        }
194
195
196
refreshTokens : Effect msg
197
refreshTokens =
198
    SendSharedMsg Shared.Msg.TriggerTokenRefresh
199
200
201
signin : Credentials -> Effect msg
202
signin credentials =
203
    SendSharedMsg (Shared.Msg.SignedIn credentials)
204
205
206
logout : Effect msg
207
logout =
208
    SendSharedMsg Shared.Msg.Logout
209
210
211
saveUser : String -> String -> Effect msg
212
saveUser accessToken refreshToken =
213
    batch
214
        [ SendToLocalStorage { key = "access_token", value = Json.Encode.string accessToken }
215
        , SendToLocalStorage { key = "refresh_token", value = Json.Encode.string refreshToken }
216
        ]
217
218
219
clearUser : Effect msg
220
clearUser =
221
    batch
222
        [ SendToLocalStorage { key = "access_token", value = Json.Encode.null }
223
        , SendToLocalStorage { key = "refresh_token", value = Json.Encode.null }
224
        ]
225
226
227
228
-- INTERNALS
229
230
231
{-| Elm Land depends on this function to connect pages and layouts
232
together into the overall app.
233
-}
234
map : (msg1 -> msg2) -> Effect msg1 -> Effect msg2
235
map fn effect =
236
    case effect of
237
        None ->
238
            None
239
240
        Batch list ->
241
            Batch (List.map (map fn) list)
242
243
        SendCmd cmd ->
244
            SendCmd (Cmd.map fn cmd)
245
246
        PushUrl url ->
247
            PushUrl url
248
249
        ReplaceUrl url ->
250
            ReplaceUrl url
251
252
        Back ->
253
            Back
254
255
        LoadExternalUrl url ->
256
            LoadExternalUrl url
257
258
        SendSharedMsg sharedMsg ->
259
            SendSharedMsg sharedMsg
260
261
        SendToLocalStorage options ->
262
            SendToLocalStorage options
263
264
        SendApiRequest opts ->
265
            SendApiRequest
266
                { endpoint = opts.endpoint
267
                , method = opts.method
268
                , body = opts.body
269
                , decoder = Json.Decode.map fn opts.decoder
270
                , onHttpError = \err -> fn (opts.onHttpError err)
271
                }
272
273
274
{-| Elm Land depends on this function to perform your effects.
275
-}
276
toCmd :
277
    { key : Browser.Navigation.Key
278
    , url : Url
279
    , shared : Shared.Model.Model
280
    , fromSharedMsg : Shared.Msg.Msg -> msg
281
    , batch : List msg -> msg
282
    , toCmd : msg -> Cmd msg
283
    }
284
    -> Effect msg
285
    -> Cmd msg
286
toCmd options effect =
287
    case effect of
288
        None ->
289
            Cmd.none
290
291
        Batch list ->
292
            Cmd.batch (List.map (toCmd options) list)
293
294
        SendCmd cmd ->
295
            cmd
296
297
        PushUrl url ->
298
            Browser.Navigation.pushUrl options.key url
299
300
        ReplaceUrl url ->
301
            Browser.Navigation.replaceUrl options.key url
302
303
        Back ->
304
            Browser.Navigation.back options.key 1
305
306
        LoadExternalUrl url ->
307
            Browser.Navigation.load url
308
309
        SendSharedMsg sharedMsg ->
310
            Task.succeed sharedMsg
311
                |> Task.perform options.fromSharedMsg
312
313
        SendToLocalStorage opts ->
314
            sendToLocalStorage opts
315
316
        SendApiRequest opts ->
317
            let
318
                headers : List Http.Header
319
                headers =
320
                    case options.shared.user of
321
                        Auth.User.SignedIn cred ->
322
                            if not (String.contains opts.endpoint "refresh-tokens") then
323
                                [ Http.header "Authorization" ("Bearer " ++ cred.accessToken) ]
324
325
                            else
326
                                []
327
328
                        _ ->
329
                            []
330
            in
331
            Http.request
332
                { method = opts.method
333
                , url = opts.endpoint
334
                , headers = headers
335
                , body = opts.body
336
                , expect =
337
                    Http.expectStringResponse
338
                        (\httpResult ->
339
                            case httpResult of
340
                                Ok msg ->
341
                                    msg
342
343
                                Err err ->
344
                                    opts.onHttpError err
345
                        )
346
                        (\resp -> fromHttpResponseToCustomError opts.decoder resp)
347
                , timeout = Just (1000 * 60) -- 60 second timeout
348
                , tracker = Nothing
349
                }
350
351
352
fromHttpResponseToCustomError : Json.Decode.Decoder msg -> Http.Response String -> Result Api.Error msg
353
fromHttpResponseToCustomError decoder response =
354
    case response of
355
        Http.GoodStatus_ _ body ->
356
            case Json.Decode.decodeString decoder body of
357
                Ok data ->
358
                    Ok data
359
360
                Err err ->
361
                    Err (Api.JsonDecodeError { message = "Something unexpected happened", reason = err })
362
363
        Http.BadStatus_ { statusCode } body ->
364
            case Json.Decode.decodeString Data.Error.decode body of
365
                Ok err ->
366
                    Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode })
367
368
                Err err ->
369
                    Err (Api.JsonDecodeError { message = "Something unexpected happened", reason = err })
370
371
        Http.BadUrl_ url ->
372
            Err (Api.HttpError { message = "Unexpected URL format", reason = Http.BadUrl url })
373
374
        Http.Timeout_ ->
375
            Err (Api.HttpError { message = "Request timed out, please try again", reason = Http.Timeout })
376
377
        Http.NetworkError_ ->
378
            Err (Api.HttpError { message = "Could not connect, please try again", reason = Http.NetworkError })