all repos

onasty @ 8e14c68

a one-time notes service

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

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