This tutorial includes: - 7 progressive lessons covering Elm fundamentals - Exercises with starter code - Solutions for exercises - Final project: Task Manager app with localStorage persistence Topics covered: - Basic syntax and types - Functions and functional programming concepts - The Elm Architecture (Model-View-Update) - Lists and Maybe types - HTTP requests and JSON decoding - Ports for JavaScript interop
841 lines
17 KiB
Markdown
841 lines
17 KiB
Markdown
# Lesson 6: HTTP & JSON
|
|
|
|
## Learning Goals
|
|
|
|
By the end of this lesson, you will:
|
|
- Understand Commands (Cmd) in Elm
|
|
- Make HTTP requests
|
|
- Decode JSON responses
|
|
- Handle loading states and errors
|
|
- Understand how Elm manages side effects
|
|
|
|
## Side Effects in Elm
|
|
|
|
Remember: Elm functions are **pure** - they can't do side effects directly. So how do we:
|
|
- Make HTTP requests?
|
|
- Generate random numbers?
|
|
- Get the current time?
|
|
- Write to local storage?
|
|
|
|
Answer: **Commands (Cmd)**
|
|
|
|
### The Command Pattern
|
|
|
|
Instead of performing side effects, you **describe** them:
|
|
|
|
```elm
|
|
-- Instead of actually making a request:
|
|
-- response = http.get("https://api.example.com") -- NOT how Elm works
|
|
|
|
-- You return a command describing what you want:
|
|
update msg model =
|
|
case msg of
|
|
FetchData ->
|
|
( model, Http.get { url = "...", expect = ... } )
|
|
-- Returns the model AND a command
|
|
```
|
|
|
|
Elm runtime executes the command and sends the result back as a message.
|
|
|
|
## Browser.element: Elm with Commands
|
|
|
|
We need to upgrade from `Browser.sandbox` to `Browser.element`:
|
|
|
|
```elm
|
|
main =
|
|
Browser.element
|
|
{ init = init
|
|
, update = update
|
|
, view = view
|
|
, subscriptions = subscriptions
|
|
}
|
|
```
|
|
|
|
### Key Differences
|
|
|
|
| Browser.sandbox | Browser.element |
|
|
|-----------------|-----------------|
|
|
| `init : Model` | `init : flags -> (Model, Cmd Msg)` |
|
|
| `update : Msg -> Model -> Model` | `update : Msg -> Model -> (Model, Cmd Msg)` |
|
|
| No side effects | Can perform side effects via Cmd |
|
|
|
|
## Your First HTTP Request
|
|
|
|
Let's fetch a random quote from an API.
|
|
|
|
### Step 1: Set Up the Project
|
|
|
|
```bash
|
|
mkdir http-example
|
|
cd http-example
|
|
elm init
|
|
elm install elm/http
|
|
elm install elm/json
|
|
```
|
|
|
|
### Step 2: The Complete Example
|
|
|
|
```elm
|
|
module Main exposing (main)
|
|
|
|
import Browser
|
|
import Html exposing (Html, button, div, p, text)
|
|
import Html.Events exposing (onClick)
|
|
import Http
|
|
|
|
|
|
-- MODEL
|
|
|
|
type Model
|
|
= Loading
|
|
| Success String
|
|
| Failure String
|
|
|
|
|
|
init : () -> ( Model, Cmd Msg )
|
|
init _ =
|
|
( Loading, fetchQuote )
|
|
|
|
|
|
-- UPDATE
|
|
|
|
type Msg
|
|
= GotQuote (Result Http.Error String)
|
|
| FetchNewQuote
|
|
|
|
|
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
update msg model =
|
|
case msg of
|
|
GotQuote result ->
|
|
case result of
|
|
Ok quote ->
|
|
( Success quote, Cmd.none )
|
|
|
|
Err error ->
|
|
( Failure (errorToString error), Cmd.none )
|
|
|
|
FetchNewQuote ->
|
|
( Loading, fetchQuote )
|
|
|
|
|
|
errorToString : Http.Error -> String
|
|
errorToString error =
|
|
case error of
|
|
Http.BadUrl url ->
|
|
"Bad URL: " ++ url
|
|
|
|
Http.Timeout ->
|
|
"Request timed out"
|
|
|
|
Http.NetworkError ->
|
|
"Network error"
|
|
|
|
Http.BadStatus status ->
|
|
"Bad status: " ++ String.fromInt status
|
|
|
|
Http.BadBody message ->
|
|
"Bad body: " ++ message
|
|
|
|
|
|
-- HTTP
|
|
|
|
fetchQuote : Cmd Msg
|
|
fetchQuote =
|
|
Http.get
|
|
{ url = "https://api.quotable.io/random"
|
|
, expect = Http.expectJson GotQuote quoteDecoder
|
|
}
|
|
|
|
|
|
quoteDecoder : Json.Decode.Decoder String
|
|
quoteDecoder =
|
|
Json.Decode.field "content" Json.Decode.string
|
|
|
|
|
|
-- VIEW
|
|
|
|
view : Model -> Html Msg
|
|
view model =
|
|
div []
|
|
[ case model of
|
|
Loading ->
|
|
text "Loading..."
|
|
|
|
Success quote ->
|
|
div []
|
|
[ p [] [ text quote ]
|
|
, button [ onClick FetchNewQuote ] [ text "New Quote" ]
|
|
]
|
|
|
|
Failure error ->
|
|
div []
|
|
[ p [] [ text ("Error: " ++ error) ]
|
|
, button [ onClick FetchNewQuote ] [ text "Try Again" ]
|
|
]
|
|
]
|
|
|
|
|
|
-- SUBSCRIPTIONS
|
|
|
|
subscriptions : Model -> Sub Msg
|
|
subscriptions _ =
|
|
Sub.none
|
|
|
|
|
|
-- MAIN
|
|
|
|
main =
|
|
Browser.element
|
|
{ init = init
|
|
, update = update
|
|
, view = view
|
|
, subscriptions = subscriptions
|
|
}
|
|
```
|
|
|
|
### Breaking It Down
|
|
|
|
#### The Model as State Machine
|
|
|
|
```elm
|
|
type Model
|
|
= Loading
|
|
| Success String
|
|
| Failure String
|
|
```
|
|
|
|
Using a custom type for the model means we can only be in ONE state at a time. No more:
|
|
```javascript
|
|
// JavaScript
|
|
{ isLoading: false, hasError: true, data: null } // Confusing!
|
|
```
|
|
|
|
#### Init Returns a Command
|
|
|
|
```elm
|
|
init : () -> ( Model, Cmd Msg )
|
|
init _ =
|
|
( Loading, fetchQuote ) -- Start in Loading state, fetch immediately
|
|
```
|
|
|
|
The `()` is called "unit" - it means no flags are passed. We return a **tuple** of model and command.
|
|
|
|
#### Update Returns a Command
|
|
|
|
```elm
|
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
update msg model =
|
|
case msg of
|
|
GotQuote result ->
|
|
case result of
|
|
Ok quote ->
|
|
( Success quote, Cmd.none ) -- No more commands
|
|
|
|
Err error ->
|
|
( Failure (errorToString error), Cmd.none )
|
|
```
|
|
|
|
`Cmd.none` means "no command to execute."
|
|
|
|
#### The HTTP Request
|
|
|
|
```elm
|
|
fetchQuote : Cmd Msg
|
|
fetchQuote =
|
|
Http.get
|
|
{ url = "https://api.quotable.io/random"
|
|
, expect = Http.expectJson GotQuote quoteDecoder
|
|
}
|
|
```
|
|
|
|
- `Http.get` creates a GET request command
|
|
- `expect` tells Elm what to do with the response
|
|
- `GotQuote` is the message constructor that will wrap the result
|
|
- `quoteDecoder` parses the JSON
|
|
|
|
## JSON Decoding
|
|
|
|
Elm doesn't just convert JSON to any type automatically. You must define a **decoder** that describes the JSON structure.
|
|
|
|
### Why Decoders?
|
|
|
|
JavaScript:
|
|
```javascript
|
|
const data = JSON.parse(response);
|
|
console.log(data.user.name); // Might crash if structure is wrong!
|
|
```
|
|
|
|
Elm: The decoder either succeeds with the exact type you expect, or fails gracefully.
|
|
|
|
### Basic Decoders
|
|
|
|
```elm
|
|
import Json.Decode exposing (Decoder, string, int, float, bool)
|
|
|
|
-- Primitive decoders
|
|
string : Decoder String -- Decodes "hello" to "hello"
|
|
int : Decoder Int -- Decodes 42 to 42
|
|
float : Decoder Float -- Decodes 3.14 to 3.14
|
|
bool : Decoder Bool -- Decodes true to True
|
|
```
|
|
|
|
### Decoding Objects
|
|
|
|
```elm
|
|
import Json.Decode exposing (Decoder, field, string, int)
|
|
|
|
-- JSON: { "name": "Alice" }
|
|
nameDecoder : Decoder String
|
|
nameDecoder =
|
|
field "name" string
|
|
|
|
-- JSON: { "user": { "name": "Alice" } }
|
|
nestedNameDecoder : Decoder String
|
|
nestedNameDecoder =
|
|
field "user" (field "name" string)
|
|
```
|
|
|
|
### Decoding Multiple Fields
|
|
|
|
```elm
|
|
import Json.Decode exposing (Decoder, map2, field, string, int)
|
|
|
|
type alias User =
|
|
{ name : String
|
|
, age : Int
|
|
}
|
|
|
|
-- JSON: { "name": "Alice", "age": 30 }
|
|
userDecoder : Decoder User
|
|
userDecoder =
|
|
map2 User
|
|
(field "name" string)
|
|
(field "age" int)
|
|
```
|
|
|
|
`map2` takes:
|
|
1. A constructor function (User)
|
|
2. Decoder for first field
|
|
3. Decoder for second field
|
|
|
|
For more fields, use `map3`, `map4`, etc., or the `elm/json-decode-pipeline` package.
|
|
|
|
### Using Pipeline Style (Recommended)
|
|
|
|
Install:
|
|
```bash
|
|
elm install NoRedInk/elm-json-decode-pipeline
|
|
```
|
|
|
|
```elm
|
|
import Json.Decode exposing (Decoder, string, int)
|
|
import Json.Decode.Pipeline exposing (required, optional)
|
|
|
|
type alias User =
|
|
{ name : String
|
|
, age : Int
|
|
, email : Maybe String
|
|
}
|
|
|
|
userDecoder : Decoder User
|
|
userDecoder =
|
|
Json.Decode.succeed User
|
|
|> required "name" string
|
|
|> required "age" int
|
|
|> optional "email" (Json.Decode.map Just string) Nothing
|
|
```
|
|
|
|
### Decoding Lists
|
|
|
|
```elm
|
|
import Json.Decode exposing (Decoder, list, string)
|
|
|
|
-- JSON: ["apple", "banana", "cherry"]
|
|
fruitsDecoder : Decoder (List String)
|
|
fruitsDecoder =
|
|
list string
|
|
|
|
-- JSON: [{ "name": "Alice" }, { "name": "Bob" }]
|
|
usersDecoder : Decoder (List User)
|
|
usersDecoder =
|
|
list userDecoder
|
|
```
|
|
|
|
### Decoding with Maybe
|
|
|
|
```elm
|
|
-- JSON: { "name": "Alice", "nickname": null }
|
|
-- Or: { "name": "Alice" } (nickname missing)
|
|
|
|
type alias Person =
|
|
{ name : String
|
|
, nickname : Maybe String
|
|
}
|
|
|
|
personDecoder : Decoder Person
|
|
personDecoder =
|
|
map2 Person
|
|
(field "name" string)
|
|
(Json.Decode.maybe (field "nickname" string))
|
|
```
|
|
|
|
### Testing Decoders
|
|
|
|
```elm
|
|
import Json.Decode exposing (decodeString)
|
|
|
|
result = decodeString userDecoder """{"name": "Alice", "age": 30}"""
|
|
-- Ok { name = "Alice", age = 30 }
|
|
|
|
badResult = decodeString userDecoder """{"name": "Alice"}"""
|
|
-- Err ... (missing field "age")
|
|
```
|
|
|
|
## POST Requests with JSON Body
|
|
|
|
```elm
|
|
import Http
|
|
import Json.Encode as Encode
|
|
|
|
createUser : String -> Int -> Cmd Msg
|
|
createUser name age =
|
|
Http.post
|
|
{ url = "https://api.example.com/users"
|
|
, body = Http.jsonBody (encodeUser name age)
|
|
, expect = Http.expectJson GotNewUser userDecoder
|
|
}
|
|
|
|
|
|
encodeUser : String -> Int -> Encode.Value
|
|
encodeUser name age =
|
|
Encode.object
|
|
[ ( "name", Encode.string name )
|
|
, ( "age", Encode.int age )
|
|
]
|
|
```
|
|
|
|
### JSON Encoding
|
|
|
|
```elm
|
|
import Json.Encode as Encode
|
|
|
|
-- Primitives
|
|
Encode.string "hello" -- "hello"
|
|
Encode.int 42 -- 42
|
|
Encode.float 3.14 -- 3.14
|
|
Encode.bool True -- true
|
|
Encode.null -- null
|
|
|
|
-- Objects
|
|
Encode.object
|
|
[ ( "name", Encode.string "Alice" )
|
|
, ( "age", Encode.int 30 )
|
|
]
|
|
-- { "name": "Alice", "age": 30 }
|
|
|
|
-- Lists
|
|
Encode.list Encode.int [1, 2, 3]
|
|
-- [1, 2, 3]
|
|
```
|
|
|
|
## Complete Example: GitHub User Search
|
|
|
|
```elm
|
|
module GitHubSearch exposing (main)
|
|
|
|
import Browser
|
|
import Html exposing (Html, button, div, img, input, li, text, ul)
|
|
import Html.Attributes exposing (placeholder, src, value, width)
|
|
import Html.Events exposing (onClick, onInput)
|
|
import Http
|
|
import Json.Decode exposing (Decoder, field, int, list, string)
|
|
|
|
|
|
-- MODEL
|
|
|
|
type alias User =
|
|
{ login : String
|
|
, avatarUrl : String
|
|
, id : Int
|
|
}
|
|
|
|
|
|
type Model
|
|
= Initial
|
|
| Loading String
|
|
| Success String (List User)
|
|
| Failure String String
|
|
|
|
|
|
init : () -> ( Model, Cmd Msg )
|
|
init _ =
|
|
( Initial, Cmd.none )
|
|
|
|
|
|
-- UPDATE
|
|
|
|
type Msg
|
|
= UpdateSearch String
|
|
| Search
|
|
| GotUsers (Result Http.Error (List User))
|
|
|
|
|
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
update msg model =
|
|
case msg of
|
|
UpdateSearch query ->
|
|
case model of
|
|
Initial ->
|
|
( Initial, Cmd.none )
|
|
|
|
Loading q ->
|
|
( Loading query, Cmd.none )
|
|
|
|
Success _ users ->
|
|
( Success query users, Cmd.none )
|
|
|
|
Failure _ error ->
|
|
( Failure query error, Cmd.none )
|
|
|
|
Search ->
|
|
let
|
|
query =
|
|
getQuery model
|
|
in
|
|
if String.isEmpty query then
|
|
( model, Cmd.none )
|
|
else
|
|
( Loading query, searchUsers query )
|
|
|
|
GotUsers result ->
|
|
let
|
|
query =
|
|
getQuery model
|
|
in
|
|
case result of
|
|
Ok users ->
|
|
( Success query users, Cmd.none )
|
|
|
|
Err error ->
|
|
( Failure query (errorToString error), Cmd.none )
|
|
|
|
|
|
getQuery : Model -> String
|
|
getQuery model =
|
|
case model of
|
|
Initial ->
|
|
""
|
|
|
|
Loading q ->
|
|
q
|
|
|
|
Success q _ ->
|
|
q
|
|
|
|
Failure q _ ->
|
|
q
|
|
|
|
|
|
errorToString : Http.Error -> String
|
|
errorToString error =
|
|
case error of
|
|
Http.BadUrl url ->
|
|
"Bad URL: " ++ url
|
|
|
|
Http.Timeout ->
|
|
"Request timed out"
|
|
|
|
Http.NetworkError ->
|
|
"Network error"
|
|
|
|
Http.BadStatus status ->
|
|
"Bad status: " ++ String.fromInt status
|
|
|
|
Http.BadBody message ->
|
|
"Bad body: " ++ message
|
|
|
|
|
|
-- HTTP
|
|
|
|
searchUsers : String -> Cmd Msg
|
|
searchUsers query =
|
|
Http.get
|
|
{ url = "https://api.github.com/search/users?q=" ++ query
|
|
, expect = Http.expectJson GotUsers usersDecoder
|
|
}
|
|
|
|
|
|
usersDecoder : Decoder (List User)
|
|
usersDecoder =
|
|
field "items" (list userDecoder)
|
|
|
|
|
|
userDecoder : Decoder User
|
|
userDecoder =
|
|
Json.Decode.map3 User
|
|
(field "login" string)
|
|
(field "avatar_url" string)
|
|
(field "id" int)
|
|
|
|
|
|
-- VIEW
|
|
|
|
view : Model -> Html Msg
|
|
view model =
|
|
div []
|
|
[ div []
|
|
[ input
|
|
[ placeholder "Search GitHub users..."
|
|
, value (getQuery model)
|
|
, onInput UpdateSearch
|
|
]
|
|
[]
|
|
, button [ onClick Search ] [ text "Search" ]
|
|
]
|
|
, viewResults model
|
|
]
|
|
|
|
|
|
viewResults : Model -> Html Msg
|
|
viewResults model =
|
|
case model of
|
|
Initial ->
|
|
div [] [ text "Enter a search term" ]
|
|
|
|
Loading _ ->
|
|
div [] [ text "Searching..." ]
|
|
|
|
Success _ users ->
|
|
if List.isEmpty users then
|
|
div [] [ text "No users found" ]
|
|
else
|
|
ul [] (List.map viewUser users)
|
|
|
|
Failure _ error ->
|
|
div [] [ text ("Error: " ++ error) ]
|
|
|
|
|
|
viewUser : User -> Html Msg
|
|
viewUser user =
|
|
li []
|
|
[ img [ src user.avatarUrl, width 50 ] []
|
|
, text (" " ++ user.login)
|
|
]
|
|
|
|
|
|
-- SUBSCRIPTIONS
|
|
|
|
subscriptions : Model -> Sub Msg
|
|
subscriptions _ =
|
|
Sub.none
|
|
|
|
|
|
-- MAIN
|
|
|
|
main =
|
|
Browser.element
|
|
{ init = init
|
|
, update = update
|
|
, view = view
|
|
, subscriptions = subscriptions
|
|
}
|
|
```
|
|
|
|
## Exercise 6.1: Basic HTTP Request
|
|
|
|
Create an app that:
|
|
1. Fetches a random joke from `https://official-joke-api.appspot.com/random_joke`
|
|
2. Displays the setup and punchline
|
|
3. Has a "New Joke" button
|
|
|
|
The API returns:
|
|
```json
|
|
{
|
|
"type": "general",
|
|
"setup": "Why did the chicken...",
|
|
"punchline": "To get to the other side!",
|
|
"id": 123
|
|
}
|
|
```
|
|
|
|
<details>
|
|
<summary>Solution</summary>
|
|
|
|
```elm
|
|
module JokeApp exposing (main)
|
|
|
|
import Browser
|
|
import Html exposing (Html, button, div, p, text)
|
|
import Html.Events exposing (onClick)
|
|
import Http
|
|
import Json.Decode exposing (Decoder, field, string)
|
|
|
|
|
|
type alias Joke =
|
|
{ setup : String
|
|
, punchline : String
|
|
}
|
|
|
|
|
|
type Model
|
|
= Loading
|
|
| Success Joke
|
|
| Failure String
|
|
|
|
|
|
init : () -> ( Model, Cmd Msg )
|
|
init _ =
|
|
( Loading, fetchJoke )
|
|
|
|
|
|
type Msg
|
|
= GotJoke (Result Http.Error Joke)
|
|
| FetchNewJoke
|
|
|
|
|
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
update msg model =
|
|
case msg of
|
|
GotJoke result ->
|
|
case result of
|
|
Ok joke ->
|
|
( Success joke, Cmd.none )
|
|
|
|
Err _ ->
|
|
( Failure "Failed to fetch joke", Cmd.none )
|
|
|
|
FetchNewJoke ->
|
|
( Loading, fetchJoke )
|
|
|
|
|
|
fetchJoke : Cmd Msg
|
|
fetchJoke =
|
|
Http.get
|
|
{ url = "https://official-joke-api.appspot.com/random_joke"
|
|
, expect = Http.expectJson GotJoke jokeDecoder
|
|
}
|
|
|
|
|
|
jokeDecoder : Decoder Joke
|
|
jokeDecoder =
|
|
Json.Decode.map2 Joke
|
|
(field "setup" string)
|
|
(field "punchline" string)
|
|
|
|
|
|
view : Model -> Html Msg
|
|
view model =
|
|
div []
|
|
[ case model of
|
|
Loading ->
|
|
text "Loading..."
|
|
|
|
Success joke ->
|
|
div []
|
|
[ p [] [ text joke.setup ]
|
|
, p [] [ text joke.punchline ]
|
|
, button [ onClick FetchNewJoke ] [ text "New Joke" ]
|
|
]
|
|
|
|
Failure error ->
|
|
div []
|
|
[ text error
|
|
, button [ onClick FetchNewJoke ] [ text "Try Again" ]
|
|
]
|
|
]
|
|
|
|
|
|
subscriptions : Model -> Sub Msg
|
|
subscriptions _ =
|
|
Sub.none
|
|
|
|
|
|
main =
|
|
Browser.element
|
|
{ init = init
|
|
, update = update
|
|
, view = view
|
|
, subscriptions = subscriptions
|
|
}
|
|
```
|
|
|
|
</details>
|
|
|
|
## Exercise 6.2: Nested JSON Decoding
|
|
|
|
Write a decoder for this JSON:
|
|
|
|
```json
|
|
{
|
|
"user": {
|
|
"profile": {
|
|
"name": "Alice",
|
|
"bio": "Elm enthusiast"
|
|
},
|
|
"stats": {
|
|
"followers": 100,
|
|
"following": 50
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Into this type:
|
|
|
|
```elm
|
|
type alias UserInfo =
|
|
{ name : String
|
|
, bio : String
|
|
, followers : Int
|
|
, following : Int
|
|
}
|
|
```
|
|
|
|
<details>
|
|
<summary>Solution</summary>
|
|
|
|
```elm
|
|
import Json.Decode exposing (Decoder, field, int, map4, string)
|
|
|
|
|
|
userInfoDecoder : Decoder UserInfo
|
|
userInfoDecoder =
|
|
map4 UserInfo
|
|
(field "user" (field "profile" (field "name" string)))
|
|
(field "user" (field "profile" (field "bio" string)))
|
|
(field "user" (field "stats" (field "followers" int)))
|
|
(field "user" (field "stats" (field "following" int)))
|
|
|
|
|
|
-- Or using Json.Decode.at for cleaner nested access:
|
|
userInfoDecoder2 : Decoder UserInfo
|
|
userInfoDecoder2 =
|
|
map4 UserInfo
|
|
(Json.Decode.at [ "user", "profile", "name" ] string)
|
|
(Json.Decode.at [ "user", "profile", "bio" ] string)
|
|
(Json.Decode.at [ "user", "stats", "followers" ] int)
|
|
(Json.Decode.at [ "user", "stats", "following" ] int)
|
|
```
|
|
|
|
</details>
|
|
|
|
## Key Takeaways
|
|
|
|
1. **Commands describe side effects** - Elm runtime performs them
|
|
2. **HTTP requests return Results** - Success or failure
|
|
3. **JSON decoders are type-safe** - Compiler ensures correct parsing
|
|
4. **Model states** - Use custom types to represent Loading/Success/Failure
|
|
5. **Update returns (Model, Cmd)** - New state plus commands to run
|
|
|
|
## What's Next?
|
|
|
|
In [Lesson 7](07-final-project.md), we'll build a complete application:
|
|
- A todo list with persistence
|
|
- Multiple pages
|
|
- All the concepts combined!
|
|
|
|
---
|
|
|
|
[← Previous: Lesson 5](05-lists-maybe.md) | [Next: Lesson 7 - Final Project →](07-final-project.md)
|