diff --git a/include/cgimap/api06/changeset_upload/osmchange_json_input_format.hpp b/include/cgimap/api06/changeset_upload/osmchange_json_input_format.hpp new file mode 100644 index 00000000..70a9140e --- /dev/null +++ b/include/cgimap/api06/changeset_upload/osmchange_json_input_format.hpp @@ -0,0 +1,325 @@ +/** + * SPDX-License-Identifier: GPL-2.0-only + * + * This file is part of openstreetmap-cgimap (https://github.com/zerebubuth/openstreetmap-cgimap/). + * + * Copyright (C) 2009-2023 by the CGImap developer community. + * For a full list of authors see the git log. + */ + +#ifndef OSMCHANGE_JSON_INPUT_FORMAT_HPP +#define OSMCHANGE_JSON_INPUT_FORMAT_HPP + +#include "cgimap/api06/changeset_upload/node.hpp" +#include "cgimap/api06/changeset_upload/osmobject.hpp" +#include "cgimap/api06/changeset_upload/parser_callback.hpp" +#include "cgimap/api06/changeset_upload/relation.hpp" +#include "cgimap/api06/changeset_upload/way.hpp" +#include "cgimap/types.hpp" + +#include "sjparser/sjparser.h" + +#include + +#include +#include +#include +#include +#include + + +namespace api06 { + +using SJParser::Array; +using SJParser::Member; +using SJParser::Object; +using SJParser::Parser; +using SJParser::Presence; +using SJParser::SArray; +using SJParser::SAutoObject; +using SJParser::SMap; +using SJParser::Value; +using SJParser::Reaction; +using SJParser::ObjectOptions; + +using std::placeholders::_1; + +class OSMChangeJSONParserFormat { + + static auto getMemberParser() { + return SAutoObject{std::tuple{Member{"type", Value{}}, + Member{"ref", Value{}}, + Member{"role", Value{}, Presence::Optional, ""}}, + ObjectOptions{Reaction::Ignore} + }; + } + + template + static auto getElementsParser(ElementParserCallback element_parser_callback = nullptr) { + return Object{ + std::tuple{ + Member{"type", Value{}}, + Member{"action", Value{}}, + Member{"if-unused", Value{}, Presence::Optional, false}, + Member{"id", Value{}}, + Member{"lat", Value{}, Presence::Optional}, + Member{"lon", Value{}, Presence::Optional}, + Member{"version", Value{}, Presence::Optional}, + Member{"changeset", Value{}}, + Member{"tags", SMap{Value{}}, Presence::Optional}, + Member{"nodes", SArray{Value{}}, Presence::Optional}, + Member{"members", SArray{getMemberParser()}, Presence::Optional} + }, + ObjectOptions{Reaction::Ignore}, + element_parser_callback}; + } + + template + static auto getMainParser(ElementParserCallback element_parser_callback = nullptr) { + return Parser{ + Object{ + std::tuple{ + Member{"version", Value{}, Presence::Optional}, + Member{"generator", Value{}, Presence::Optional}, + Member{"osmChange", Array{getElementsParser(element_parser_callback)}} + },ObjectOptions{Reaction::Ignore}}}; + } + + friend class OSMChangeJSONParser; +}; + +class OSMChangeJSONParser { + +public: + explicit OSMChangeJSONParser(Parser_Callback& callback) + : m_callback(callback) { } + + OSMChangeJSONParser(const OSMChangeJSONParser &) = delete; + OSMChangeJSONParser &operator=(const OSMChangeJSONParser &) = delete; + + OSMChangeJSONParser(OSMChangeJSONParser &&) = delete; + OSMChangeJSONParser &operator=(OSMChangeJSONParser &&) = delete; + + void process_message(const std::string &data) { + + try { + m_callback.start_document(); + _parser.parse(data); + _parser.finish(); + + if (_parser.parser().isEmpty()) { + throw payload_error("Empty JSON payload"); + } + + if (element_count == 0) { + throw payload_error("osmChange array is empty"); + } + + m_callback.end_document(); + } catch (const std::exception& e) { + throw http::bad_request(e.what()); // rethrow JSON parser error as HTTP 400 Bad request + } + } + +private: + + using ElementsParser = decltype(api06::OSMChangeJSONParserFormat::getElementsParser()); + using MainParser = decltype(api06::OSMChangeJSONParserFormat::getMainParser()); + + MainParser _parser{api06::OSMChangeJSONParserFormat::getMainParser(std::bind(&api06::OSMChangeJSONParser::process_element, this, _1))}; + + // OSM element callback + bool process_element(ElementsParser &parser) { + + element_count++; + + // process action + process_action(parser); + + // process if-unused flag for delete action + process_if_unused(parser); + + // process type (node, way, relation) + process_type(parser); + + return true; + } + + void process_action(ElementsParser &parser) { + + const std::string& action = parser.get<1>(); + + if (action == "create") { + m_operation = operation::op_create; + } else if (action == "modify") { + m_operation = operation::op_modify; + } else if (action == "delete") { + m_operation = operation::op_delete; + } else { + throw payload_error{fmt::format("Unknown action {}, choices are create, modify, delete", action)}; + } + } + + void process_if_unused(ElementsParser &parser) { + + if (m_operation == operation::op_delete) { + m_if_unused = false; + if (parser.parser<2>().isSet()) { + m_if_unused = parser.get<2>(); + } + } + } + + void process_type(ElementsParser &parser) { + + const std::string& type = parser.get<0>(); + + if (type == "node") { + process_node(parser); + } else if (type == "way") { + process_way(parser); + } else if (type == "relation") { + process_relation(parser); + } else { + throw payload_error{fmt::format("Unknown element {}, expecting node, way or relation", type)}; + } + } + + void process_node(ElementsParser& parser) { + + Node node; + init_object(node, parser); + + if (parser.parser<4>().isSet()) { + node.set_lat(parser.get<4>()); + } + + if (parser.parser<5>().isSet()) { + node.set_lon(parser.get<5>()); + } + + process_tags(node, parser); + + if (!node.is_valid(m_operation)) { + throw payload_error{fmt::format("{} does not include all mandatory fields", node.to_string())}; + } + + m_callback.process_node(node, m_operation, m_if_unused); + } + + void process_way(ElementsParser& parser) { + + Way way; + init_object(way, parser); + + // adding way nodes + if (parser.parser<9>().isSet()) { + for (const auto& value : parser.get<9>()) { + way.add_way_node(value); + } + } + + process_tags(way, parser); + + if (!way.is_valid(m_operation)) { + throw payload_error{fmt::format("{} does not include all mandatory fields", way.to_string())}; + } + + m_callback.process_way(way, m_operation, m_if_unused); + } + + void process_relation(ElementsParser& parser) { + + Relation relation; + init_object(relation, parser); + + process_relation_members(relation, parser); + + process_tags(relation, parser); + + if (!relation.is_valid(m_operation)) { + throw payload_error{fmt::format("{} does not include all mandatory fields", relation.to_string())}; + } + + m_callback.process_relation(relation, m_operation, m_if_unused); + } + + void process_relation_members(Relation &relation, ElementsParser& parser) { + + if (!parser.parser<10>().isSet()) { + return; + } + + for (auto &mbr : parser.get<10>()) { + const auto& [type, ref, role] = mbr; + + RelationMember member; + member.set_type(type); + member.set_ref(ref); + member.set_role(role); + + if (!member.is_valid()) { + throw payload_error{fmt::format("Missing mandatory field on relation member in {}", relation.to_string()) }; + } + relation.add_member(member); + } + } + + void process_tags(OSMObject &o, ElementsParser& parser) { + + if (parser.parser<8>().isSet()) { + for (const auto &tag : parser.get<8>()) { + o.add_tag(tag.first, tag.second); + } + } + } + + void init_object(OSMObject &object, ElementsParser& parser) { + + // id + object.set_id(parser.get<3>()); + + // version + if (parser.parser<6>().isSet()) { + object.set_version(parser.get<6>()); + } + + // changeset + if (parser.parser<7>().isSet()) { + object.set_changeset(parser.get<7>()); + } + + // TODO: not needed, handled by sjparser + if (!object.has_id()) { + throw payload_error{ "Mandatory field id missing in object" }; + } + + if (!object.has_changeset()) { + throw payload_error{fmt::format("Changeset id is missing for {}", object.to_string()) }; + } + + if (m_operation == operation::op_create) { + // we always override version number for create operations (they are not + // mandatory) + object.set_version(0u); + } else if (m_operation == operation::op_delete || + m_operation == operation::op_modify) { + // objects for other operations must have a positive version number + if (!object.has_version()) { + throw payload_error{fmt::format("Version is required when updating {}", object.to_string()) }; + } + if (object.version() < 1) { + throw payload_error{ fmt::format("Invalid version number {} in {}", object.version(), object.to_string()) }; + } + } + } + + operation m_operation = operation::op_undefined; + Parser_Callback& m_callback; + bool m_if_unused = false; + int element_count = 0; +}; + +} // namespace api06 + +#endif // OSMCHANGE_JSON_INPUT_FORMAT_HPP diff --git a/include/cgimap/json_formatter.hpp b/include/cgimap/json_formatter.hpp index a9ef04db..e10a0d45 100644 --- a/include/cgimap/json_formatter.hpp +++ b/include/cgimap/json_formatter.hpp @@ -42,6 +42,8 @@ class json_formatter : public output_formatter { void start_changeset(bool) override; void end_changeset(bool) override; + void start_diffresult() override; + void end_diffresult() override; void start_action(action_type type) override; void end_action(action_type type) override; void error(const std::exception &e) override; @@ -63,6 +65,7 @@ class json_formatter : public output_formatter { const osm_nwr_signed_id_t old_id, const osm_nwr_id_t new_id, const osm_version_t new_version) override; + void write_diffresult_delete(const element_type elem, const osm_nwr_signed_id_t old_id) override; diff --git a/include/cgimap/osm_diffresult_responder.hpp b/include/cgimap/osm_diffresult_responder.hpp index 2d5498c2..e6b421f4 100644 --- a/include/cgimap/osm_diffresult_responder.hpp +++ b/include/cgimap/osm_diffresult_responder.hpp @@ -27,6 +27,8 @@ class osm_diffresult_responder : public osm_responder { ~osm_diffresult_responder() override; + // lists the standard types that OSM format can respond in + std::vector types_available() const override; void write(output_formatter& f, const std::string &generator, diff --git a/include/cgimap/output_formatter.hpp b/include/cgimap/output_formatter.hpp index f6c8438d..f894b493 100644 --- a/include/cgimap/output_formatter.hpp +++ b/include/cgimap/output_formatter.hpp @@ -56,6 +56,23 @@ T element_type_name(element_type elt) noexcept { return ""; } +template +T action_type_name(action_type action) noexcept { + + switch (action) { + case action_type::create: + return "create"; + break; + case action_type::modify: + return "modify"; + break; + case action_type::del: + return "delete"; + break; + } + return ""; +} + } // anonymous namespace struct element_info { @@ -216,6 +233,11 @@ struct output_formatter { virtual void end_changeset(bool) = 0; + // marks the beginning of diffResult response processing + virtual void start_diffresult() = 0; + + virtual void end_diffresult() = 0; + // TODO: document me. virtual void start_action(action_type type) = 0; virtual void end_action(action_type type) = 0; diff --git a/include/cgimap/text_formatter.hpp b/include/cgimap/text_formatter.hpp index a314f38c..7b4fcdea 100644 --- a/include/cgimap/text_formatter.hpp +++ b/include/cgimap/text_formatter.hpp @@ -38,6 +38,8 @@ class text_formatter : public output_formatter { void start_changeset(bool) override; void end_changeset(bool) override; + void start_diffresult() override; + void end_diffresult() override; void start_action(action_type type) override; void end_action(action_type type) override; void error(const std::exception &e) override; diff --git a/include/cgimap/xml_formatter.hpp b/include/cgimap/xml_formatter.hpp index 9f67ff8a..b225488a 100644 --- a/include/cgimap/xml_formatter.hpp +++ b/include/cgimap/xml_formatter.hpp @@ -39,6 +39,9 @@ class xml_formatter : public output_formatter { void start_changeset(bool) override; void end_changeset(bool) override; + void start_diffresult() override; + void end_diffresult() override; + void start_action(action_type type) override; void end_action(action_type type) override; void error(const std::exception &e) override; diff --git a/src/api06/changeset_upload_handler.cpp b/src/api06/changeset_upload_handler.cpp index 2955ba24..9141cf93 100644 --- a/src/api06/changeset_upload_handler.cpp +++ b/src/api06/changeset_upload_handler.cpp @@ -14,6 +14,7 @@ #include "cgimap/api06/changeset_upload/osmchange_handler.hpp" #include "cgimap/api06/changeset_upload/osmchange_xml_input_format.hpp" +#include "cgimap/api06/changeset_upload/osmchange_json_input_format.hpp" #include "cgimap/api06/changeset_upload/osmchange_tracking.hpp" #include "cgimap/api06/changeset_upload_handler.hpp" #include "cgimap/backend/apidb/changeset_upload/changeset_updater.hpp" @@ -58,6 +59,9 @@ changeset_upload_responder::changeset_upload_responder(mime::type mt, if (mt != mime::type::application_json) { OSMChangeXMLParser(handler).process_message(payload); } + else { + OSMChangeJSONParser(handler).process_message(payload); + } // store diffresult for output handling in class osm_diffresult_responder m_diffresult = change_tracking.assemble_diffresult(); diff --git a/src/json_formatter.cpp b/src/json_formatter.cpp index cb3f996b..131eea9a 100644 --- a/src/json_formatter.cpp +++ b/src/json_formatter.cpp @@ -89,6 +89,16 @@ void json_formatter::end_document() { writer->end_object(); } +void json_formatter::start_diffresult() { + + writer->object_key("diffResult"); + writer->start_array(); +} + +void json_formatter::end_diffresult() { + writer->end_array(); +} + void json_formatter::start_action(action_type type) { } @@ -248,28 +258,22 @@ void json_formatter::write_diffresult_create_modify(const element_type elem, const osm_version_t new_version) { -// writer->start_object(); -// writer->object_key("type"); -// writer->entry_string(element_type_name(elem)); -// writer->object_key("old_id"); -// writer->entry_int(old_id); -// writer->object_key("new_id"); -// writer->entry_int(new_id); -// writer->object_key("new_version"); -// writer->entry_int(new_version); -// writer->end_object(); + writer->start_object(); + writer->property("type", element_type_name(elem)); + writer->property("old_id", old_id); + writer->property("new_id", new_id); + writer->property("new_version", new_version); + writer->end_object(); } void json_formatter::write_diffresult_delete(const element_type elem, const osm_nwr_signed_id_t old_id) { -// writer->start_object(); -// writer->object_key("type"); -// writer->entry_string(element_type_name(elem)); -// writer->object_key("old_id"); -// writer->entry_int(old_id); -// writer->end_object(); + writer->start_object(); + writer->property("type", element_type_name(elem)); + writer->property("old_id", old_id); + writer->end_object(); } void json_formatter::flush() { writer->flush(); } diff --git a/src/osm_diffresult_responder.cpp b/src/osm_diffresult_responder.cpp index bb031552..6524d65d 100644 --- a/src/osm_diffresult_responder.cpp +++ b/src/osm_diffresult_responder.cpp @@ -32,7 +32,6 @@ namespace { throw std::runtime_error("Unhandled object_type in as_elem_type."); } - } osm_diffresult_responder::osm_diffresult_responder(mime::type mt) @@ -40,6 +39,13 @@ osm_diffresult_responder::osm_diffresult_responder(mime::type mt) osm_diffresult_responder::~osm_diffresult_responder() = default; +std::vector osm_diffresult_responder::types_available() const { + std::vector types; + types.push_back(mime::type::application_xml); + types.push_back(mime::type::application_json); + return types; +} + void osm_diffresult_responder::write(output_formatter& fmt, const std::string &generator, const std::chrono::system_clock::time_point &) { @@ -48,6 +54,8 @@ void osm_diffresult_responder::write(output_formatter& fmt, try { fmt.start_document(generator, "diffResult"); + fmt.start_diffresult(); + // Iterate over all elements in the sequence defined in the osmChange // message for (const auto &item : m_diffresult) { @@ -83,6 +91,8 @@ void osm_diffresult_responder::write(output_formatter& fmt, } } + fmt.end_diffresult(); + } catch (const std::exception &e) { logger::message(fmt::format("Caught error in osm_diffresult_responder: {}", e.what())); diff --git a/src/osmchange_responder.cpp b/src/osmchange_responder.cpp index 3cafb174..8b4810f7 100644 --- a/src/osmchange_responder.cpp +++ b/src/osmchange_responder.cpp @@ -185,6 +185,16 @@ struct sorting_formatter : public output_formatter { throw std::runtime_error("Unexpected call to end_action."); } + void start_diffresult() override { + // this shouldn't be called here + throw std::runtime_error("Unexpected call to start_diffresult."); + } + + void end_diffresult() override { + // this shouldn't be called here + throw std::runtime_error("Unexpected call to end_diffresult."); + } + void write(output_formatter &fmt) { std::sort(m_elements.begin(), m_elements.end()); for (const auto &e : m_elements) { diff --git a/src/text_formatter.cpp b/src/text_formatter.cpp index 9a4f30bc..ee874c9d 100644 --- a/src/text_formatter.cpp +++ b/src/text_formatter.cpp @@ -47,6 +47,15 @@ void text_formatter::end_changeset(bool) { // nothing needed here } +void text_formatter::start_diffresult() { + // nothing needed here +} + +void text_formatter::end_diffresult() { + // nothing needed here +} + + void text_formatter::start_action(action_type type) { // nothing needed here } diff --git a/src/xml_formatter.cpp b/src/xml_formatter.cpp index b283e9f2..aa1af7e1 100644 --- a/src/xml_formatter.cpp +++ b/src/xml_formatter.cpp @@ -60,18 +60,17 @@ void xml_formatter::end_changeset(bool) { // nothing to do for xml } +void xml_formatter::start_diffresult() { + // not needed in case of xml +} + +void xml_formatter::end_diffresult() { + // not needed in case of xml +} + + void xml_formatter::start_action(action_type type) { - switch (type) { - case action_type::create: - writer->start("create"); - break; - case action_type::modify: - writer->start("modify"); - break; - case action_type::del: - writer->start("delete"); - break; - } + writer->start(action_type_name(type)); } void xml_formatter::end_action(action_type type) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 850d33c3..4046d93e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -124,7 +124,20 @@ if(BUILD_TESTING) add_test(NAME test_parse_osmchange_xml_input COMMAND test_parse_osmchange_xml_input) + ################################# + # test_parse_osmchange_json_input + ################################# + add_executable(test_parse_osmchange_json_input + test_parse_osmchange_json_input.cpp) + target_link_libraries(test_parse_osmchange_json_input + cgimap_common_compiler_options + cgimap_core + Boost::program_options + catch2) + + add_test(NAME test_parse_osmchange_json_input + COMMAND test_parse_osmchange_json_input) ############################ # test_parse_changeset_input @@ -275,6 +288,7 @@ if(BUILD_TESTING) test_http test_parse_time test_parse_osmchange_xml_input + test_parse_osmchange_json_input test_parse_changeset_input) add_dependencies(check test_apidb_backend_nodes diff --git a/test/test_apidb_backend_changeset_uploads.cpp b/test/test_apidb_backend_changeset_uploads.cpp index a8f268c8..8c82b364 100644 --- a/test/test_apidb_backend_changeset_uploads.cpp +++ b/test/test_apidb_backend_changeset_uploads.cpp @@ -2454,6 +2454,66 @@ TEST_CASE_METHOD( DatabaseTestsFixture, "test_osmchange_end_to_end", "[changeset REQUIRE(req.response_status() == 200); } + SECTION("JSON upload") + { + std::string payload = R"( + { + "version": "0.6", + "generator": "demo", + "osmChange": [ + { + "type": "node", + "action": "create", + "id": -1, + "lat": 42, + "lon": 13, + "changeset": 1 + }, + { + "type": "node", + "action": "modify", + "id": -1, + "version": 1, + "lat": 42.7957187, + "lon": 13.5690032, + "changeset": 1, + "tags": { + "man_made": "mast", + "name": "Monte Piselli - San Giacomo" + } + } + ] + } + )"; + + req.set_header("REQUEST_URI", "/api/0.6/changeset/1/upload.json"); + req.set_payload(payload); + + // execute the request + process_request(req, limiter, generator, route, *sel_factory, upd_factory.get()); + + CAPTURE(req.body().str()); + + REQUIRE(req.response_status() == 200); + + SECTION("Validate diffResult in JSON format") + { + pt::ptree act_tree; + std::stringstream ss(req.body().str()); + pt::read_json(ss, act_tree); + + auto diffResult = act_tree.get_child("diffResult"); + int version = 1; + for (auto & entry : diffResult) { + REQUIRE(entry.second.get("type") == "node"); + REQUIRE(entry.second.get("old_id") == -1); + REQUIRE(entry.second.get("new_id") > 0); + REQUIRE(entry.second.get("new_version") == version); + version++; + } + } + } + } diff --git a/test/test_formatter.cpp b/test/test_formatter.cpp index 07098baa..56ad7f78 100644 --- a/test/test_formatter.cpp +++ b/test/test_formatter.cpp @@ -152,6 +152,12 @@ void test_formatter::start_document( void test_formatter::end_document() { } +void test_formatter::start_diffresult() { +} + +void test_formatter::end_diffresult() { +} + void test_formatter::write_bounds(const bbox &bounds) { } diff --git a/test/test_formatter.hpp b/test/test_formatter.hpp index 464dc37d..b87bf38f 100644 --- a/test/test_formatter.hpp +++ b/test/test_formatter.hpp @@ -89,6 +89,8 @@ struct test_formatter : public output_formatter { mime::type mime_type() const override; void start_document(const std::string &generator, const std::string &root_name) override; void end_document() override; + void start_diffresult() override; + void end_diffresult() override; void write_bounds(const bbox &bounds) override; void start_element() override; void end_element() override; diff --git a/test/test_parse_osmchange_json_input.cpp b/test/test_parse_osmchange_json_input.cpp new file mode 100644 index 00000000..3c7d46a2 --- /dev/null +++ b/test/test_parse_osmchange_json_input.cpp @@ -0,0 +1,867 @@ +/** + * SPDX-License-Identifier: GPL-2.0-only + * + * This file is part of openstreetmap-cgimap (https://github.com/zerebubuth/openstreetmap-cgimap/). + * + * Copyright (C) 2009-2023 by the CGImap developer community. + * For a full list of authors see the git log. + */ + + +#include "cgimap/options.hpp" +#include "cgimap/api06/changeset_upload/osmchange_json_input_format.hpp" +#include "cgimap/api06/changeset_upload/parser_callback.hpp" +#include "cgimap/util.hpp" +#include "cgimap/http.hpp" + +#include +#include +#include +#include +#include + +#define CATCH_CONFIG_MAIN +#include + +class Test_Parser_Callback : public api06::Parser_Callback { + +public: + Test_Parser_Callback() = default; + + void start_document() override { start_executed = true; } + + void end_document() override { + end_executed = true; + REQUIRE(nodes.empty()); + REQUIRE(ways.empty()); + REQUIRE(relations.empty()); + } + + void process_node(const api06::Node &n, operation op, bool if_unused) override { + REQUIRE(!nodes.empty()); + + auto const& [n_expected, op_expected, if_unused_expected] = nodes.front(); + + REQUIRE(n_expected == n); + REQUIRE(op == op_expected); + REQUIRE(if_unused == if_unused_expected); + + nodes.pop_front(); + } + + void process_way(const api06::Way &w, operation op, bool if_unused) override { + REQUIRE(!ways.empty()); + + auto const& [w_expected, op_expected, if_unused_expected] = ways.front(); + + REQUIRE(w_expected == w); + REQUIRE(op == op_expected); + REQUIRE(if_unused == if_unused_expected); + + ways.pop_front(); + } + + void process_relation(const api06::Relation &r, operation op, bool if_unused) override { + REQUIRE(!relations.empty()); + + auto const& [r_expected, op_expected, if_unused_expected] = relations.front(); + + REQUIRE(r_expected == r); + REQUIRE(op == op_expected); + REQUIRE(if_unused == if_unused_expected); + + relations.pop_front(); + } + + bool start_executed{false}; + bool end_executed{false}; + + using node_tuple = std::tuple; + using way_tuple = std::tuple; + using relation_tuple = std::tuple; + + std::list< node_tuple > nodes; + std::list< way_tuple> ways; + std::list< relation_tuple > relations; +}; + +class global_settings_test_class : public global_settings_default { + +public: + + std::optional get_relation_max_members() const override { + return m_relation_max_members; + } + + std::optional get_element_max_tags() const override { + return m_element_max_tags; + } + + std::optional m_relation_max_members{}; + std::optional m_element_max_tags{}; + +}; + +std::string repeat(const std::string &input, size_t num) { + std::ostringstream os; + std::fill_n(std::ostream_iterator(os), num, input); + return os.str(); +} + +void process_testmsg(const std::string &payload, Test_Parser_Callback& cb) { + + api06::OSMChangeJSONParser parser(cb); + parser.process_message(payload); + + REQUIRE(cb.start_executed); + REQUIRE(cb.end_executed); +} + +void process_testmsg(const std::string &payload) { + + Test_Parser_Callback cb{}; + process_testmsg(payload, cb); +} + + +// OSMCHANGE STRUCTURE TESTS + +TEST_CASE("Invalid JSON", "[osmchange][json]") { + auto i = GENERATE(R"({})", R"(bla)"); + REQUIRE_THROWS_AS(process_testmsg(i), http::bad_request); +} +/* + +TEST_CASE("XML without any changes", "[osmchange][json]") { + REQUIRE_NOTHROW(process_testmsg(R"()")); +} + +TEST_CASE("Invalid XML: osmchange end only", "[osmchange][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Misspelled osmchange xml", "[osmchange][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("osmchange: Unknown action", "[osmchange][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg(R"()"), http::bad_request, + Catch::Message("Unknown action dummy, choices are create, modify, delete at line 1, column 18")); +} + +TEST_CASE("osmchange: Empty create action", "[osmchange][json]") { + REQUIRE_NOTHROW(process_testmsg(R"()")); +} + +TEST_CASE("osmchange: Empty modify action", "[osmchange][json]") { + REQUIRE_NOTHROW(process_testmsg(R"()")); +} + +TEST_CASE("osmchange: Empty delete action", "[osmchange][json]") { + REQUIRE_NOTHROW(process_testmsg(R"()")); +} + +TEST_CASE("osmchange: create invalid object", "[osmchange][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg(R"()"), http::bad_request, + Catch::Message("Unknown element bla, expecting node, way or relation at line 1, column 24")); +} + +*/ + +// NODE TESTS + +TEST_CASE("Create empty node without details", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"({"osmChange": [{ "type": "node", "action": "create"}]})"), http::bad_request); +} + +TEST_CASE("Create node, details except changeset info missing", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"({"osmChange": [{ "type": "node", "action": "create", changeset: 1}]})"), http::bad_request); +} + +TEST_CASE("Create node, lat lon missing", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"({"osmChange": [{ "type": "node", "action": "create", changeset: 12, id: -1}]})"), http::bad_request); +} + +/* +TEST_CASE("Create node, lat missing", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Create node, lon missing", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Create node, lat outside range", "[osmchange][node][json]") { + auto i = GENERATE(R"(90.01)", R"(-90.01)"); + REQUIRE_THROWS_AS(process_testmsg(fmt::format(R"()", i)), http::bad_request); +} + +TEST_CASE("Create node, lon outside range", "[osmchange][node][json]") { + auto i = GENERATE(R"(180.01)", R"(-180.01)"); + REQUIRE_THROWS_AS(process_testmsg(fmt::format(R"()", i)), http::bad_request); +} + +TEST_CASE("Create node, lat float overflow", "[osmchange][node][json]") { + auto i = GENERATE(R"(9999999999999999999999999999999999999999999999.01)", R"(-9999999999999999999999999999999999999999999999.01)"); + REQUIRE_THROWS_AS(process_testmsg(fmt::format(R"()", i)), http::bad_request); +} + +TEST_CASE("Create node, lon float overflow", "[osmchange][node][json]") { + auto i = GENERATE(R"(9999999999999999999999999999999999999999999999.01)", R"(-9999999999999999999999999999999999999999999999.01)"); + REQUIRE_THROWS_AS(process_testmsg(fmt::format(R"()", i)), http::bad_request); +} + +TEST_CASE("Create node, lat non-finite float", "[osmchange][node][json]") { + auto i = GENERATE(R"(nan)", R"(-nan)", R"(Inf), R"(-Inf)"); + REQUIRE_THROWS_AS(process_testmsg(fmt::format(R"()", i)), http::bad_request); +} + +TEST_CASE("Create node, lon non-finite float", "[osmchange][node][json]") { + auto i = GENERATE(R"(nan)", R"(-nan)", R"(Inf), R"(-Inf)"); + REQUIRE_THROWS_AS(process_testmsg(fmt::format(R"()", i)), http::bad_request); +} + +TEST_CASE("Create node, changeset missing", "[osmchange][node][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg(R"()"), http::bad_request, + Catch::Message("Changeset id is missing for Node -1 at line 1, column 60")); +} + +TEST_CASE("Create node, redefined lat attribute", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Create valid node", "[osmchange][node][json]") { + auto i = GENERATE(R"()", + R"()"); + REQUIRE_NOTHROW(process_testmsg(i)); +} + +TEST_CASE("Modify node, missing version", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Modify node, invalid version", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Delete node", "[osmchange][node][json]") { + REQUIRE_NOTHROW(process_testmsg(R"()")); +} + +TEST_CASE("Delete node, if-unused", "[osmchange][node][json]") { + REQUIRE_NOTHROW(process_testmsg(R"()")); +} + +TEST_CASE("Delete node, missing version", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Delete node, invalid version", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Delete node, missing id", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg(R"()"), http::bad_request); +} + +TEST_CASE("Create node, extra xml nested inside tag", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"( + )"), http::bad_request); +} + +TEST_CASE("Create node, empty tag key", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"( + )"), http::bad_request); +} + +TEST_CASE("Create node, empty tag value", "[osmchange][node][json]") { + REQUIRE_NOTHROW(process_testmsg( + R"( + )")); +} + +TEST_CASE("Create node, duplicate key dup1", "[osmchange][node][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + R"( + + + + + )"), + http::bad_request, Catch::Message("Node -1 has duplicate tags with key dup1 at line 4, column 48")); +} + +TEST_CASE("Create node, tag without value", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"( + )"), http::bad_request); +} + +TEST_CASE("Create node, tag without key", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"( + )"), http::bad_request); +} + +TEST_CASE("Create node, tag value with <= 255 unicode characters", "[osmchange][node][json]") { + for (int i = 0; i <= 255; i++) { + auto v = repeat("😎", i); + REQUIRE_NOTHROW(process_testmsg( + fmt::format(R"( + )", v))); + } +} + +TEST_CASE("Create node, tag value with > 255 unicode characters", "[osmchange][node][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + fmt::format(R"( + )", repeat("😎", 256))), + http::bad_request, Catch::Message("Value has more than 255 unicode characters in Node -1 at line 2, column 301")); +} + +TEST_CASE("Create node, tag key with <= 255 unicode characters", "[osmchange][node][json]") { + for (int i = 1; i <= 255; i++) { + auto v = repeat("😎", i); + REQUIRE_NOTHROW(process_testmsg( + fmt::format(R"( + )", v))); + } +} + +TEST_CASE("Create node, tag key with > 255 unicode characters", "[osmchange][node][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + fmt::format(R"( + )", repeat("😎", 256))), + http::bad_request, Catch::Message("Key has more than 255 unicode characters in Node -1 at line 2, column 303")); +} + + +// NODE: INVALID ARGUMENTS, OUT OF RANGE VALUES + +TEST_CASE("Modify node, invalid version number", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Modify node, version too large", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Modify node, version negative", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, invalid changeset number", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, changeset number too large", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, changeset number zero", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, changeset number negative", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, longitude not numeric", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, latitude not numeric", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, invalid id", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, id too large", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create node, id zero", "[osmchange][node][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + + + +// WAY TESTS + +TEST_CASE("Create way, no details", "[osmchange][way][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create way, only changeset", "[osmchange][way][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create way, missing changeset", "[osmchange][way][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + R"()"), + http::bad_request, Catch::Message("Changeset id is missing for Way -1 at line 1, column 32")); +} + +TEST_CASE("Create way, missing node ref", "[osmchange][way][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + R"()"), + http::precondition_failed, Catch::Message("Precondition failed: Way -1 must have at least one node")); +} + +TEST_CASE("Create way, node refs < max way nodes", "[osmchange][way][json]") { + std::string node_refs{}; + for (uint32_t i = 1; i <= global_settings::get_way_max_nodes(); i++) { + node_refs += fmt::format(R"()", i); + REQUIRE_NOTHROW(process_testmsg( + fmt::format(R"({})", node_refs))); + } +} + +TEST_CASE("Create way, node refs >= max way nodes", "[osmchange][way][json]") { + std::string node_refs{}; + for (uint32_t i = 1; i <= global_settings::get_way_max_nodes(); i++) + node_refs += fmt::format(R"()", i); + for (uint32_t j = global_settings::get_way_max_nodes()+1; j < global_settings::get_way_max_nodes() + 10; ++j) { + node_refs += fmt::format(R"()", j); + REQUIRE_THROWS_MATCHES(process_testmsg( + fmt::format(R"({})", node_refs)), + http::bad_request, Catch::Message(fmt::format("You tried to add {} nodes to way -1, however only {} are allowed", j, global_settings::get_way_max_nodes()))); + } +} + +TEST_CASE("Create way, with tags", "[osmchange][way][json]") { + REQUIRE_NOTHROW(process_testmsg( + R"()")); +} + +TEST_CASE("Create way, node ref not numeric", "[osmchange][way][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create way, node ref too large", "[osmchange][way][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create way, invalid zero node ref", "[osmchange][way][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create way, node ref missing", "[osmchange][way][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Delete way, no version", "[osmchange][way][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), + http::bad_request); +} + +TEST_CASE("Delete way, no id", "[osmchange][way][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + R"()"), + http::bad_request, Catch::Message(fmt::format("Mandatory field id missing in object at line 1, column 52"))); +} + +TEST_CASE("Delete way, no changeset", "[osmchange][way][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + R"()"), + http::bad_request, Catch::Message(fmt::format("Changeset id is missing for Way -1 at line 1, column 44"))); +} + +TEST_CASE("Delete way", "[osmchange][way][json]") { + REQUIRE_NOTHROW(process_testmsg(R"()")); +} + + +// RELATION TESTS + +TEST_CASE("Create relation, id missing", "[osmchange][relation][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create relation, member ref missing", "[osmchange][relation][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create relation, no member role", "[osmchange][relation][json]") { + REQUIRE_NOTHROW(process_testmsg( + R"()")); +} + +TEST_CASE("Create relation, member type missing", "[osmchange][relation][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create relation, invalid member type", "[osmchange][relation][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create relation, invalid member ref", "[osmchange][relation][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create relation, invalid member ref zero", "[osmchange][relation][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"()"), http::bad_request); +} + +TEST_CASE("Create relation, member ref too large", "[osmchange][relation][json]") { + REQUIRE_THROWS_AS(process_testmsg( + R"( + + )"), http::bad_request); +} + +TEST_CASE("Create relation, role with <= 255 unicode characters", "[osmchange][relation][json]") { + for (int i = 1; i <= 255; i++) { + auto v = repeat("😎", i); + REQUIRE_NOTHROW(process_testmsg( + fmt::format( + R"( + + )", + v))); + } +} + +TEST_CASE("Create relation, role with > 255 unicode characters", "[osmchange][relation][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + fmt::format( + R"( + + )", + repeat("😎", 256))), + http::bad_request, Catch::Message("Relation Role has more than 255 unicode characters at line 2, column 321")); +} + +TEST_CASE("Delete relation, no version", "[osmchange][relation][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + R"()"), + http::bad_request, Catch::Message(fmt::format("Version is required when updating Relation -1 at line 1, column 53"))); +} + +TEST_CASE("Delete relation, no id", "[osmchange][relation][json]") { + REQUIRE_THROWS_MATCHES(process_testmsg( + R"()"), + http::bad_request, Catch::Message(fmt::format("Mandatory field id missing in object at line 1, column 57"))); +} + +TEST_CASE("Delete relation", "[osmchange][relation][json]") { + REQUIRE_NOTHROW(process_testmsg( + R"()")); +} + +// INVALID DATA TESTS + +TEST_CASE("Invalid data", "[osmchange][json]") { + REQUIRE_THROWS_AS(process_testmsg("\x3C\x00\x00\x00\x00\x0A\x01\x00"), http::bad_request); +} + +*/ + +// LARGE MESSAGE TESTS + +TEST_CASE("Very large JSON message", "[osmchange][node][json]") { + + // Test JSON processing with a very large message + std::stringstream s; + + s << R"( + { + "version": "0.6", + "generator": "demo", + "osmChange": [ + )"; + + Test_Parser_Callback cb{}; + + for (int i = 1; i < 100000; i++) { + + if (i > 1) { + s << ",\n"; + } + + api06::Node node; + node.set_id(-i); + node.set_changeset(123); + node.add_tags({{"some key", "some value"}}); + + switch (i % 3) { + case 0: + node.set_lat(1); + node.set_lon(2); + node.set_version(0); // operation create forces version 0, regardless of JSON contents + + cb.nodes.emplace_back(node, operation::op_create, false); + + s << fmt::format(R"( + {{ + "type": "node", + "action": "{}", + "id": {}, + "lat": 1, + "lon": 2, + "changeset": 123, + "tags": {{ + "some key": "some value" + }} + }} + )", "create", -i); + + break; + + case 1: + node.set_lat(1); + node.set_lon(2); + node.set_version(1); + + cb.nodes.emplace_back(node, operation::op_modify, false); + + s << fmt::format(R"( + {{ + "type": "node", + "action": "{}", + "id": {}, + "lat": 1, + "lon": 2, + "version": 1, + "changeset": 123, + "tags": {{ + "some key": "some value" + }} + }} + )", "modify", -i); + break; + + case 2: + node.set_version(1); + cb.nodes.emplace_back(node, operation::op_delete, false); + + s << fmt::format(R"( + {{ + "type": "node", + "action": "{}", + "id": {}, + "version": 1, + "changeset": 123, + "tags": {{ + "some key": "some value" + }} + }} + )", "delete", -i); + + break; + + } + } + + s << R"( + ] + } + )"; + + REQUIRE_NOTHROW(process_testmsg(s.str(), cb)); + +} + +/* + +// OBJECT LIMIT TESTS + +TEST_CASE("Create node, tags < max tags", "[osmchange][node][json]") { + auto test_settings = std::unique_ptr(new global_settings_test_class()); + test_settings->m_element_max_tags = 50; + + global_settings::set_configuration(std::move(test_settings)); + REQUIRE(global_settings::get_element_max_tags()); + + std::string tags{}; + for (uint32_t i = 1; i <= global_settings::get_element_max_tags(); i++) { + tags += fmt::format("", i); + REQUIRE_NOTHROW(process_testmsg( + fmt::format(R"({})", tags))); + } +} + +TEST_CASE("Create node, tags >= max tags", "[osmchange][node][json]") { + auto test_settings = std::unique_ptr(new global_settings_test_class()); + test_settings->m_element_max_tags = 50; + + global_settings::set_configuration(std::move(test_settings)); + REQUIRE(global_settings::get_element_max_tags()); + + std::string tags{}; + for (uint32_t i = 1; i <= *global_settings::get_element_max_tags(); i++) + tags += fmt::format("", i); + for (uint32_t j = *global_settings::get_element_max_tags()+1; j < *global_settings::get_element_max_tags() + 10; ++j) { + tags += fmt::format("", j); + REQUIRE_THROWS_AS(process_testmsg( + fmt::format(R"({})", tags)), + http::bad_request); + } +} + +TEST_CASE("Create relation, members < max members", "[osmchange][relation][json]") { + auto test_settings = std::unique_ptr(new global_settings_test_class()); + test_settings->m_relation_max_members = 32000; + + global_settings::set_configuration(std::move(test_settings)); + REQUIRE(global_settings::get_relation_max_members()); + + std::string members = repeat(R"()", *global_settings::get_relation_max_members()); + REQUIRE_NOTHROW(process_testmsg( + fmt::format(R"({}")", members))); +} + +TEST_CASE("Create relation, members >= max members", "[osmchange][relation][json]") { + auto test_settings = std::unique_ptr(new global_settings_test_class()); + test_settings->m_relation_max_members = 32000; + + global_settings::set_configuration(std::move(test_settings)); + REQUIRE(global_settings::get_relation_max_members()); + + std::string members = repeat(R"()", *global_settings::get_relation_max_members()); + for (uint32_t j = *global_settings::get_relation_max_members()+1; j < *global_settings::get_relation_max_members() + 3; ++j) { + members += R"()"; + REQUIRE_THROWS_AS(process_testmsg( + fmt::format(R"({}")", members)), + http::bad_request); + } +} +*/ + +TEST_CASE("Create node", "[osmchange][node][json]") { + + Test_Parser_Callback cb{}; + api06::Node node; + node.set_id(-1); + node.set_lat(42.7957187); + node.set_lon(13.5690032); + node.set_changeset(124176968); + node.set_version(0); // operation create forces version 0, regardless of JSON contents + node.add_tags({{"man_made", "mast"},{"name", "Monte Piselli - San Giacomo"}}); + + cb.nodes.emplace_back(node, operation::op_create, false); + + REQUIRE_NOTHROW(process_testmsg( + R"( + { + "version": "0.6", + "generator": "demo", + "osmChange": [ + { + "type": "node", + "action": "create", + "id": -1, + "lat": 42.7957187, + "lon": 13.5690032, + "changeset": 124176968, + "tags": { + "man_made": "mast", + "name": "Monte Piselli - San Giacomo" + } + } + ] + } + )", cb)); +} + +TEST_CASE("Create way", "[osmchange][way][json]") { + + Test_Parser_Callback cb{}; + api06::Way way; + way.set_id(-1); + way.set_changeset(124176968); + way.set_version(0); // operation create forces version 0, regardless of JSON contents + way.add_way_nodes({1,2,3,4}); + way.add_tags({{"highway", "residential"},{"name", "Via Monte"}}); + + cb.ways.emplace_back(way, operation::op_create, false); + + REQUIRE_NOTHROW(process_testmsg( + R"( + { + "version": "0.6", + "generator": "demo", + "osmChange": [ + { + "type": "way", + "action": "create", + "id": -1, + "changeset": 124176968, + "nodes": [1,2,3,4], + "tags": { + "highway": "residential", + "name": "Via Monte" + } + } + ] + } + )", cb)); +} + +TEST_CASE("Create relation", "[osmchange][relation][json]") { + + Test_Parser_Callback cb{}; + api06::Relation rel; + rel.set_id(-1); + rel.set_changeset(124176968); + rel.set_version(0); // operation create forces version 0, regardless of JSON contents + rel.add_tags({{"route", "bus"}, {"ref", "23"}}); // last ref tag wins + rel.add_members({{"Node", -1, "stop"}, {"Way", -2, ""}, {"Relation", -3, "parent"}}); + + cb.relations.emplace_back(rel, operation::op_create, false); + + REQUIRE_NOTHROW(process_testmsg( + R"( + { + "version": "0.6", + "generator": "demo", + "osmChange": [ + { + "type": "relation", + "action": "create", + "id": -1, + "changeset": 124176968, + "members": [ + {"type": "Node", "ref": -1, "role": "stop"}, + {"type": "Way", "ref": -2}, + {"type": "Relation", "ref": -3, "role": "parent"} + ], + "tags": { + "ref": "123", + "route": "bus", + "ref": "23" + } + } + ] + } + )", cb)); +}