Importing JSON values
Note: If you are reading this page for the first time, you
might want to skip directly to the explicit type annotation below as
this is the recommended way of parsing JSON data. The content before
that is here to explain the inner workings of JSON parsing in
liquidsoap
.
Liquidsoap supports importing JSON values through a special
let
syntax. Using this syntax makes it relatively natural
to parse JSON data in your script while keeping type-safety at runtime.
Here’s an example:
let json.parse v = '{"foo": "abc"}'
print("We parsed a JSON object and got value " ^ v.foo ^ " for attribute foo!")
This prints:
We parsed a JSON object and got value abc for attribute foo!
What happened here is that liquidsoap kept track of the fact that
v
was called with v.foo
and that the result of
that was a string. Then, at runtime, it checks the parsed JSON value
against this type and raises an issue if that did not match. For
instance, the following script:
let json.parse v = '{"foo": 123}'
print("We parsed a JSON object and got value " ^ v.foo ^ " for attribute foo!")
raises the following exception:
Error 14: Uncaught runtime error:
type: json,
message: "Parsing error: json value cannot be parsed as type {foo: string, _}"
Of course, this all seems pretty trivial presented like that but, let’s switch to reading a file instead:
let json.parse v = file.contents("/path/to/file.json")
print("We parsed a JSON object and got value " ^ v.foo ^ " for attribute foo!")
Now, this is getting somewhere! Let’s push it further and parse a
whole package.json
from a typical npm
package:
# Content of package.json is:
# {
# "name": "my_package",
# "version": "1.0.0",
# "scripts": {
# "test": "echo \"Error: no test specified\" && exit 1"
# },
# ...
let json.parse package = file.contents("/path/to/package.json")
name = package.name
version = package.version
test = package.scripts.test
print("This is package " ^ name ^ ", version " ^ version ^ " with test script: " ^ test)
And we get:
This is package my_package, version 1.0.0 with test script: echo "Error: no test specified" && exit 1
This can even be combined with patterns:
let json.parse {
name,
version,scripts = {
test
}file.contents("/path/to/package.json")
} =
print("This is package " ^ name ^ ", version " ^ version ^ " with test script: " ^ test)
Now, this is looking nice!
Explicit type annotation
Explicit type annotation are the recommended way to parse JSON data.
Let’s try a slight variation of the previous script now:
let json.parse {
name,
version,scripts = {
test
}file.contents("/path/to/package.json")
} =
print("This is package #{name}, version #{version} with test script: #{test}")
This returns:
This is package null, version null with test script: null
What? 🤔
This is because, in this script, we only use name
,
version
, etc.. through the interpolation syntax
#{...}
. However, interpolated variables can be anything so
this does not leave enough information to the typing system to know what
type those variables should be and, in this case, we default to
null
.
In order to avoid bad surprises like this, it is usually recommended to add type annotations to your json parsing call to explicitely state what kind of data you are expecting. Let’s add one here:
let json.parse ({
name,
version,scripts = {
test
}
} : {
name: string,
version: string,
scripts: {
test: string
}file.contents("/path/to/package.json")
}) =
print("This is package #{name}, version #{version} with test script: #{test}")
And we get:
This is package my_package, version 1.0.0 with test script: echo "Error: no test specified" && exit 1
Back to normal!
Type syntax
The syntax for type annotation is as follows:
Ground types
string
, int
, float
are parsed
as, resp., a string, an integer or a floating point number. Note that if
your json value contains an integer such as 123
, parsing it
as a floating point number will succeed. Also, if an integer is too big
to be represented as an int
internally, it will be parsed
as a floating point number.
Nullable types
All type annotation can be postfixed with a trailing ?
to denote a nullable value. If a type is nullable, the json
parser will return null
when it cannot parse the value as
the principal type. This is particularly useful when you are not sure of
all the types that you are parsing.
For instance, some npm
packages do not have a
scripts
entry or a test
entry, so you would
parse them as:
let json.parse ({
name,
version,
scripts,
} : {
name: string,
version: string,
scripts: {
test: string?
}?file.contents("/path/to/package.json") }) =
And, later, inspect the returned value to see if it is in fact present. You can do it in several ways:
# Check if the value is defined:
test =
if null.defined(scripts) then
null.get(scripts.test)
else
null ()
end
# Use the ?? syntax:
test = (scripts ?? { test = null() }).test
Tuple types
The type (int * float * string)
tells liquidsoap to
parse a JSON array whose first values are of type:
int
, float
and string
. If any
further values are present in the array, they will be ignored.
For arrays as well as any other structured types, the special
notation _
can be used to denote any type. For instance,
(_ * _ * float)
denotes an JSON array whose first 2
elements can be of any type and its third element is a floating point
number.
Lists
The type [int]
tells liquidsoap to parse a JSON array
where all its values are integers as a list of integers. If you
are not sure if all elements in the array are integers, you can always
use nullable integers: [int?]
Objects
The type {foo: int}
tells liquidsoap to parse a JSON
object as a record with an attribute labelled foo
whose
value is an integer. All other attributes are ignored.
Arbitrary object keys can be parsed using the following syntax:
{"foo bar key" as foo_bar_key: int}
, which tells liquidsoap
to parse a JSON object as a record with an attribute labelled
foo_bar_key
which maps to the attribute
"foo bar key"
from the JSON object.
Associative lists as objects
It can sometimes be useful to parse a JSON object as an associative
list, for instance if you do not know in advance all the possible keys
of an object. In this case, you can use the special type:
[(string * int)] as json.object
. This tells liquidsoap to
parse the JSON object as a list of pairs (string * int)
where string
represents the attribute label and
int
represent the attribute value.
If you are not sure if all the object values are integers you can
always use nullable integers:
[(string * int?)] as json.object
Parsing errors
When parsing fails, a error.json
is raised which can be
caught at runtime:
try
let json.parse ({
status,data = {
track
}
} : {
status: string,
data: {
track: string
}
}) = res
# Do something on success here..
catch err: [error.json] do
# Do something on parse errors here..
end
Example
Here’s a full example. Feel free to refer to
tests/language/json.liq
in the source code for more of
them.
data = '{
"foo": 34.24,
"gni gno": true,
"nested": {
"tuple": [123, 3.14, false],
"list": [44.0, 55, 66.12],
"nullable_list": [12.33, 23, "aabb"],
"object_as_list": {
"foo": 123,
"gni": 456.0,
"gno": 3.14
},"arbitrary object key ✨": true
},"extra": "ignored"
}'
let json.parse ( x : {
foo: float,"gni gno" as gni_gno: bool,
nested: {
tuple: (_ * float),
list: [float],
nullable_list: [int?],
object_as_list: [(string * float)] as json.object,"arbitrary object key ✨" as arbitrary_object_key: bool,
not_present: bool?
}
}) = data
- x : {foo = 34.24,
gni_gno = true,
nested = {
tuple = (null, 3.14),
list = [44., 55., 66.12],
nullable_list = [null, 23, null],
object_as_list = [("foo", 123.), ("gni", 456.0), ("gno", 3.14)],
arbitrary_object_key = true,
not_present = null
} }
JSON5 extension
Liquidsoap supports the JSON5
extension. Parsing of json5
values is enabled with the
following argument:
let json.parse[json5=true] x = ...
If a json5
variable is in scope, you can also simply use
let json.parse[json5] x = ...
Exporting JSON values
Exporting JSON values works similarly to importing, using the
json.stringify
let syntax:
let json.stringify s = x
You can also use type annotation to explicitely state what kind of JSON value you want to render:
let json.stringify s = (x : {foo: int})
The json.stringify
syntax supports the following
arguments:
json5
: allowjson5
representations, in particular infinite andNaN
floating point numbers. Default:false
compact
: output a compact value instead of a human-readable value. Default:false
.
Generic JSON objects
Generic JSON
objects can be manipulated through the
json()
operator. This operator returns an opaque json
variable with methods to add
and remove
attributes:
j = json()
j.add("foo", 1)
j.add("bla", "bar")
j.add("baz", 3.14)
j.add("key_with_methods", "value".{method = 123})
j.add("record", { a = 1, b = "ert"})
j.remove("foo")
let json.stringify s = j
"record": { "b": "ert", "a": 1 }, "key_with_methods": "value", "bla": "bar", "baz": 3.14 }' - s: '{