8. Elm - Eine funktionale Programmiersprache zur Entwicklung von Web-Apps

8.5 Json in Elm

Json in Elm

Wenn Sie mit einem Server Daten austauschen, dann ist Json ein übliches Format. Der Server könnte zum Beispiel eine Hochschul-Datenbank sein, und die Daten könnten so aussehen:

"student182952" : {
    "Immatrikulationsnummer" : 182952, 
    "Jahrgang" : {
        "Semester" : "Winter",
        "Jahr" : "2022"
    },
    "Name" : "Simone Schneider",
    "Kurse" : ["Algorithmen", "Netwerke", "Datenbanken"]
}                        

In Javascript könnten wir das jetzt per Json.parse in ein Objekt übersetzen. Das Problem in Elm ist, dass der Datentyp bereits aus dem Code heraus ersichtlich sein muss; es kann also gar keine allgemeine Funktion Json.parse geben. Dafür kann man sich recht problemlos aus Bausteinen einen Parser zusammenbauen.

elm repl                        
import Json.Decode as Js
Js.decodeString
<function> : Js.Decoder a -> String -> Result Js.Error a
                    

Js.decodeString wandelt also einen String (das rohe Json) in einen a-Wert um, braucht aber einen "Json-nach-a"-Decoder. Am Besten zeige ich Ihnen ein paar Beispiele:

Js.decodeString Js.int "42"                        
Ok 42 : Result Js.Error Int

Warum ist der Output nicht 42 sondern Ok 42 : Result Js.Error Int? Offensichtlich kann Js.decodeString Js.int nicht immer ein Int liefern:

Js.decodeString Js.int "zwei"                        
Err (Failure ("This is not valid JSON! Unexpected token z in JSON at position 0") <internals>)
    : Result Js.Error Int

Js.decodeString Js.int wandelt den gegebenen (Json)-String also entweder in ein Int oder in eine Fehlermeldung um; hierfür brauchen wir einen Union-Typ, in diesem Falle Result err value. Dies ist ähnlich zu Maybe a, nur dass es im Fehlerfall uns erlaubt, eine Fehlermeldungen als Payload anzuhängen.

Js.decodeString Js.bool "false" -- achten Sie darauf, im Json-String "false" kleinzuschreiben  
Ok False : Result Js.Error Bool
Js.decodeString Js.string "\"ein String im String\""-- der Elm-String muss als Inhalt einen Json-String enthalten; daher die inneren Quotes  
Ok ("ein String im String") : Result Js.Error String
Js.decodeString Js.string "42"
Err (Failure ("Expecting a STRING") <internals>)
: Result Js.Error String
                    
Als zusammengesetzte Datentypen gibt es in Json Listen wie [2,3,5] und Dictionaries wie {hostname : "www.hszg.de", port : 80}. Listen sind einfach zu decodieren. Mit Js.list myJsonTo_a_decoder erhalten sie einen "Json-nach-(List a)-Decoder".
Js.decodeString (Js.list Js.int) "[42,6,78]"
Ok [42,6,78] : Result Js.Error (List Int)
Js.decodeString (Js.list Js.string) "[\"42\", \"hello\"]"
Ok ["42","hello"] : Result Js.Error (List String)
                    

Beachten Sie: "[42,6,78]" ist keine Liste aus Strings, sondern ein String, dessen Inhalt wie eine Liste aussieht.

{"hostname" : "www.hszg.de", 
 "port" : 80}                        
                    

Mit Js.field "port" erhalten Js.field "port" Js.int erhalten wir einen Js.Decoder Int, der aus dem Json-Dictionary das Feld "port" herausziehen will; das funktioniert natürlich nur, wenn es (1) das Feld gibt und (2) es tatsächlich ein Int ist.

jsonString = "{\"hostname\" : \"www.hszg.de\", \"port\" : 80}"                     
Js.decodeString (Js.field "port" Js.int) jsonString
Ok 80 : Result Js.Error Int
 Js.decodeString (Js.field "hostname" Js.string) jsonString
Ok "www.hszg.de" : Result Js.Error String
Js.decodeString (Js.field "portnumber" Js.int) jsonString
Err (Failure ("Expecting an OBJECT with a field named `portnumber`") <internals>)
    : Result Js.Error Int
Js.decodeString (Js.field "hostname" Js.int) jsonString
Err (Field "hostname" (Failure ("Expecting an INT") <internals>))
    : Result Js.Error Int

Stellen Sie sich vor, wir hätten einen Datentyp host wie folgt:

type alias Host =                         
  { hostname : String
  , portnumber : Int
  }

und wollen den String in ein Objekt vom Typ Host umwandeln. Wir könnten es per Hand machen und die Felder einzeln herausziehen und jedes Mal auf Fehler prüfen; manchmal geht das nicht: gewisse Elm-Funktionen verlangen einen "Json-nach-Datentyp-XYZ-Decoder". Hier kommen Js.map, Js.map2, ..., Js.map8 zur Hilfe.

Js.map2
<function>: (a -> b -> value) -> Js.Decoder a -> Js.Decoder b -> Js.Decoder value

Die Semantik ist wie folgt: das zweite und dritte Argument sind ein Json-nach-a- bzw. Json-nach-b-Decoder. Das erste ist eine Funktion, die aus einem a und einem b ein Objekt vom Type value bauen. Hier wollen wir aus String und Int einen Host bauen.

type alias Host = {hostname : String, portnumber : Int}    

hostBuilder : String -> Int -> Host
hostBuilder hname pnum = 
    {hostname = hname, portnumber = pnum}
<function> : String -> Int -> Host

hostDecoder = Js.map2 hostBuilder (Js.field "hostname" Js.string) (Js.field "port" Js.int)
<internals> : Js.Decoder Host

Js.decodeString hostDecoder "{\"hostname\" : \"www.hszg.de\", \"port\" : 80}"
Ok { hostname = "www.hszg.de", portnumber = 80 }
    : Result Js.Error Host

Ein bisschen Syntax-Zucker kommt uns zur Hilfe. Wir brauchen die Funktion hostBuilder gar nicht. Der Name der Typ-Alias Host selbst ist eine Funktion, die die Argumente in den Record-Typ Host umwandelt:

Host                        
<function> : String -> Int -> Host

Natürlich ist Host keine "echte" Funktion, sondern ein Datenkonstruktor. Es kann aber natürlich vorkommen, dass Sie als erstes Argument von Js.map2 keinen Datenkonstruktor sondern eine echte Funktion schreiben wollen:

hostDecoder = Js.map2 (\n p -> n ++ ":" ++ (String.fromInt p)) (Js.field "hostname" Js.string) (Js.field "port" Js.int)                        
Js.decodeString hostDecoder "{\"hostname\" : \"www.hszg.de\", \"port\" : 80}"

Gute Programmierpraxis ist das wahrscheinlich nicht; es ist wohl immer besser, die Daten erst einmal explizit in der Hand zu halten, bevor Sie sie weiterverarbeiten. Aber nach Syntax und Semantik von Elm und Json.Decode ist das gar kein Problem.

Übungsaufgabe Adaptieren Sie Website03ExpandList.elm, so dass oben eine große Textarea ist, in die ich im Json-Format eine Integer-Sequenz eingeben kann; diese soll aus einem Namen und einer Liste von ganzen Zahlen bestehen, wie zum Beispiel hier