How I uploaded a file
I was migrating some old rails (trailblazer) code, turning the front into Elm and all communications into JSON. One of the forms contains a required file select to attach a PDF.
I started looking at the docs for elm/file
and elm/http
, but they weren’t all too clear.
I understood easily how to upload the file, but what about the rest of my form, Http.filePart
was easy to understand, but Http.stringPart
made no sense.
How do I fit my JSON into it?
To find out, I had to resort to WireShark and look at what the current form sends when I attach a file. When you use a Http.multipartBody
, every field becomes a separate Http.stringPart
.
Code
module Upload exposing (main)
import File exposing (File)
import File.Select as Select
import Html exposing (..)
import Html.Attributes exposing (type_, value, checked)
import Html.Events exposing (onClick, onInput)
import Http
import Json.Decode as Decode
import Json.Encode as Encode
--- MODEL
type alias Model =
{ name: String
, age: Int
, checked: Bool
, pdf : Maybe File
}
init: () -> (Model, Cmd Msg)
init _ =
(
{ name = ""
, age = 0
, checked = False
, pdf = Nothing
}
--- API
encode : Model -> List (String, Encode.Value)
encode model =
[("name", Encode.string model.name)
,("age", Encode.int model.age)
,("checked", Encode.bool model.checked)
]
save : Model -> Cmd Msg
save model =
let
body =
case model.pdf of
Nothing ->
Http.jsonBody (Encode.object [("person", Encode.object (encode model) ) ])
Just file ->
Http.multipartBody
(Http.filePart "person[pdf]" file
:: List.map
(\( l, v ) ->
Http.stringPart ("person[" ++ l ++ "]") (Encode.encode 0 v)
)
(encode model)
)
in
Http.post
{ url = "/persons"
, body = body
, expect = Http.expectJson Saved Decode.int
}
--- UPDATE
Type Msg
= SelectFile
| FileSelected File
| ClearFile
| Save
| Saved (Result Http.Error Int)
| ChangedName String
| ChangedAge String
| Toggled
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
SelectFile ->
( model, Select.file [ "application/pdf" ] FileSelected )
FileSelected f ->
( {model | pdf = Just f}, Cmd.none)
ClearFile ->
( {model | pdf = Nothing}, Cmd.none)
Save ->
( model, save model)
Saved (Ok i) ->
( {model | pdf = Nothing}, Cmd.none)
Saved (Err _) ->
( model, Cmd.none)
ChangedName str ->
({ model | name = str}, Cmd.none)
ChangedAge str ->
({ model | age = String.toInt str |> Maybe.withDefault 0 }, Cmd.none)
Toggled ->
({ model | check = not model.check}, Cmd.none)
--- VIEW
view : Model -> Html Msg
view model =
let
pdfBtn =
case model.pdf of
Nothing ->
button [ onClick SelectFile ] [ text "Add PDF" ]
Just pdf ->
button [ onClick ClearFile ] [ text ("Remove " ++ File.name pdf) ]
in
div []
[ label [] [ text "Name" ]
, input [ type_ "text", value model.name, onInput ChangedName ] []
, label [] [ text "Age" ]
, input [ type_ "number", value (String.fromInt model.age), onInput ChangedAge ] []
, label []
[ text "Check this if you dare "
, input [ type_ "checkbox", checked model.checked, onClick Toggled ]
]
, pdfBtn
, button [ onClick Save ] [ text "Save" ]
]
--- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = always Sub.none
}
So what did I do?
I used the elm/file
File.Select
, triggered from a button
, to allow the user to choose a PDF file. On receiving this file, I store it in the model. When the save button is clicked, the code checks for the presence of a File
, if there is none, we use a regular Http.jsonBody
and Http.post
it. If however, there is a File
, we use Http.multipartBody
, add the file using Http.filePart
and then loop over the fields, convert them into strings for the Http.stringPart
. Both filePart
and stringPart
take a String
that is the field name, by using the person[fieldname]
construction, Rails (Rack) receives this as fields in params[:person]
. Encode.encode 0 v
allows me to use the Json.Encode
functions to turn my fields into valid strings.
Disclaimer
I distilled this code from my working solution, don’t shoot me over typos or certain omissions.
Herman