JSON Voorhees
Killer JSON for C++
Serialization Builder DSL

Most applications tend to have a lot of structure types.

While it is possible to write an extractor and serializer (or adapter) for each type, this can get a little bit tedious. Beyond that, it is very difficult to look at the contents of adapter code and discover what the JSON might actually look like. The builder DSL is meant to solve these issues by providing a convenient way to describe conversion operations for your C++ types.

At the end of the day, the goal is to take some C++ structures like this:

struct person
{
std::string first_name;
std::string last_name;
int age;
std::string role;
};
struct company
{
std::string name;
bool certified;
std::vector<person> employees;
std::list<person> candidates;
};

...and easily convert it to an from a JSON representation that looks like this:

{
"name": "Paul's Construction",
"certified": false,
"employees": [
{
"first_name": "Bob",
"last_name": "Builder",
"age": 29
},
{
"first_name": "James",
"last_name": "Johnson",
"age": 38,
"role": "Foreman"
}
],
"candidates": [
{
"firstname": "Adam",
"lastname": "Ant"
}
]
}

To define a formats for this person type using the serialization builder DSL, you would say:

.type<person>()
.member("first_name", &person::first_name)
.alternate_name("firstname")
.member("last_name", &person::last_name)
.alternate_name("lastname")
.member("age", &person::age)
.until({ 6,1 })
.default_value(21)
.default_on_null()
.check_input([] (int value) { if (value < 0) throw std::logic_error("Age must be positive."); })
.member("role", &person::role)
.since({ 2,0 })
.default_value("Builder")
.type<company>()
.member("name", &company::name)
.member("certified", &company::certified)
.member("employees", &company::employees)
.member("candidates", &company::candidates)
.register_containers<company, std::vector, std::list>()
.check_references(jsonv::formats::defaults())
;

Reference

The DSL is made up of three major parts:

  1. formats – modifies a jsonv::formats object by adding new type adapters to it
  2. type – modifies the behavior of a jsonv::adapter by adding new members to it
  3. member – modifies an individual member inside of a specific type

Each successive function call transforms your context. Narrowing calls make your context more specific; for example, calling type from a formats context allows you to modify a specific type. Widening calls make the context less specific and are always available; for example, when in the member context, you can still call type from the formats context to specify a new type.

Formats Context

Commands in this section modify the behavior of the underlying jsonv::formats object.

Level

check_references

  • check_references(formats)
  • check_references(formats, std::string name)
  • check_references(formats::list)
  • check_references(formats::list, std::string name)
  • check_references()
  • check_references(std::string name)

Tests that every type referenced by the members of the output of the DSL have an extractor and a serializer. The provided formats is used to draw extra types from (a common value is jsonv::formats::defaults). In other words, it asks the question: If the formats from this DSL was combined with these other formats, could all of the types be encoded and decoded?

This does not mutate the DSL in any way. On successful verification, it will appear that nothing happened. If the verification is not successful, an exception will be thrown with the offending types in the message. For example:

There are 2 types referenced that the formats do not know how to serialize:
- date_type (referenced by: name_space::foo, other::name::space::bar)
- tree

If name is provided, the value will be output to the error message on failure. This can be useful if you have multiple check_references statements and wish to more easily determine the failing formats combination from the error message alone.

Note
This is evaluated immediately, so it is best to call this function as the very last step in the DSL.
.check_references(jsonv::formats::defaults())

reference_type

  • reference_type(std::type_index type)
  • reference_type(std::type_index type, std::type_index from)

Explicitly add a reference to the provided type in the DSL. If from is provided, also add a back reference for tracking purposes. The from field is useful for tracking why the type is referenced.

Type references are used in check_references to both check and generate error messages if the formats the DSL is building cannot fully create and extract JSON values. You do not usually have to call this, as each call to member calls this automatically.

.reference_type(std::type_index(typeid(int)), std::type_index(typeid(my_type)))
.reference_type(std::type_index(typeid(my_type))

register_adapter

  • register_adapter(const adapter*)
  • register_adapter(std::shared_ptr<const adapter>)

Register an arbitrary adapter with the formats we are currently building. This is useful for integrating with type adapters that do not (or can not) use the DSL.

.register_adapter(my_type::get_adapter())

register_optional

  • register_optional<TOptional>()

Similar to register_adapter, but automatically create an optional_adapter<TOptional> to store.

.register_optional<std::optional<int>>()
.register_optional<boost::optional<double>>()

register_container

  • register_container<TContainer>()

Similar to register_adapter, but automatically create a container_adapter<TContainer> to store.

.register_container<std::vector<int>>()
.register_container<std::list<std::string>>()

register_containers

  • register_containers<T, template <T, ...>... TTContainer>

Convenience function for calling register_container for multiple containers with the same value_type. Unfortunately, it only supports varying the first template parameter of the TTContainer types, so if you wish to do something like vary the allocator, you will have to either call register_container multiple times or use a template alias.

.register_containers<int, std::list, std::deque>()
.register_containers<double, std::vector, std::set>()
Note
Not supported in MSVC 14 (CTP 5).

register_wrapper

  • register_wrapper<TWrapper>()

Similar to register_adapter, but automatically create an wrapper_adapter<TWrapper> to store.

.register_optional<std::optional<int>>()
.register_optional<boost::optional<double>>()

enum_type

  • enum_type<TEnum>(std::string name, std::initializer_list<std::pair<TEnum, jsonv::value>>)
  • enum_type_icase<TEnum>(std::string name, std::initializer_list<std::pair<TEnum, jsonv::value>>)

Create an adapter for the TEnum type with a mapping of C++ values to JSON values and vice versa. The most common use of this is to map enum values in C++ to string representations in JSON. TEnum is not restricted to types which are enum, but can be anything which you would like to restrict to a limited subset of possible values. Likewise, JSON representations are not restricted to being of kind::string.

The sibling function enum_type_icase will create an adapter which uses case-insensitive checking when converting to C++ values in extract.

.enum_type<ring>("ring",
{
{ ring::fire, "fire" },
{ ring::wind, "wind" },
{ ring::earth, "earth" },
{ ring::water, "water" },
{ ring::heart, "heart" }, // "heart" is preferred for to_json
{ ring::heart, "useless" }, // "useless" is interpreted as ring::heart in extract
{ ring::fire, 1 }, // the JSON value 1 will also be interpreted as ring::fire in extract
{ ring::ussr, "wind" }, // old C++ value ring::ussr will get output as "wind"
}
)
.enum_type_icase<int>("integer",
{
{ 0, "zero" },
{ 0, "naught" },
{ 1, "one" },
{ 2, "two" },
{ 3, "three" },
}
)
See also
enum_adapter

polymorphic_type

  • polymorphic_type<<TPointer>(std::string discrimination_key);

Create an adapter for the TPointer type (usually std::shared_ptr or std::unique_ptr) that knows how to serialize and deserialize one or more types that can be polymorphically represented by TPointer, i.e. derived types. It uses a discrimination key to determine which concrete type should be instantiated when extracting values from json.

.polymorphic_type<std::unique_ptr<base>>("type")
.subtype<derived_1>("derived_1")
.subtype<derived_2>("derived_2", keyed_subtype_action::check)
.subtype<derived_3>("derived_3", keyed_subtype_action::insert);

The keyed_subtype_action can be used to configure the adapter to make sure that the discrimination key was correctly serialized (keyed_subtype_action::check) or to insert the discrimination key for the underlying type so that the underlying type doesn't need to do that itself (keyed_subtype_action::insert). The default is to do nothing (keyed_subtype_action::none).

extend

Extend the formats_builder with the provided func by passing the current builder to it. This provides a more convenient way to call helper functions.

foo(builder);
bar(builder);
baz(builder);

This can be done equivalently with:

.extend(foo)
.extend(bar)
.extend(baz)

on_duplicate_type

  • on_duplicate_type(on_duplicate_type_action action);

Set what action to take when attempting to register an adapter, but there is already an adapter for that type in the formats. The default is to throw a duplicate_type_error exception (duplicate_type_action::exception), but the formats_builder can also be configured to ignore the duplicate (duplicate_type_action::ignore), or to replace the existing adapter with the new one (duplicate_type_action::replace). This is useful when calling multiple extend methods that may add common types to the formats_builder.

Narrowing

type<T>

Create an adapter for type T and begin building the members for it. If func is provided, it will be called with the adapter_builder<T> this call to type creates, which can be used for creating common extension functions.

.type<my_type>()
.member(...)
.
.
.

Type Context

Commands in this section modify the behavior of the jsonv::adapter for a particular type.

Level

pre_extract

  • pre_extract(std::function<void (const extraction_context& context, const value& from)> perform)

Call the given perform function during the extract operation, but before performing any extraction. This can be called multiple times – all functions will be called in the order they are provided.

post_extract

Call the given perform function after the extract operation. All functions will be called in the order they are provided. This allows validation methods to be called on the extracted object as part of extraction. Postprocessing functions are allowed to mutate the extracted object.

type_default_on_null

  • type_default_on_null()
  • type_default_on_null(bool on)

If the JSON value null is in the input, should this type take on some default? This should be used with serialization_builder_dsl_ref_type_level_type_default_value type_default_value.

serialization_builder_dsl_ref_type_level_type_default_value

  • type_default_value(T value)
  • type_default_value(std::function<T (const extraction_context& context)>)

What value should be used to create the default for this type?

.type<my_type>()
.type_default_on_null()
.type_default_value(my_type("default"))

on_extract_extra_keys

  • on_extract_extra_keys(std::function<void (const extraction_context& context, const value& from, std::set<std::string> extra_keys)> action )

When extracting, perform some action if extra keys are provided. By default, extra keys are usually simply ignored, so this is useful if you wish to throw an exception (or anything you want).

.type<my_type>()
.member("x", &my_type::x)
.member("y", &my_type::y)
.on_extract_extra_keys([] (const extraction_context&, const value&, std::set<std::string> extra_keys)
{
throw extracted_extra_keys("my_type", std::move(extra_keys));
}
)

There is a convenience function named throw_extra_keys_extraction_error which does this for you.

.type<my_type>()
.member("x", &my_type::x)
.member("y", &my_type::y)
.on_extract_extra_keys(jsonv::throw_extra_keys_extraction_error)

Narrowing

member

  • member(std::string name, TMember T::*selector)
  • member(std::string name, const TMember& (*access)(const T&), void (*mutate)(T&, TMember&&))
  • member(std::string name, const TMember& (T::*access)() const, TMember& (T::*mutable_access)())
  • member(std::string name, const TMember& (T::*access)() const, void (T::*mutate)(TMember))
  • member(std::string name, const TMember& (T::*access)() const, void (T::*mutate)(TMember&&))

Adds a member to the type we are currently building. By default, the member will be serialized with the key of the given name and the extractor will search for the given name. If you wish to change properties of this field, use the Member Context.

.type<my_type>()
.member("x", &my_type::x)
.member("y", &my_type::y)
.member("thing", &my_type::get_thing, &my_type::set_thing)

Member Context

Commands in this section modify the behavior of a particular member. Here, T refers to the containing type (the one we are adding a member to) and TMember refers to the type of the member we are modifying.

Level

after

  • after(version)

Only serialize this member if the serialization_context::version is not version::empty and is greater than or equal to the provided version.

alternate_name

  • alternate_name(std::string name)

Provide an alternate name to search for when extracting this member. If a user provides values for multiple names, preference is given to names earlier in the list, starting with the original given name.

before

  • before(version)

Only serialize this member if the serialization_context::version is not version::empty and is less than or equal to the provided version.

check_input

  • check_input(std::function<void (const TMember&)> check)
  • check_input(std::function<bool (const TMember&)> check, std::function<void (const TMember&)> thrower)
  • check_input(std::function<bool (const TMember&)> check, TException ex)

Checks the extracted value with the given check function. In the first form, you are expected to throw inside the function. In the latter forms, the second parameter will be invoked (in the case of thrower) or thrown directly (in the case of ex).

.member("x", &my_type::x)
.check_input([] (int x) { if (x < 0) throw std::logic_error("x must be greater than 0"); })
.check_input([] (int x) { return x < 100; }, [] (int x) { throw exceptions::less_than(100, x); })
.check_input([] (int x) { return x % 2 == 0; }, std::logic_error("x must be divisible by 2"))

default_value

  • default_value(TMember value)
  • default_value(std::function<TMember (const extraction_context&, const value&)> create)

Provide a default value for this member if no key is found when extracting. You can use the function implementation to synthesize the key however you want.

.member("x", &my_type::x)
.default_value(10)

default_on_null

  • default_on_null()
  • default_on_null(bool on)

If the value associated with this key is kind::null, should that be treated as the default value? This option is only considered if a default_value default_value was provided.

encode_if

Only serialize this member if the check function returns true.

since

  • since(version)

Only serialize this member if the serialization_context::version is not version::empty and is greater than the provided version.

until

  • until(version)

Only serialize this member if the serialization_context::version is not version::empty and is less than the provided version.