Beispiel 5 - JSON erstellen mit newMap(), zipMap(), toJSON() und .val()
tl;dr Dieses Tutorial zeigt, wie man valides JSON mit Hilfe zweier Synesty Template-Funktionen erstellt. Synesty bietet mit der Freemarker Skriptsprache dafür verschiedene Methodiken dies zu realisieren, die wir hier vorstellen.
Beispielszenario
Bei der Anbindung von REST-APIs wird JSON in der Regel als Datenaustauschformat genutzt. D.h. um ein JSON-Dokument im HTTP-Request-Body eines API-Calls zu schicken, muss man dieses JSON erstellen.
Ziel: Nehmen wir an, wir wollen ein JSON für eine Liste von Produkten erzeugen, die sich in einem Datastore befinden:
{
"products: [
{
"product_id: "1",
"price": 19.99
},
{
"product_id: "2",
"price": 29.99
}
]
}
Aus diesen Daten in einem Datastore mit folgendem Schema:
Ein Ansatz, wäre sich das JSON als Zeichenkette / String mit einem Freemarker Skript zusammenzubauen z.B. per TextHTMLWriter Step, oder mit einem der HTTP-Steps (siehe REST-API Anbindung).
Auf "herkömmliche Art und Weise könnte man dies per Freemarker wie folgt machen:
{
"products" : [
<#list spreadsheet@SearchMasterDatastore_1.getRows() as row>
{
"product_id: "${row['id']!?json_string}",
"price": ${row['price']}
}<#sep>,<#sep>
</#list>
]
}
Problem: Man muss sehr genau aufpassen, dass man valides JSON erzeugt. Ein Komma zuviel oder ein Hochkomma zu wenig und die Empfänger-API lehnt das JSON ab, weil es nicht valide (kaputt) ist.
Lösung: Die Synesty Template-Funktion toJSON() in Verbindung mit newMap() und .val()
erlaubt es durch eine etwas andere Schreibweise ein valides JSON zu erstellen.
${toJSON(
newMap(
{
"products":
spreadsheet@SearchMasterDatastore_1
.getRows()?map(
row ->
newMap(
{
"product_id": row.val("id"),
"price": row.val("price")
}
)
)?sequence
}
)
)
}
Das Ergebnis beider Ansätze ist gleich.
Hinweis zu ?sequence
Die ?sequence Anweisung ist notwendig, um einen spezielle Freemarker-Limitierung in Verbindung mit ?map zu umgehen.
Anmerkung zu Syntaxalternativen
Das Beispiel oben zeigt eine sehr kompakte inline-Schreibweise von toJSON()
und newMap()
. Der Vorteil dieser Schreibweise ist, dass sie optisch schon dem zu erstellenden JSON-ähnelt. Diese sieht zwar durch mehr zusätzliche Klammern etwas komplexer aus, aber durch Einrückung kann man den Überblick behalten.
Wer statt der kompakten inline-Schreibweise mit ?map()
lieber die Freemarker <#list>
Anweisung benutzt kann dies auch, und nutzt dann ein der set()-Funktionen die newMap() bietet (Siehe newMap()-Dokumentation). z.B. set
${newMap({'key1': 'value1', 'key2': 'value2'}).set('key3','value3')['key3']}
oder setNonEmpty
${newMap({'key1': 'value1', 'key2': 'value2'}).setNonEmpty('key3','','mydefaultIfEmpty')['key3']}
Das lässt sich auch mit einer Variable mit <#assign> kombinieren:
<#assign meineMap = newMap({'key1': 'value1', 'key2': 'value2'})}>
${meineMap.set('key3','value3')}
${meineMap['key3']}
oder mit einer <#list> Anweisung:
<#list row as col>
${meineMap.set(col.getTitle, col.val)}
</#list>
Die zipMap() Funktion
Mithilfe der zipMap() Funktion lassen sich aus mehreren Listen oder Multivalue-Spalten indexbasiert Listen von Objekten erstellen. Klingt erst einmal kompliziert, kann aber sehr hilfreich beim Erstellen komplexer JSON-Strukturen sein. Angenommen es soll eine JSON erstellt werden, die Arrays von Objekten beinhaltet.
{
"products: [
{
"product_id": "1",
"prices": [
{
"type": "REDUCED",
"price": 9.99
},
{
"type": "PURCHASE",
"price": 12.99
}
],
"images": [
{
"url": "http://url.to.image?1",
"name": "image 1"
},
{
"url": "http://url.to.image?2",
"name": "image 2"
},
]
}
]
}
Das Produkt hat in diesem Beispiel mehrere Preise und Bilder. Die Spalten des Spreadsheets sehen so aus:
products_product_id | products_product_prices_type | products_product_prices_price | products_product_images_url | products_product_images_name |
---|---|---|---|---|
1 | REDUCED;PURCHASE | 9.99;12.99 | http://url.to.image?1;http://url.to.image?2 | image 1;image 2 |
Für das prices
und images
Array müssen die Werte nun zusammengefügt werden. Dazu nutzen wir nun die neue zipMap() Freemarker Funktion:
- für Multivalue-Spalten direkt aus dem SearchDatastore Step, wo die Schema Informationen vorhanden sind (um die val() Funktion nutzen zu können)
{
"products: [
{
"product_id": row.val("product_id"),
"prices": zipMap({
"type": row.val("type"),
"price": row.val("price")
}),
"images": zipMap({
"url": row.val("url"),
"name": row.val("name")
})
}
]
}
- für Listen bzw. Sequenzen:
{
"products: [
{
"product_id": "1",
"prices": zipMap({
"type": ["REDUCED","PURCHASE"],
"price": [9.99,12.99]
}),
"images": zipMap({
"url": ["http://url.to.image?1","http://url.to.image?2"],
"name": ["image 1","image 2"]
})
}
]
}
- oder Strings, die zu Listen umgeformt werden können
{
"products: [
{
"product_id": "1",
"prices": zipMap({
"type": "REDUCED;PURCHASE"!?split(';'),
"price": "9.99;12.99"!?split(';')
}),
"images": zipMap({
"url": "http://url.to.image?1;http://url.to.image?2"!?split(';'),
"name": "image 1;image 2"!?split(';')
})
}
]
}
Indexbasiert heist in diesem Fall, das für jedes Element der Liste eine neue Map ertellt wird.
...
"prices": zipMap({
"type": ["REDUCED","PURCHASE"],
"price": [9.99,12.99]
}),
...
Hier wird also eine Liste aus 2 Maps erstellt, wobei jeder Stelle der angegebenen Sequenzen die entsprechende Stelle der anderen zugeordnet wird.
[0] REDUCED -> 9.99
[1] PURCHASE -> 12.99
Als JSON sieht die Ausgabe dann so aus:
"prices": [
{
"type": "REDUCED",
"price": 9.99
},
{
"type": "PURCHASE",
"price": 12.99
}
],
Zusätzlich gibt es noch einige Optionen:
Konstanten
Wird keine Sequenz, sondern ein Einzelwert angegeben, wird dieser in jeder Map gleich gesetzt:
...
"prices": zipMap({
"type": ["REDUCED","PURCHASE","SALE"],
"price": 9.99
}),
...
"prices": [
{
"type": "REDUCED",
"price": 9.99
},
{
"type": "PURCHASE",
"price": 9.99
},
{
"type": "SALE",
"price": 9.99
}
],
Die skipNull Option
Die skipNull
Option erwartet eine Liste von Keys und legt damit die Einträge fest, die nicht ausgegeben werden sollen, falls ein Wert nicht vorhanden ist.
...
"prices": zipMap({
"type": ["REDUCED","PURCHASE","SALE"],
"price": [9.99,12.99]
}, {"skipNull": ["price"]}),
...
"prices": [
{
"type": "REDUCED",
"price": 9.99
},
{
"type": "PURCHASE",
"price": 12.99
}
],
Die Map mit dem SALE
type wird nicht ausgegeben, da der Wert für price
nicht vorhanden ist. price
ist sozusagen der Wert, der vorhanden sein muss, damit die Map erstellt wird. Der skipNull
Parameter kann auch, je nach use case, mehrere Keys beinhalten.
Die defaultNull Option
Die defaultNull
Option legt den Wert fest, der gesetzt werden soll, wenn kein Wert vorhanden ist. Es kann ein Einzelwert festgelegt werden, der für alle fehlenden Werte eingesetzt wird, oder es kann ein Objekt angegeben werden, das das für einen Key einen speziellen Wert setzt.
...
"prices": zipMap({
"type": ["REDUCED","PURCHASE","SALE"],
"currency": ["EUR","USD"],
"price": [9.99,12.99]
}, {"defaultNull": 10.99}),
...
"prices": [
{
"type": "REDUCED",
"currency": "EUR",
"price": 9.99
},
{
"type": "PURCHASE",
"currency": "USD",
"price": 12.99
},
{
"type": "SALE",
"currency": 10.99,
"price": 10.99
}
],
Da der price
und currency
für den Eintrag SALE
nicht vorhanden ist, wird dieser mit dem defaultNull
gesetzt. Vermutlich würde der Request mit einer Dezimalzahl im currency
Feld fehlschlagen.
...
"prices": zipMap({
"type": ["REDUCED","PURCHASE","SALE"],
"currency": ["EUR","USD"],
"price": [9.99,12.99]
}, {"defaultNull": {'currency': "EUR", 'price': 10.99}}),
...
"prices": [
{
"type": "REDUCED",
"currency": "EUR",
"price": 9.99
},
{
"type": "PURCHASE",
"currency": "USD",
"price": 12.99
},
{
"type": "SALE",
"currency": "EUR",
"price": 10.99
}
],
In diesem Beispiel wurden nun für price
und currency
jeweils einzelne Werte festgelegt, die dem Key zugeordnet werden, falls Werte nicht vorhanden sind.
defaultNull
und skipNull
können auch kombiniert werden:
...
"prices": zipMap({
"type": ["REDUCED","PURCHASE","SALE"],
"currency": ["EUR","USD"],
"price": [9.99]
}, {"skipNull": ['currency'], "defaultNull": {'price': 10.99}}),
...
"prices": [
{
"type": "REDUCED",
"currency": "EUR",
"price": 9.99
},
{
"type": "PURCHASE",
"currency": "USD",
"price": 10.99
}
],
Hier wird das Element SALE
komplett ausgelassen und nicht ausgegeben, price
aber mit dem defaultNull
Wert gesetzt.
Die skipEmpty Option
Die skipEmpty
Option legt fest, ob Maps, die keine oder nur leere Werte beinhaltet, überhaupt ausgegeben werden sollen. Im Beispiel werden leere Sequenzen an die zipMap() Funktion übergeben.
...
"prices": zipMap({
"type": [],
"price": []
}, {"skipEmpty": true}),
...
"prices": [],
Ohne skipEmpty
:
"prices": [
{
"type": "",
"price": ""
}
],
skipEmpty
ist dann sinnvoll, wenn die toJSON()
Funktion mit der Option skipEmpty=true
verwendet wird. skipEmpty
entfernt bei der JSON Erstellung alle Einträge, deren Werte leer sind. Das beinhaltet null
, ""
, leere Listen []
und Objekte {}
(im Gegensatz zu skipNull
, was nur null
entfernt). Damit lassen sich valide und 'saubere' JSON Dateien erstellen. Gerade bei JSON's mit sehr vielen optionalen Daten können so alle Spalten ohne weitere Prüfungen einfach den JSON-Keys zugewiesen werden und beim finalen toJSON()
'aufgeräumt' werden.
Was ist an toJSON() und newMap() besser, wenn das Ergebnis doch das gleiche ist?
Der Vorteil von toJSON()
mit newMap()
liegt darin, dass man "echte" Objekte erzeugt und diese garantieren, dass valides JSON heraus kommt, wenn die Objekte korrekt befüllt werden.
${newMap()}
erzeugt ein sog. Map Objekt (Schlüssel-Wert Paare). Bietet Methoden zum Hinzufügen, Setzen und Entfernen von Feldern. Der Grund für diese Funktion ist, dass Freemarker keine Manipulation von Maps erlaubt. Eine mit newMap() erstellte Map, kann allerdings verändert werden.
${toJSON()}
erstellt einen JSON String aus einer Map (die via ${newMap()}
erstellt wurde) oder Listen-Objekt. Der Vorteil im Gegensatz zum manuellen JSON-'zusammenbauen' ist, dass das JSON valide und typsicher ist (d.h. kein Komma zu viel, kein Hochkomma an der falschen Stelle). Ausserdem muss man sich nicht um das korrekte "escaping" mit ?json_string kümmern.
Die Verwendung der row.val() Funktion (im Beispiel row.val("price")
) sorgt dafür, dass Werte entsprechend des hinterlegten Datastoreschemas in den korrekten Typ umgewandelt werden und in die Map geschrieben werden. Damit weiss dann ${toJSON()}
, dass price
eine Dezimalzahl ist und ohne doppelte Anführungstriche in das JSON geschrieben werden muss, und dass das Feld id
ein TEXT / String ist, und der Wert im JSON von doppelten Anführungstrichen umschlossen werden muss.
Und was ist daran jetzt besser?
Ein großer Vorteil ist der frühere Zeitpunkt an dem Fehler auffallen.
Im Fall, dass man den JSON-String manuell zusammenbaut (ohne toJSON()), kann es passieren, dass man ein Komma vergisst und somit kaputtes JSON über die Leitung (API) zur Empfängerseite schickt. Erst die empfangende API kann sich dann über das invalide JSON beschweren. Wenn man "Glück" hat erhält man eine aussagekräftige Fehlermeldung von der API - wenn nicht eine nichtssagende "An error has occured" Fehlermeldung.
Mit ${toJSON()}
, ${newMap()}
und ${zipMap()}
fällt ein Fehler schon früher auf und es wird kein kaputtes JSON über die Leitung geschickt.
Merke: Mit `${toJSON()}` oder `${newMap()}` kann man garantiert valides JSON erzeugen.
Wirft ${toJSON()}
oder ${newMap()}
keinen Fehler, dann kann man sicher sein, dass das generierte JSON zu 100% valide und nicht kaputt ist. Valide bedeutet in dem Zusammenhang, strukturell valide. Inhaltlich kann es natürlich immer noch falsche Daten enthalten, wenn man falsche Daten hineinschreibt.
Einfaches Beispiel, zur Verdeutlichung eines kleinen syntaktischen Fehlers:
${toJSON(newMap({"key": "value}))}
Hier fehlt ein Hochkomma (") nach value. Man erhält sofort beim erstellen (wenn das Freemarker Skript gerendert wird) einen Freemarker-Fehler:
Das Wert-Feld enthält Fehler. Template Parsing-Fehler...
Korrekt wäre:
${toJSON(newMap({"key": "value"}))}
Fazit
D.h. um robusteres und valides JSON zu erstellen, empfehlen wir die Verwendung der Template-Funktionen:
- toJSON()
- newMap()
- zipMap()
- und die
.val()
Funktion, wenn man Spreadsheets verarbeitet, die Schema-Information enthalten (z.B. SearchDatastore Step) - sowie die Helfer-Funktion emptyToNull(), die hilfreich ist, wenn man leere Felder zu
null
machen möchte, um diese dann ggf. aus dem JSON zu entfernen.