In Practical Elm, I discuss at length how to convert a complex JSON string containing nested objects, arrays and recursion into Elm values.
But what about the opposite conversion, from Elm values to a JSON string? We often need to make requests to a server which include data in JSON format. For example, you might be working with a database like MongoDB or CouchDB, and you might need to get a JSON document from it, modify it in some way, and then send it back to the server.
In JavaScript, converting JSON to a string is as simple as JSON.stringify(myObject)
, but in Elm it takes a bit more work.
The Elm core library includes the Json.Encode
package. The process of using it is somewhat similar to using Json.Decode
:
Json.Encode.Value
encoding the appropriate JSON structure encode : Int -> Value -> String
(the Int
argument is the number of spaces to use for indentation) To produce a Json.Encode.Value
, there are a number of primitive functions to deal with “leaf nodes”:
string : String -> Value
int : Int -> Value
float : Float -> Value
bool : Bool -> Value
null : Value
To combine these values into objects and arrays, we have three more functions:
list : List Value -> Value
array : Array Value -> Value
object : List (String, Value) -> Value
Since JSON is a fairly simple format, this is enough to describe any JSON document.
In Practical Elm, I show how to decode JSON documents that look like this:
{
"Plan": {
"Node Type": "Hash Join",
"Parallel Aware": false,
"Join Type": "Inner",
"Startup Cost": 1.04,
"Total Cost": 2.11,
"Plan Rows": 2,
"Plan Width": 176,
"Actual Startup Time": 0.061,
"Actual Total Time": 0.066,
"Actual Rows": 8,
"Actual Loops": 1,
"Hash Cond": "(zones.project_id = projects.project_id)",
"Plans": [
{
"Node Type": "Seq Scan",
"Parent Relationship": "Outer",
"Parallel Aware": false,
"Relation Name": "zones",
"Alias": "zones",
"Startup Cost": 0.00,
"Total Cost": 1.03,
"Plan Rows": 3,
"Plan Width": 112,
"Actual Startup Time": 0.013,
"Actual Total Time": 0.015,
"Actual Rows": 4,
"Actual Loops": 1
},
{
"Node Type": "Hash",
"Parent Relationship": "Inner",
"Parallel Aware": false,
"Startup Cost": 1.02,
"Total Cost": 1.02,
"Plan Rows": 2,
"Plan Width": 72,
"Actual Startup Time": 0.013,
"Actual Total Time": 0.013,
"Actual Rows": 4,
"Actual Loops": 1,
"Hash Buckets": 1024,
"Original Hash Buckets": 1024,
"Hash Batches": 1,
"Original Hash Batches": 1,
"Peak Memory Usage": 9,
"Plans": [
{
"Node Type": "Seq Scan",
"Parent Relationship": "Outer",
"Parallel Aware": false,
"Relation Name": "projects",
"Alias": "projects",
"Startup Cost": 0.00,
"Total Cost": 1.02,
"Plan Rows": 2,
"Plan Width": 72,
"Actual Startup Time": 0.004,
"Actual Total Time": 0.005,
"Actual Rows": 4,
"Actual Loops": 1
}
]
}
]
},
"Planning Time": 0.174,
"Triggers": [
],
"Execution Time": 0.169
}
The top level consists of a “Plan” key plus some optional fields like “Execution Time” and “Planning Time”. That’s straightforward, but the value under the “Plan” key is more interesting:
Each node has a set of attributes common to all nodes, as well as some node specific attributes. For example, every node has “Startup Cost” and “Total Cost”, but a “Seq Scan” node also has “Filter” and “Relation Name” which are specific to it. There are many possible node types with different sets of attributes, and some of the attributes may or may not be present.
The structure is recursive: a node may optionally contain a “Plans” field which is a list of nodes, each of which may have its own “Plans” field, and so on.
Eventually, I write the decoders to produce a value of type PlanJson
which in turn incorporates a bunch of other types:
type alias PlanJson =
{ executionTime : Float
, plan : Plan
, planningTime : Float
, triggers : List String
}
type Plan
= PCte CteNode
| PGeneric CommonFields
| PResult ResultNode
| PSeqScan SeqScanNode
| PSort SortNode
type Plans
= Plans (List Plan)
type alias CommonFields =
{ actualLoops : Int
, actualTotalTime : Float
, nodeType : String
, plans : Plans
, relationName : String
, schema : String
, startupCost : Float
, totalCost : Float
}
type alias ResultNode =
{ common : CommonFields
, parentRelationship : String
}
type alias CteNode =
{ common : CommonFields
, alias_ : String
, cteName : String
}
type alias SeqScanNode =
{ common : CommonFields
, alias_ : String
, filter : String
, relationName : String
, rowsRemovedByFilter : Int
}
type alias SortNode =
{ common : CommonFields
, sortKey : List String
, sortMethod : String
, sortSpaceUsed : Int
, sortSpaceType : String
}
But let’s see how we can instead encode a subset of data from a PlanJson
value into a JSON string. We’ll start from the top level structure:
encode : PlanJson -> Json.Value
encode planJson =
object
[ ( "Execution Time", float planJson.executionTime )
, ( "Planning Time", float planJson.planningTime )
, ( "Triggers", list <| List.map string planJson.triggers )
, ( "Plan", encodePlan planJson.plan )
]
Note that in order to encode “Triggers” which is an array of strings, we first encode individual values in the list and then convert the resulting List Value
into Value
with list
: ( "Triggers", list <| List.map string planJson.triggers )
Next, encodePlan
looks like this:
encodePlan : Plan -> Json.Value
encodePlan plan =
case plan of
PCte cteNode ->
object <|
encodeCommonFields cteNode.common
++ [ ( "Alias", string cteNode.alias_ )
, ( "CTE Name", string cteNode.cteName )
]
PGeneric common ->
object <| encodeCommonFields common
PResult resultNode ->
object <| encodeCommonFields resultNode.common
PSeqScan seqScanNode ->
object <|
encodeCommonFields seqScanNode.common
++ [ ( "Alias", string seqScanNode.alias_ )
, ( "Filter", string seqScanNode.filter )
]
PSort sortNode ->
object <| encodeCommonFields sortNode.common
For each type of node, we have to encode the common attributes and then combine them in a single object with node-specific attributes. I didn’t bother with node-specific attributes for PResult
and PSort
node types.
encodeCommonFields
looks like this (again, I’m only including a subset of fields in the JSON):
encodeCommonFields : CommonFields -> List ( String, Json.Value )
encodeCommonFields common =
let
(Plans plans) =
common.plans
in
[ ( "Actual Loops", int common.actualLoops )
, ( "Actual Total Time", float common.actualTotalTime )
, ( "Plans", list <| List.map encodePlan plans )
]
This function describes the recursion in the JSON structure as it calls encodePlan
for the “Plans” key. Fortunately, it’s more straightforward to deal with recursion when encoding JSON. Unlike decoding, it doesn’t require the use of lazy
, for example.
With these three functions, we’ve described the JSON that we would like to produce. In order to generate a string, we can call encode
:
Json.encode 2 <| EncodePlan.encode planJson
This will produce JSON strings like this one:
{
"Execution Time": 0.169,
"Planning Time": 0.174,
"Triggers": [],
"Plan": {
"Actual Loops": 1,
"Actual Rows": 8,
"Plans": [
{
"Actual Loops": 1,
"Actual Rows": 4,
"Plan": [],
"Alias": "zones",
"Filter": ""
},
{
"Actual Loops": 1,
"Actual Rows": 4,
"Plans": [
{
"Actual Loops": 1,
"Actual Rows": 4,
"Plans": [],
"Alias": "projects",
"Filter": ""
}
]
}
]
}
}
However, in order to send this JSON to a server in an HTTP request (using the Http
package from the Elm core library), we don’t need to convert it to a string. We can use Http.jsonBody
when constructing the request, and pass it the planJson
converted into Json.Encode.Value
:
savePlan : String -> PlanJson -> Cmd Msg
savePlan serverUrl planJson =
let
body =
Http.jsonBody <| EncodePlan.encode planJson
request =
Http.post (serverUrl ++ "plan") body Json.Decode.succeed
in
Http.send PlanSaved request
Finally, as an aside: what if we wanted to send a PlanJson
value to JavaScript through a port? In that case, we don’t need to convert the value to JSON, all we need to do is define an appropriate port:
port sendPlanJson : PlanJson -> Cmd msg
Then we can generate a command to send planJson
through the port with sendPlanJson planJson
.
And that’s it!
If you want to know how to decode complex JSON, you can learn all the grisly details in Practical Elm right now.