How I uploaded a file

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

8 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.