From bf4cef21c308b716239b733da9e8f24e97d47678 Mon Sep 17 00:00:00 2001 From: Patrick Boettcher Date: Thu, 22 Dec 2016 18:47:31 +0100 Subject: [PATCH] initial commit --- .clang-format | 15 ++ .gitignore | 3 + CMakeLists.txt | 63 +++++++ LICENSE.mit | 22 +++ README.md | 82 ++++++++ app/json-schema-test.cpp | 61 ++++++ app/json-schema-validate.cpp | 58 ++++++ src/json-schema-validator.hpp | 340 ++++++++++++++++++++++++++++++++++ test.sh | 13 ++ 9 files changed, 657 insertions(+) create mode 100644 .clang-format create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE.mit create mode 100644 README.md create mode 100644 app/json-schema-test.cpp create mode 100644 app/json-schema-validate.cpp create mode 100644 src/json-schema-validator.hpp create mode 100755 test.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..647604d --- /dev/null +++ b/.clang-format @@ -0,0 +1,15 @@ +--- +BasedOnStyle: LLVM +AccessModifierOffset: -2 +#AlignConsecutiveAssignments: true +#AlignConsecutiveDeclarations: true +AllowShortFunctionsOnASingleLine: Inline +BreakBeforeBraces: Linux +ColumnLimit: 0 +ConstructorInitializerAllOnOneLineOrOnePerLine: true +IndentWidth: 2 +ObjCBlockIndentWidth: 2 +SpaceAfterCStyleCast: true +TabWidth: 2 +UseTab: ForIndentation +... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b334d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build*/ +*.sw? + diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..27de78b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,63 @@ +project(json-schema-validator CXX) + +cmake_minimum_required(VERSION 3.2) + +# find nlohmann's json.hpp +find_path(NLOHMANN_JSON_DIR + NAMES + json.hpp) + +if(NOT NLOHMANN_JSON_DIR) + message(FATAL_ERROR "please set NLOHMANN_JSON_DIR to a path in which NLohmann's json.hpp can be found.") +endif() + +# create an interface-library for simple linking +add_library(json INTERFACE) +target_include_directories(json + INTERFACE + ${NLOHMANN_JSON_DIR}) + +# and one for the validator +add_library(json-schema-validator INTERFACE) +target_include_directories(json-schema-validator + INTERFACE + ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_compile_options(json-schema-validator + INTERFACE + -Wall -Wextra) # bad, better use something else based on compiler type +target_link_libraries(json-schema-validator + INTERFACE + json) + +# simple json-schema-validator-executable +add_executable(json-schema-validate app/json-schema-validate.cpp) +target_link_libraries(json-schema-validate json-schema-validator) + +# json-schema-validator-tester +add_executable(json-schema-test app/json-schema-test.cpp) +target_link_libraries(json-schema-test json-schema-validator) + +enable_testing() + +# find schema-test-suite +find_path(JSON_SCHEMA_TEST_SUITE_PATH + NAMES + tests/draft4) + +if(JSON_SCHEMA_TEST_SUITE_PATH) + # create tests foreach test-file + file(GLOB_RECURSE TEST_FILES ${JSON_SCHEMA_TEST_SUITE_PATH}/tests/draft4/*.json) + + foreach(TEST_FILE ${TEST_FILES}) + get_filename_component(TEST_NAME ${TEST_FILE} NAME_WE) + add_test( + NAME ${TEST_NAME} + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/test.sh $ ${TEST_FILE} + ) + endforeach() +else() + message(STATUS "Please test JSON_SCHEMA_TEST_SUITE_PATH to a path in which you've cloned JSON-Schema-Test-Suite (github.com/json-schema-org/JSON-Schema-Test-Suite).") +endif() + + + diff --git a/LICENSE.mit b/LICENSE.mit new file mode 100644 index 0000000..f660b34 --- /dev/null +++ b/LICENSE.mit @@ -0,0 +1,22 @@ +Modern C++ JSON schema validator is licensed under the MIT License +: + +Copyright (c) 2016 Patrick Boettcher + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3f6bec --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Modern C++ JSON schema validator + +# What is it? + +This is a C++ header-only library for validating JSON documents based on a +[JSON Schema](http://json-schema.org/) which itself should validate with +[draft-4 of JSON Schema Validation](http://json-schema.org/schema). + +First a disclaimer: *Everything here should be considered work in progress and +contributions or hints or discussions are welcome.* + +Niels Lohmann et al develop a great JSON parser for C++ called [JSON for Modern +C++](https://github.com/nlohmann/json). This validator is based on this +library, hence the name. + +The name is for the moment purely marketing, because there is, IMHO, not much +modern C++ inside. + +External documentation is missing as well. However the API of the validator +will be rather simple. + +# How to use + +## Build + +```Bash +git clone https://github.com/pboettch/json-schema-validator.git +cd json-schema-validator +mkdir build +cd build +cmake .. \ + -DNLOHMANN_JSON_DIR= \ + -DJSON_SCHEMA_TEST_SUITE_PATH= # optional +make +``` + +## Code + +See also `app/json-schema-validate.cpp`. + +```C++ +#include "json-schema-validator.hpp" + +using nlohmann::json; +using nlohmann::json_validator; + +int main(void) +{ + json schema, document; + + /* fill in the schema */ + /* fill in the document */ + + json_validator validator; + + try { + validator.validate(document, scheam); + } catch (const std::out_of_range &e) { + std::cerr << "Validation failed, here is why: " << e.what() << "\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} +``` + +# Conformity + +There is an application which can be used for testing the validator with the +[JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite). + +Currently more 150 tests are still failing, because simply not all keyword and +their functionalities have been implemented. Some of the missing feature will +require a rework. + +# Additional features + +## Default value population + +For my use case I need something to populate default values into the JSON +instance of properties which are not set by the user. + +This feature can be enable by setting the `default_value_insertion` to true. diff --git a/app/json-schema-test.cpp b/app/json-schema-test.cpp new file mode 100644 index 0000000..c7cb7a6 --- /dev/null +++ b/app/json-schema-test.cpp @@ -0,0 +1,61 @@ +#include "json-schema-validator.hpp" + +using nlohmann::json; +using nlohmann::json_validator; + +int main(void) +{ + json validation; + + try { + std::cin >> validation; + } catch (std::exception &e) { + std::cerr << e.what() << "\n"; + return EXIT_FAILURE; + } + + json_validator validator; + + size_t failed = 0, + total = 0; + + for (auto &test_group : validation) { + + std::cerr << "Testing Group " << test_group["description"] << "\n"; + + const auto &schema = test_group["schema"]; + + for (auto &test_case : test_group["tests"]) { + std::cerr << " Testing Case " << test_case["description"] << "\n"; + + bool valid = true; + + try { + validator.validate(test_case["data"], schema); + } catch (const std::out_of_range &e) { + valid = false; + std::cerr << " Test Case Exception (out of range): " << e.what() << "\n"; + } catch (const std::invalid_argument &e) { + valid = false; + std::cerr << " Test Case Exception (invalid argument): " << e.what() << "\n"; + } catch (const std::logic_error &e) { + valid = !test_case["valid"]; /* force test-case failure */ + std::cerr << " Not yet implemented: " << e.what() << "\n"; + } + + if (valid == test_case["valid"]) + std::cerr << " --> Test Case exited with " << valid << " as expected.\n"; + else { + failed++; + std::cerr << " --> Test Case exited with " << valid << " NOT expected.\n"; + } + total++; + std::cerr << "\n"; + } + std::cerr << "-------------\n"; + } + + std::cerr << (total - failed) << " of " << total << " have succeeded - " << failed << " failed\n"; + + return failed; +} diff --git a/app/json-schema-validate.cpp b/app/json-schema-validate.cpp new file mode 100644 index 0000000..3cd69a4 --- /dev/null +++ b/app/json-schema-validate.cpp @@ -0,0 +1,58 @@ +#include "json-schema-validator.hpp" + +#include + +#include + +using nlohmann::json; +using nlohmann::json_validator; + +static void usage(const char *name) +{ + std::cerr << "Usage: " << name << " < \n"; + exit(EXIT_FAILURE); +} + +int main(int argc, char *argv[]) +{ + if (argc != 2) + usage(argv[0]); + + std::fstream f(argv[1]); + if (!f.good()) { + std::cerr << "could not open " << argv[1] << " for reading\n"; + usage(argv[0]); + } + + json schema; + + try { + f >> schema; + } catch (std::exception &e) { + std::cerr << e.what() << " at " << f.tellp() << "\n"; + return EXIT_FAILURE; + } + + json document; + + try { + std::cin >> document; + } catch (std::exception &e) { + std::cerr << e.what() << " at " << f.tellp() << "\n"; + return EXIT_FAILURE; + } + + json_validator validator; + + try { + validator.validate(document, schema); + } catch (std::exception &e) { + std::cerr << "schema validation failed\n"; + std::cerr << e.what() << "\n"; + return EXIT_FAILURE; + } + + std::cerr << std::setw(2) << document << "\n"; + + return EXIT_SUCCESS; +} diff --git a/src/json-schema-validator.hpp b/src/json-schema-validator.hpp new file mode 100644 index 0000000..f207b9e --- /dev/null +++ b/src/json-schema-validator.hpp @@ -0,0 +1,340 @@ +#ifndef NLOHMANN_JSON_VALIDATOR_HPP__ +#define NLOHMANN_JSON_VALIDATOR_HPP__ + +#include + +#include + +// make yourself a home - welcome to nlohmann's namespace +namespace nlohmann +{ + +class json_validator +{ + // insert default values items into object + // if the key is not present before checking their + // validity in regards to their schema + // + // breaks JSON-Schema-Test-Suite if true + // *PARTIALLY IMPLEMENTED* only for properties of objects + bool default_value_insertion = false; + + // recursively insert default values and create parent objects if + // they would be empty + // + // breaks JSON-Schema-Test-Suite if true + // *NOT YET IMPLEMENTED* -> maybe the same as the above option, need more thoughts + bool recursive_default_value_insertion = false; + + void not_yet_implemented(const json &schema, const std::string &field, const std::string &type) + { + if (schema.find(field) != schema.end()) + throw std::logic_error(field + " for " + type + " is not yet implemented"); + } + + void validate_type(const json &schema, const std::string &expected_type, const std::string &name) + { + const auto &type_it = schema.find("type"); + if (type_it == schema.end()) + /* TODO guess type for more safety, + * TODO use definitions + * TODO valid by not being defined? FIXME not clear - there are + * schema-test case which are not specifying a type */ + return; + + const auto &type_instance = type_it.value(); + + // any of the types in this array + if (type_instance.type() == json::value_t::array) { + if (std::find(type_instance.begin(), + type_instance.end(), + expected_type) != type_instance.end()) + return; + + std::ostringstream s; + s << expected_type << " is not any of " << type_instance << " for " << name; + throw std::invalid_argument(s.str()); + + } else { // type_instance is a string + if (type_instance == expected_type) + return; + + throw std::invalid_argument(type_instance.get() + " is not a " + expected_type + " for " + name); + } + } + + void validate_enum(json &instance, const json &schema, const std::string &name) + { + const auto &enum_value = schema.find("enum"); + if (enum_value == schema.end()) + return; + + if (std::find(enum_value.value().begin(), enum_value.value().end(), instance) != enum_value.value().end()) + return; + + std::ostringstream s; + s << "invalid enum-value '" << instance << "' " + << "for instance '" << name << "'. Candidates are " << enum_value.value() << "."; + + throw std::invalid_argument(s.str()); + } + + void validate_string(json &instance, const json &schema, const std::string &name) + { + // possibile but unhanled keywords + not_yet_implemented(schema, "format", "string"); + not_yet_implemented(schema, "pattern", "string"); + + validate_type(schema, "string", name); + + auto attr = schema.find("minLength"); + if (attr != schema.end()) + if (instance.get().size() < attr.value()) { + std::ostringstream s; + s << "'" << name << "' of value '" << instance << "' is too short as per minLength (" + << attr.value() << ")"; + throw std::out_of_range(s.str()); + } + + attr = schema.find("maxLength"); + if (attr != schema.end()) + if (instance.get().size() > attr.value()) { + std::ostringstream s; + s << "'" << name << "' of value '" << instance << "' is too long as per maxLength (" + << attr.value() << ")"; + throw std::out_of_range(s.str()); + } + } + + void validate_boolean(json & /*instance*/, const json &schema, const std::string &name) + { + validate_type(schema, "boolean", name); + } + + void validate_numeric(json &instance, const json &schema, const std::string &name) + { + double value = instance; + + const auto &multipleOf = schema.find("multipleOf"); + if (multipleOf != schema.end()) { + double rem = fmod(value, multipleOf.value()); + if (rem != 0.0) + throw std::out_of_range(name + " is not a multiple ..."); + } + + const auto &maximum = schema.find("maximum"); + if (maximum != schema.end()) { + double maxi = maximum.value(); + auto ex = std::out_of_range(name + " exceeds maximum of ..."); + if (schema.find("exclusiveMaximum") != schema.end()) { + if (value >= maxi) + throw ex; + } else { + if (value > maxi) + throw ex; + } + } + + const auto &minimum = schema.find("minimum"); + if (minimum != schema.end()) { + double mini = minimum.value(); + auto ex = std::out_of_range(name + " exceeds minimum of ..."); + if (schema.find("exclusiveMinimum") != schema.end()) { + if (value <= mini) + throw ex; + } else { + if (value < mini) + throw ex; + } + } + } + + void validate_integer(json &instance, const json &schema, const std::string &name) + { + validate_type(schema, "integer", name); + validate_numeric(instance, schema, name); + } + + void validate_unsigned(json &instance, const json &schema, const std::string &name) + { + validate_type(schema, "integer", name); + validate_numeric(instance, schema, name); + } + + void validate_float(json &instance, const json &schema, const std::string &name) + { + validate_type(schema, "number", name); + validate_numeric(instance, schema, name); + } + + void validate_null(json & /*instance*/, const json &schema, const std::string &name) + { + validate_type(schema, "null", name); + } + + void validate_array(json & /*instance*/, const json &schema, const std::string &name) + { + not_yet_implemented(schema, "maxItems", "array"); + not_yet_implemented(schema, "minItems", "array"); + not_yet_implemented(schema, "uniqueItems", "array"); + not_yet_implemented(schema, "items", "array"); + not_yet_implemented(schema, "additionalItems", "array"); + + validate_type(schema, "array", name); + } + + void validate_object(json &instance, const json &schema, const std::string &name) + { + not_yet_implemented(schema, "maxProperties", "object"); + not_yet_implemented(schema, "minProperties", "object"); + not_yet_implemented(schema, "dependencies", "object"); + + validate_type(schema, "object", name); + + json properties = {}; + if (schema.find("properties") != schema.end()) + properties = schema["properties"]; + + // check for default values of properties + // and insert them into this object, if they don't exists + // works only for object properties for the moment + if (default_value_insertion) + for (auto it = properties.begin(); it != properties.end(); ++it) { + + const auto &default_value = it.value().find("default"); + if (default_value == it.value().end()) + continue; /* no default value -> continue */ + + if (instance.find(it.key()) != instance.end()) + continue; /* value is present */ + + /* create element from default value */ + instance[it.key()] = default_value.value(); + } + + // additionalProperties + enum { + True, + False, + Object + } additionalProperties = True; + + const auto &additionalPropertiesVal = schema.find("additionalProperties"); + if (additionalPropertiesVal != schema.end()) { + if (additionalPropertiesVal.value().type() == json::value_t::boolean) + additionalProperties = additionalPropertiesVal.value() == true ? True : False; + else + additionalProperties = Object; + } + + json patternProperties = {}; + if (schema.find("patternProperties") != schema.end()) + patternProperties = schema["patternProperties"]; + + // check all elements in object + for (auto child = instance.begin(); child != instance.end(); ++child) { + std::string child_name = name + "." + child.key(); + + // is this a property which is described in the schema + const auto &object_prop = properties.find(child.key()); + if (object_prop != properties.end()) { + // validate the element with its schema + validate(child.value(), object_prop.value(), child_name); + continue; + } + + bool patternProperties_has_matched = false; + for (auto pp = patternProperties.begin(); + pp != patternProperties.end(); ++pp) { + std::regex re(pp.key(), std::regex::ECMAScript); + + if (std::regex_search(child.key(), re)) { + validate(child.value(), pp.value(), child_name); + patternProperties_has_matched = true; + } + } + if (patternProperties_has_matched) + continue; + + switch (additionalProperties) { + case True: + break; + + case Object: + validate(child.value(), additionalPropertiesVal.value(), child_name); + break; + + case False: + throw std::invalid_argument("unknown property '" + child.key() + "' in object '" + name + "'"); + break; + }; + } + + // check for required elements which are not present + const auto &required = schema.find("required"); + if (required == schema.end()) + return; + + for (const auto &element : required.value()) { + if (instance.find(element) == instance.end()) { + throw std::invalid_argument("required element '" + element.get() + + "' not found in object '" + name + "'"); + } + } + } + +public: + void validate(json &instance, const json &schema, const std::string &name = "root") + { + not_yet_implemented(schema, "allOf", "all"); + not_yet_implemented(schema, "anyOf", "all"); + not_yet_implemented(schema, "oneOf", "all"); + not_yet_implemented(schema, "not", "all"); + not_yet_implemented(schema, "definitions", "all"); + not_yet_implemented(schema, "$ref", "all"); + + validate_enum(instance, schema, name); + + switch (instance.type()) { + case json::value_t::object: + validate_object(instance, schema, name); + break; + + case json::value_t::array: + validate_array(instance, schema, name); + break; + + case json::value_t::string: + validate_string(instance, schema, name); + break; + + case json::value_t::number_unsigned: + validate_unsigned(instance, schema, name); + break; + + case json::value_t::number_integer: + validate_integer(instance, schema, name); + break; + + case json::value_t::number_float: + validate_float(instance, schema, name); + break; + + case json::value_t::boolean: + validate_boolean(instance, schema, name); + break; + + case json::value_t::null: + validate_null(instance, schema, name); + break; + + default: + throw std::out_of_range("type '" + schema["type"].get() + + "' has no validator yet"); + break; + } + } +}; +} + +#endif /* NLOHMANN_JSON_VALIDATOR_HPP__ */ diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..acd5157 --- /dev/null +++ b/test.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +if [ ! -x "$1" ] +then + exit 1 +fi + +if [ ! -e "$2" ] +then + exit 1 +fi + +$1 < $2