Elixir API and Elm SPA - Part 5
source link: https://www.tuicool.com/articles/hit/V7ZBneV
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Part 5: Persisting session data to localStorage
We are going to save the session (the token) to the localStorage so on app startup, if it exists, the user can avoid login in again. We're also going to implement the Logout command and add a generic error page.
Series
- Part 1 - Elixir App creation
- Part 2 - Adds Guardian Authentication
- Part 3 - Elm App creation and Routing setup
- Part 4 - Adding Login and Register pages
- Part 5 - Persisting session data to localStorage
Persisting the session to localStorage
We are going to serialize the Session type we have and store it as JSON in the localStorage. Also, at app boostrap we're going to check if the localStorage has something in it, and if it does, we'll try to get it, to deserialize it using the Session.decoder and finally, put it on our model.
Start by adding some needed dependencies:
elm-app install lukewestby/elm-http-builder
Add this function to the app model:
-- src/Model.elm decodeSessionFromJson : Value -> Maybe Session decodeSessionFromJson json = json |> Decode.decodeValue Decode.string |> Result.toMaybe |> Maybe.andThen (Decode.decodeString Session.decoder >> Result.toMaybe)
This gets a Value that maybe has a string and then tries to decode it as a session object.
We are going to use this function on Elm app initialization to get a session from localStorage and put it on our model if it exists.
Modify the initialModel function in Model.elm to look like this:
-- src/Model.elm initialModel : Value -> Model initialModel val = { session = decodeSessionFromJson val , pageState = Loaded Blank }
Adding Ports
As we're going to communicate with the JavaScript world, we need to set up some ports.
Add a Ports.elm file
-- src/Ports.elm port module Ports exposing (onSessionChange, storeSession) import Json.Encode exposing (Value) port storeSession : Maybe String -> Cmd msg port onSessionChange : (Value -> msg) -> Sub msg
This essentially register two functions with the Elm runtime that will be used to communicate with the JS world. The first one, storeSession takes a string (our encoded Session type) and sends it to JS world. The second one, onSessionChange , it will be called each time the localStorage value changes so that we can get notifications of that change and react appropriately in our Elm app.
These functions need their JS counterpart. Open index.js and replace its contents by this:
-- src/index.js import './main.css'; import { Main } from './Main.elm'; import registerServiceWorker from './registerServiceWorker'; var app = Main.fullscreen(localStorage.session || null); app.ports.storeSession.subscribe(function(session) { localStorage.session = session; }); window.addEventListener( "storage", function(event) { if (event.storageArea === localStorage && event.key === "session") { app.ports.onSessionChange.send(event.newValue); } }, false ); registerServiceWorker();
We are changing the way we bootstrap the app. Previously, we had this:
Main.embed(document.getElementById('root'));
It looked for the DOM element with id set to root and replace its contents with the Elm app.
Now we'll use the fullscreen function to use the whole screen.
This returns us an app object that we can use to configure our ports.
The first one registers a callback to be called each time the storeSession is called. It receives a value and stores it on the localSession.session property.
The second one is a little bit more complex. It adds an event listener to the storage property of the browser's window object and registers a callback to be called when something happens to the storage property. We only act when the event.storageArea is the localStorage and the event key is 'session'. If those conditions are met, we use the onSessionChange function to send the eventNewValue to the Elm port.
In summary, we wait for changes to the session from the Elm app and write them to the localStorage in JS land and we react to events on the localStorage in JS land and we send the value to Elm land.
Given that we are bootstrapping the app in fullscreen mode, we don't need the id="root" element anymore. Let's remove it. Change the body of the index.html to this.
<!-- public/index.html --> <body class="bg-white"> <noscript> You need to enable JavaScript to run this app. </noscript> </body>
Storing the session
Let's now add a function to save the session to the local storage using these ports we've just created. Add this function to Session/Model.elm and expose it
-- src/Session/Model.elm module Session.Model exposing (Session, decoder, encode, storeSession) -- .. import Ports -- ... storeSession : Session -> Cmd msg storeSession session = encode session |> Encode.encode 0 |> Just |> Ports.storeSession
This takes a session, encodes it and passes it to the Ports.storeSession to be saved to localStorage.
Now let's use it on the Login and Register pages to store the session after a successful login:
-- src/Session/Login.elm import Session.Model exposing (Session, storeSession) -- .. -- and change the Login (Ok session) branch of the update function to LoginCompleted (Ok session) -> model => Cmd.batch [ storeSession session, Route.modifyUrl Route.Home ] => SetSession session -- src/Session/Register.elm import Session.Model exposing (Session, storeSession) -- .. -- and change the Register (Ok session) branch of the update function to RegisterCompleted (Ok session) -> model => Cmd.batch [ storeSession session, Route.modifyUrl Route.Home ] => SetSession session
One last bit, we need to add a subscription on our Elm app for when a session change is triggered from the JS land. Lets add a new message to handle this:
-- src/Messages.elm import Session.Model exposing (Session) -- .. type Msg = SetRoute (Maybe Route) | SetSession (Maybe Session) | LoginMsg Login.Msg | RegisterMsg Register.Msg
We'll send the SetSession message when the session changes. We'll add the subscription that sends this message when this happens:
-- src/Session/Model.elm module Session.Model exposing (Session, decoder, encode, storeSession, sessionChangeSubscription) -- ... sessionChangeSubscription : Sub (Maybe Session) sessionChangeSubscription = Ports.onSessionChange (Decode.decodeValue decoder >> Result.toMaybe)
And then we use that function in the Subscriptions.elm
-- src/Subscriptions.elm import Session.Model exposing (sessionChangeSubscription) subscriptions : Model -> Sub Msg subscriptions model = Sub.batch [ pageSubscriptions (getPage model.pageState) , Sub.map SetSession sessionChangeSubscription ]
And add a branch to the updatePage function to handle this new message:
-- src/Update.elm updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg ) updatePage page msg model = case ( msg, page ) of -- .. ( SetSession newSession, _ ) -> let cmd = -- If we just signed out, then redirect to Home. if model.session /= Nothing && newSession == Nothing then Route.modifyUrl Route.Home else Cmd.none in { model | session = newSession } => cmd
That's it. We are storing our session on localStorage and passing the value in localStorage to the Elm app on bootstrap.
Go to the app, and use your browser's Developer Tools to look at the localStorage before and after logging in.
Before logging in the first time it is empty:
After logging successfully in, it should have the session data in it:
Adding a Logout action
We are now going to add a Logout action. This action will do several things:
- make a DELETE request to the /api/sessions
- set the session to Nothing in the Model
- store Nothing in the localStorage
- redirect to the Home route
Let's start by showing the Logout menu on the header if we are logged in and hide it when we're not. Add this function to Page.elm
-- src/Page/Page.elm import Session.Model exposing (Session) -- .. viewNavBar : Maybe Session -> ActivePage -> List (Html msg) viewNavBar session activePage = let linkTo = navbarLink activePage in case session of Nothing -> [ linkTo Route.Login [ text "Login" ] , linkTo Route.Register [ text "Register" ] ] Just session -> [ linkTo Route.Logout [ text "Logout" ] ]
and use it on the viewHeader function like this:
-- src/Page/Page.elm viewHeader : Maybe Session -> ActivePage -> Bool -> Html msg viewHeader session activePage isLoading = nav [ class "dt w-100 border-box pa3 ph5-ns" ] [ a [ class "dtc v-mid mid-gray link dim w-25", Route.href Route.Home, title "Home" ] [ text "Toltec" ] , div [ class "dtc v-mid w-75 tr" ] <| navbarLink activePage Route.Home [ text "Home" ] :: viewNavBar session activePage ]
As you can see, now we have a fixed Home link and a variable set of menus depending on whether the user is or is not logged in.
As the signature changed, we need to also change the frame function:
-- src/Page/Page.elm frame : Bool -> Maybe Session -> ActivePage -> Html msg -> Html msg frame isLoading session activePage content = div [] [ viewHeader session activePage isLoading , content ]
And this also forces us to change all the usages of frame :
-- src/View.elm import Session.Model exposing (Session) -- .. view : Model -> Html Msg view model = case model.pageState of Loaded page -> viewPage True model.session page TransitioningFrom page -> viewPage False model.session page viewPage : Bool -> Maybe Session -> Page -> Html Msg viewPage isLoading session page = let frame = Page.frame isLoading session in case page of -- ..
Let's now add a new message
-- src/Messages.elm import Http -- .. type Msg = SetRoute (Maybe Route) -- .. | LogoutCompleted (Result Http.Error ())
The logout request to the backend needs to include the auth token in it. Add this new helper function:
-- src/Helpers/Request.elm module Helpers.Request exposing (apiUrl, withAuthorization) import HttpBuilder exposing (RequestBuilder, withHeader) import Session.AuthToken exposing (AuthToken(..)) import Session.Model exposing (Session) -- ... withAuthorization : Maybe Session -> RequestBuilder a -> RequestBuilder a withAuthorization session builder = case session of Just s -> let (AuthToken token) = s.token in builder |> withHeader "authorization" ("Bearer " ++ token) Nothing -> builder
Change the module signature in AuthToken.elm
-- src/Session/AuthToken.elm module Session.AuthToken exposing (AuthToken(..), decoder, encode)
And add the logout request function:
-- src/Session/Request.elm module Session.Request exposing (login, register, logout) -- .. import Helpers.Request exposing (apiUrl, withAuthorization) import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams) -- .. logout : Maybe Session -> Http.Request () logout session = let expectNothing = Http.expectStringResponse (\_ -> Ok ()) in apiUrl "/sessions" |> HttpBuilder.delete |> HttpBuilder.withExpect expectNothing |> withAuthorization session |> HttpBuilder.toRequest
As you can see the logout function receives the session and builds a DELETE request to the "/api/sessions" endpoint, sets no body, expects no response body either and includes the authorization header with the token in it.
Now we need to change the Update.elm file:
-- src/Update.elm import Http import Ports import Session.Request exposing (logout) -- .. updateRoute : Maybe Route -> Model -> ( Model, Cmd Msg ) updateRoute maybeRoute model = case maybeRoute of -- .. Just Route.Logout -> model => (Http.send LogoutCompleted <| logout model.session) -- .. updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg ) updatePage page msg model = case ( msg, page ) of -- .. ( LogoutCompleted (Ok ()), _ ) -> { model | session = Nothing } => Cmd.batch [ Ports.storeSession Nothing , Route.modifyUrl Route.Home ]
Pay attention that the logout action has two phases, the first one is when the route changes to the Route.Logout. This doesn't modify the model, but creates a command to send the logout request to the backend and tag the Result with the LogoutCompleted message.
The Elm runtime will to the request and will build a message with the Ok or Err response. This messages will be passed to the update function with the current model.
The second phase is when the update function receives the LogoutCompleted message. If the message is LogoutCompleted (Ok ()) we'll batch two commands: one to use the ports to store Nothing in the localStorage, and the other to move to the Home route.
We're done with the logout. Reload the app and verify that after loggin in the localStorage should have the correct serialized Session. When we click on the Logout button the request is made and then some time after that, the LogoutCompleted messages will be sent to continue with the logout. When this happens the localStorage will be set to Nothing and you should see a null as the value of the localStorage.session property in the browser developer tools.
Also, you should end in the Home page and logged out.
After clicking on the logout menu, the localStorage is set to null
Adding an error page
As you probably noticed, we are only handling the success logout response. The failure will be ignored completely in the update function. I'll fix that in a moment, because first we are going to add an error page to show in case something goes wrong. Right now it only shows a generic error message.
Add a Error.elm page in src/Page
-- src/Page/Error.elm module Page.Error exposing (PageError, pageError, view) import Html exposing (Html, div, h1, main_, p, text) import Page.Page exposing (ActivePage) type PageError = PageError Model type alias Model = { activePage : ActivePage , errorMessage : String } pageError : ActivePage -> String -> PageError pageError activePage errorMessage = PageError { activePage = activePage, errorMessage = errorMessage } view : PageError -> Html msg view (PageError model) = main_ [] [ h1 [] [ text "Something wrong happened" ] , div [] [ p [] [ text model.errorMessage ] ] ]
And add the new page to the Page type:
-- src/Model.elm import Page.Error as Error exposing (PageError) -- .. type Page = Blank | NotFound | Error PageError -- ..
Add a helper function to generate Error pages:
-- src/Update.elm import Page.Error as Error import Page.Page as Page exposing (ActivePage) -- .. pageError : Model -> ActivePage -> String -> ( Model, Cmd msg ) pageError model activePage errorMessage = let error = Error.pageError activePage errorMessage in { model | pageState = Loaded (Error error) } => Cmd.none
And now handle the logout error in the updatePage function:
-- src/Update.elm updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg ) updatePage page msg model = case ( msg, page ) of -- .. ( LogoutCompleted (Err error), _ ) -> pageError model Page.Other "There was a problem while trying to logout"
We need to handle the Error page on the view file:
-- src/View.elm import Page.Error as Error -- .. viewPage : Bool -> Maybe Session -> Page -> Html Msg viewPage isLoading session page = -- .. Error subModel -> Error.view subModel |> frame Page.Other
Finally, handle the Error page on the Subscriptions.elm
-- src/Subscriptions.elm pageSubscriptions : Page -> Sub Msg pageSubscriptions page = -- .. Error _ -> Sub.none
That's it. Now login to the app, stop the backend API and try to logout. You should see the Error page
You can find the source code here , on the part-05 branch.
Ok, let's leave it there as this post is already too long. Next post we'll start with the CRUD part of the app.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK