diff --git a/Development/cmake/NmosCppLibraries.cmake b/Development/cmake/NmosCppLibraries.cmake index b49f78736..3ebf4939b 100644 --- a/Development/cmake/NmosCppLibraries.cmake +++ b/Development/cmake/NmosCppLibraries.cmake @@ -834,6 +834,77 @@ target_include_directories(nmos_is12_schemas PUBLIC list(APPEND NMOS_CPP_TARGETS nmos_is12_schemas) add_library(nmos-cpp::nmos_is12_schemas ALIAS nmos_is12_schemas) +# nmos_is13_schemas library + +set(NMOS_IS13_SCHEMAS_HEADERS + nmos/is13_schemas/is13_schemas.h + ) + +set(NMOS_IS13_V1_0_TAG v1.0-dev) + +set(NMOS_IS13_V1_0_SCHEMAS_JSON + third_party/is-13/${NMOS_IS13_V1_0_TAG}/APIs/schemas/annotationapi-base.json + third_party/is-13/${NMOS_IS13_V1_0_TAG}/APIs/schemas/annotationapi-node-base.json + third_party/is-13/${NMOS_IS13_V1_0_TAG}/APIs/schemas/error.json + third_party/is-13/${NMOS_IS13_V1_0_TAG}/APIs/schemas/resource_core.json + third_party/is-13/${NMOS_IS13_V1_0_TAG}/APIs/schemas/resource_core_patch.json + third_party/is-13/${NMOS_IS13_V1_0_TAG}/APIs/schemas/resource-list.json + ) + +set(NMOS_IS13_SCHEMAS_JSON_MATCH "third_party/is-13/([^/]+)/APIs/schemas/([^;]+)\\.json") +set(NMOS_IS13_SCHEMAS_SOURCE_REPLACE "${CMAKE_CURRENT_BINARY_DIR_REPLACE}/nmos/is13_schemas/\\1/\\2.cpp") +string(REGEX REPLACE "${NMOS_IS13_SCHEMAS_JSON_MATCH}(;|$)" "${NMOS_IS13_SCHEMAS_SOURCE_REPLACE}\\3" NMOS_IS13_V1_0_SCHEMAS_SOURCES "${NMOS_IS13_V1_0_SCHEMAS_JSON}") + +foreach(JSON ${NMOS_IS13_V1_0_SCHEMAS_JSON}) + string(REGEX REPLACE "${NMOS_IS13_SCHEMAS_JSON_MATCH}" "${NMOS_IS13_SCHEMAS_SOURCE_REPLACE}" SOURCE "${JSON}") + string(REGEX REPLACE "${NMOS_IS13_SCHEMAS_JSON_MATCH}" "\\1" NS "${JSON}") + string(REGEX REPLACE "${NMOS_IS13_SCHEMAS_JSON_MATCH}" "\\2" VAR "${JSON}") + string(MAKE_C_IDENTIFIER "${NS}" NS) + string(MAKE_C_IDENTIFIER "${VAR}" VAR) + + file(WRITE "${SOURCE}.in" "\ +// Auto-generated from: ${JSON}\n\ +\n\ +namespace nmos\n\ +{\n\ + namespace is13_schemas\n\ + {\n\ + namespace ${NS}\n\ + {\n\ + const char* ${VAR} = R\"-auto-generated-(") + + file(READ "${JSON}" RAW) + file(APPEND "${SOURCE}.in" "${RAW}") + + file(APPEND "${SOURCE}.in" ")-auto-generated-\";\n\ + }\n\ + }\n\ +}\n") + + configure_file("${SOURCE}.in" "${SOURCE}" COPYONLY) +endforeach() + +add_library( + nmos_is13_schemas STATIC + ${NMOS_IS13_SCHEMAS_HEADERS} + ${NMOS_IS13_V1_0_SCHEMAS_SOURCES} + ) + +source_group("nmos\\is13_schemas\\Header Files" FILES ${NMOS_IS13_SCHEMAS_HEADERS}) +source_group("nmos\\is13_schemas\\${NMOS_IS13_V1_0_TAG}\\Source Files" FILES ${NMOS_IS13_V1_0_SCHEMAS_SOURCES}) + +target_link_libraries( + nmos_is13_schemas PRIVATE + nmos-cpp::compile-settings + ) +target_include_directories(nmos_is13_schemas PUBLIC + $ + $ + ) + +list(APPEND NMOS_CPP_TARGETS nmos_is13_schemas) +add_library(nmos-cpp::nmos_is13_schemas ALIAS nmos_is13_schemas) + # nmos-cpp library set(NMOS_CPP_BST_SOURCES @@ -903,6 +974,7 @@ set(NMOS_CPP_JWK_HEADERS set(NMOS_CPP_NMOS_SOURCES nmos/activation_utils.cpp nmos/admin_ui.cpp + nmos/annotation_api.cpp nmos/api_downgrade.cpp nmos/api_utils.cpp nmos/authorization.cpp @@ -989,6 +1061,7 @@ set(NMOS_CPP_NMOS_HEADERS nmos/activation_mode.h nmos/activation_utils.h nmos/admin_ui.h + nmos/annotation_api.h nmos/api_downgrade.h nmos/api_utils.h nmos/api_version.h @@ -1048,6 +1121,7 @@ set(NMOS_CPP_NMOS_HEADERS nmos/is09_versions.h nmos/is10_versions.h nmos/is12_versions.h + nmos/is13_versions.h nmos/issuers.h nmos/json_fields.h nmos/json_schema.h @@ -1198,6 +1272,7 @@ target_link_libraries( nmos-cpp::nmos_is09_schemas nmos-cpp::nmos_is10_schemas nmos-cpp::nmos_is12_schemas + nmos-cpp::nmos_is13_schemas nmos-cpp::mdns nmos-cpp::slog nmos-cpp::OpenSSL diff --git a/Development/cmake/NmosCppTest.cmake b/Development/cmake/NmosCppTest.cmake index ac56a57d8..10de269e9 100644 --- a/Development/cmake/NmosCppTest.cmake +++ b/Development/cmake/NmosCppTest.cmake @@ -40,6 +40,7 @@ set(NMOS_CPP_TEST_MDNS_TEST_HEADERS ) set(NMOS_CPP_TEST_NMOS_TEST_SOURCES + nmos/test/annotation_api_test.cpp nmos/test/api_utils_test.cpp nmos/test/capabilities_test.cpp nmos/test/channels_test.cpp diff --git a/Development/nmos-cpp-node/config.json b/Development/nmos-cpp-node/config.json index f7e5216e6..63abf856d 100644 --- a/Development/nmos-cpp-node/config.json +++ b/Development/nmos-cpp-node/config.json @@ -107,6 +107,9 @@ // is12_versions [node]: used to specify the enabled API versions for a version-locked configuration //"is12_versions": ["v1.0"], + // is13_versions [node]: used to specify the enabled API versions for a version-locked configuration + //"is13_versions": ["v1.0"], + // pri [registry, node]: used for the 'pri' TXT record; specifying nmos::service_priorities::no_priority (maximum value) disables advertisement completely //"pri": 100, @@ -145,6 +148,7 @@ //"events_port": 3216, //"events_ws_port": 3217, //"channelmapping_port": 3215, + //"annotation_port": 3212, // system_port [node]: used to construct request URLs for the System API (if not discovered via DNS-SD) //"system_port": 10641, // control_protocol_ws_port [node]: used to construct request URLs for the Control Protocol websocket, or negative to disable the control protocol features diff --git a/Development/nmos-cpp-node/node_implementation.cpp b/Development/nmos-cpp-node/node_implementation.cpp index f12cca15e..1ebc56be6 100644 --- a/Development/nmos-cpp-node/node_implementation.cpp +++ b/Development/nmos-cpp-node/node_implementation.cpp @@ -1696,6 +1696,27 @@ nmos::channelmapping_activation_handler make_node_implementation_channelmapping_ }; } +// Example Annotation API patch callback to update resource labels, descriptions and tags +nmos::annotation_patch_merger make_node_implementation_annotation_patch_merger(const nmos::settings& settings, slog::base_gate& gate) +{ + using web::json::value; + using web::json::value_of; + + return [&settings, &gate](const nmos::resource& resource, web::json::value& value, const web::json::value& patch) + { + const std::pair id_type{ resource.id, resource.type }; + slog::log(gate, SLOG_FLF) << nmos::stash_category(impl::categories::node_implementation) << "Updating " << id_type; + // this example uses the specified tags for node and device resources as defaults + const auto default_tags + = id_type.second == nmos::types::node ? impl::fields::node_tags(settings) + : id_type.second == nmos::types::device ? impl::fields::device_tags(settings) + : value::object(); + // and uses the default predicate for read-only tags + nmos::details::merge_annotation_patch(value, patch, &nmos::details::is_read_only_tag, value_of({ { nmos::fields::tags, default_tags } })); + // this example does not save the new values to persistent storage or e.g. reject values that are too large + }; +} + // Example Control Protocol WebSocket API property changed callback to perform application-specific operations to complete the property changed nmos::control_protocol_property_changed_handler make_node_implementation_control_protocol_property_changed_handler(slog::base_gate& gate) { @@ -1868,5 +1889,6 @@ nmos::experimental::node_implementation make_node_implementation(nmos::node_mode .on_connection_activated(make_node_implementation_connection_activation_handler(model, gate)) .on_validate_channelmapping_output_map(make_node_implementation_map_validator()) // may be omitted if not required .on_channelmapping_activated(make_node_implementation_channelmapping_activation_handler(gate)) + .on_merge_annotation_patch(make_node_implementation_annotation_patch_merger(model.settings, gate)) // may be omitted if not required .on_control_protocol_property_changed(make_node_implementation_control_protocol_property_changed_handler(gate)); // may be omitted if IS-12 not required } diff --git a/Development/nmos/annotation_api.cpp b/Development/nmos/annotation_api.cpp new file mode 100644 index 000000000..9857cff90 --- /dev/null +++ b/Development/nmos/annotation_api.cpp @@ -0,0 +1,342 @@ +#include "nmos/annotation_api.h" + +#include +#include +#include "cpprest/json_validator.h" +#include "nmos/api_utils.h" +#include "nmos/is13_versions.h" +#include "nmos/json_schema.h" +#include "nmos/model.h" +#include "nmos/slog.h" + +namespace nmos +{ + web::http::experimental::listener::api_router make_unmounted_annotation_api(nmos::model& model, nmos::annotation_patch_merger merge_patch, slog::base_gate& gate); + + web::http::experimental::listener::api_router make_annotation_api(nmos::model& model, nmos::annotation_patch_merger merge_patch, slog::base_gate& gate) + { + using namespace web::http::experimental::listener::api_router_using_declarations; + + api_router annotation_api; + + annotation_api.support(U("/?"), methods::GET, [](http_request req, http_response res, const string_t&, const route_parameters&) + { + set_reply(res, status_codes::OK, nmos::make_sub_routes_body({ U("x-nmos/") }, req, res)); + return pplx::task_from_result(true); + }); + + annotation_api.support(U("/x-nmos/?"), methods::GET, [](http_request req, http_response res, const string_t&, const route_parameters&) + { + set_reply(res, status_codes::OK, nmos::make_sub_routes_body({ U("annotation/") }, req, res)); + return pplx::task_from_result(true); + }); + + const auto versions = with_read_lock(model.mutex, [&model] { return nmos::is13_versions::from_settings(model.settings); }); + annotation_api.support(U("/x-nmos/") + nmos::patterns::annotation_api.pattern + U("/?"), methods::GET, [versions](http_request req, http_response res, const string_t&, const route_parameters&) + { + set_reply(res, status_codes::OK, nmos::make_sub_routes_body(nmos::make_api_version_sub_routes(versions), req, res)); + return pplx::task_from_result(true); + }); + + annotation_api.mount(U("/x-nmos/") + nmos::patterns::annotation_api.pattern + U("/") + nmos::patterns::version.pattern, make_unmounted_annotation_api(model, std::move(merge_patch), gate)); + + return annotation_api; + } + + web::json::value make_annotation_patch(const nmos::resource& resource) + { + using web::json::value_of; + return value_of({ + { nmos::fields::label, resource.data.at(nmos::fields::label) }, + { nmos::fields::description, resource.data.at(nmos::fields::description) }, + { nmos::fields::tags, resource.data.at(nmos::fields::tags) } + }); + } + + web::json::value make_annotation_response(const nmos::resource& resource) + { + using web::json::value_of; + return value_of({ + { nmos::fields::id, resource.data.at(nmos::fields::id) }, + { nmos::fields::version, resource.data.at(nmos::fields::version) }, + { nmos::fields::label, resource.data.at(nmos::fields::label) }, + { nmos::fields::description, resource.data.at(nmos::fields::description) }, + { nmos::fields::tags, resource.data.at(nmos::fields::tags) } + }); + } + + namespace details + { + // BCP-002-01 Group Hint tag and BCP-002-02 Asset Distinguishing Information tags are read-only + // all other tags are read/write + bool is_read_only_tag(const utility::string_t& key) + { + return boost::algorithm::starts_with(key, U("urn:x-nmos:tag:asset:")) + || boost::algorithm::starts_with(key, U("urn:x-nmos:tag:grouphint/")); + } + + const web::json::field_as_string_or default_label{ nmos::fields::label.key, U("") }; + const web::json::field_as_string_or default_description{ nmos::fields::description.key, U("") }; + const web::json::field_as_value_or default_tags{ nmos::fields::tags.key, web::json::value::object() }; + + void merge_annotation_patch(web::json::value& value, const web::json::value& patch, annotation_tag_predicate is_read_only_tag, const web::json::value& default_value) + { + // reject changes to read-ony tags + + if (patch.has_object_field(nmos::fields::tags)) + { + const auto& tags = nmos::fields::tags(patch); + auto patch_readonly = std::find_if(tags.begin(), tags.end(), [&](const std::pair& field) + { + return is_read_only_tag(field.first); + }); + if (tags.end() != patch_readonly) throw std::runtime_error("cannot patch read-only tag: " + utility::us2s(patch_readonly->first)); + } + + // save existing read-only tags (so that read-only tags don't need to be included in default_value) + + auto readonly_tags = web::json::value_from_fields(nmos::fields::tags(value) | boost::adaptors::filtered([&](const std::pair& field) + { + return is_read_only_tag(field.first); + })); + + // apply patch + + web::json::merge_patch(value, patch, true); + + // apply defaults to properties that have been reset + + web::json::insert(value, std::make_pair(nmos::fields::label, details::default_label(default_value))); + web::json::insert(value, std::make_pair(nmos::fields::description, details::default_description(default_value))); + web::json::insert(value, std::make_pair(nmos::fields::tags, readonly_tags)); + auto& tags = value.at(nmos::fields::tags); + for (const auto& default_tag : details::default_tags(default_value).as_object()) + { + web::json::insert(tags, default_tag); + } + } + + void assign_annotation_patch(web::json::value& value, web::json::value&& patch) + { + if (patch.has_string_field(nmos::fields::label)) value[nmos::fields::label] = std::move(patch.at(nmos::fields::label)); + if (patch.has_string_field(nmos::fields::description)) value[nmos::fields::description] = std::move(patch.at(nmos::fields::description)); + if (patch.has_object_field(nmos::fields::tags)) value[nmos::fields::tags] = std::move(patch.at(nmos::fields::tags)); + } + + void handle_annotation_patch(nmos::resources& resources, const nmos::resource& resource, const web::json::value& patch, const nmos::annotation_patch_merger& merge_patch, slog::base_gate& gate) + { + auto merged = nmos::make_annotation_patch(resource); + try + { + if (merge_patch) + { + merge_patch(resource, merged, patch); + } + else + { + nmos::merge_annotation_patch(resource, merged, patch); + } + } + catch (const web::json::json_exception& e) + { + throw std::logic_error(e.what()); + } + catch (const std::runtime_error& e) + { + throw std::logic_error(e.what()); + } + modify_resource(resources, resource.id, [&merged](nmos::resource& resource) + { + resource.data[nmos::fields::version] = web::json::value::string(nmos::make_version()); + details::assign_annotation_patch(resource.data, std::move(merged)); + }); + } + } + + web::http::experimental::listener::api_router make_unmounted_annotation_api(nmos::model& model, nmos::annotation_patch_merger merge_patch, slog::base_gate& gate_) + { + using namespace web::http::experimental::listener::api_router_using_declarations; + + api_router annotation_api; + + // check for supported API version + const auto versions = with_read_lock(model.mutex, [&model] { return nmos::is13_versions::from_settings(model.settings); }); + annotation_api.support(U(".*"), details::make_api_version_handler(versions, gate_)); + + annotation_api.support(U("/?"), methods::GET, [](http_request req, http_response res, const string_t&, const route_parameters&) + { + set_reply(res, status_codes::OK, nmos::make_sub_routes_body({ U("node/") }, req, res)); + return pplx::task_from_result(true); + }); + + annotation_api.support(U("/node/?"), methods::GET, [](http_request req, http_response res, const string_t&, const route_parameters&) + { + set_reply(res, status_codes::OK, nmos::make_sub_routes_body({ U("self/"), U("devices/"), U("sources/"), U("flows/"), U("senders/"), U("receivers/") }, req, res)); + return pplx::task_from_result(true); + }); + + annotation_api.support(U("/node/self/?"), methods::GET, [&model, &gate_](http_request req, http_response res, const string_t&, const route_parameters& parameters) + { + nmos::api_gate gate(gate_, req, parameters); + auto lock = model.read_lock(); + auto& resources = model.node_resources; + + auto resource = nmos::find_self_resource(resources); + if (resources.end() != resource) + { + slog::log(gate, SLOG_FLF) << "Returning self resource: " << resource->id; + set_reply(res, status_codes::OK, nmos::make_annotation_response(*resource)); + } + else + { + slog::log(gate, SLOG_FLF) << "Self resource not found!"; + set_reply(res, status_codes::InternalError); // rather than Not Found, since the Annotation API doesn't allow a 404 response + } + + return pplx::task_from_result(true); + }); + + const web::json::experimental::json_validator validator + { + nmos::experimental::load_json_schema, + boost::copy_range>(versions | boost::adaptors::transformed(experimental::make_annotationapi_resource_core_patch_request_schema_uri)) + }; + + annotation_api.support(U("/node/self/?"), methods::PATCH, [&model, validator, merge_patch, &gate_](http_request req, http_response res, const string_t&, const route_parameters& parameters) + { + nmos::api_gate gate(gate_, req, parameters); + + return details::extract_json(req, gate).then([&model, &validator, merge_patch, req, res, parameters, gate](value body) mutable + { + const nmos::api_version version = nmos::parse_api_version(parameters.at(nmos::patterns::version.name)); + + validator.validate(body, experimental::make_annotationapi_resource_core_patch_request_schema_uri(version)); + + auto lock = model.write_lock(); + auto& resources = model.node_resources; + + auto resource = nmos::find_self_resource(resources); + if (resources.end() != resource) + { + slog::log(gate, SLOG_FLF) << "Patching self resource: " << resource->id; + + details::handle_annotation_patch(resources, *resource, body, merge_patch, gate); + + set_reply(res, status_codes::OK, nmos::make_annotation_response(*resource)); + + model.notify(); + } + else + { + slog::log(gate, SLOG_FLF) << "Self resource not found!"; + set_reply(res, status_codes::InternalError); // rather than Not Found, since the Annotation API doesn't allow a 404 response + } + + return true; + }); + }); + + annotation_api.support(U("/node/") + nmos::patterns::subresourceType.pattern + U("/?"), methods::GET, [&model, &gate_](http_request req, http_response res, const string_t&, const route_parameters& parameters) + { + nmos::api_gate gate(gate_, req, parameters); + auto lock = model.read_lock(); + auto& resources = model.node_resources; + + const string_t resourceType = parameters.at(nmos::patterns::subresourceType.name); + + const auto match = [&](const nmos::resources::value_type& resource) { return resource.type == nmos::type_from_resourceType(resourceType); }; + + size_t count = 0; + + // experimental extension, to support human-readable HTML rendering of NMOS responses + if (experimental::details::is_html_response_preferred(req, web::http::details::mime_types::application_json)) + { + set_reply(res, status_codes::OK, + web::json::serialize_array(resources + | boost::adaptors::filtered(match) + | boost::adaptors::transformed( + [&count, &req](const nmos::resource& resource) { ++count; return experimental::details::make_html_response_a_tag(resource.id + U("/"), req); } + )), + web::http::details::mime_types::application_json); + } + else + { + set_reply(res, status_codes::OK, + web::json::serialize_array(resources + | boost::adaptors::filtered(match) + | boost::adaptors::transformed( + [&count](const nmos::resource& resource) { ++count; return value(resource.id + U("/")); } + ) + ), + web::http::details::mime_types::application_json); + } + + slog::log(gate, SLOG_FLF) << "Returning " << count << " matching " << resourceType; + + return pplx::task_from_result(true); + }); + + annotation_api.support(U("/node/") + nmos::patterns::subresourceType.pattern + U("/") + nmos::patterns::resourceId.pattern + U("/?"), methods::GET, [&model, &gate_](http_request req, http_response res, const string_t&, const route_parameters& parameters) + { + nmos::api_gate gate(gate_, req, parameters); + auto lock = model.read_lock(); + auto& resources = model.node_resources; + + const string_t resourceType = parameters.at(nmos::patterns::subresourceType.name); + const string_t resourceId = parameters.at(nmos::patterns::resourceId.name); + + const std::pair id_type{ resourceId, nmos::type_from_resourceType(resourceType) }; + auto resource = find_resource(resources, id_type); + if (resources.end() != resource) + { + slog::log(gate, SLOG_FLF) << "Returning " << id_type; + set_reply(res, status_codes::OK, nmos::make_annotation_response(*resource)); + } + else + { + set_reply(res, status_codes::NotFound); + } + + return pplx::task_from_result(true); + }); + + annotation_api.support(U("/node/") + nmos::patterns::subresourceType.pattern + U("/") + nmos::patterns::resourceId.pattern + U("/?"), methods::PATCH, [&model, validator, merge_patch, &gate_](http_request req, http_response res, const string_t&, const route_parameters& parameters) + { + nmos::api_gate gate(gate_, req, parameters); + + return details::extract_json(req, gate).then([&model, &validator, merge_patch, req, res, parameters, gate](value body) mutable + { + const nmos::api_version version = nmos::parse_api_version(parameters.at(nmos::patterns::version.name)); + + validator.validate(body, experimental::make_annotationapi_resource_core_patch_request_schema_uri(version)); + + auto lock = model.write_lock(); + auto& resources = model.node_resources; + + const string_t resourceType = parameters.at(nmos::patterns::connectorType.name); + const string_t resourceId = parameters.at(nmos::patterns::resourceId.name); + + const std::pair id_type{ resourceId, nmos::type_from_resourceType(resourceType) }; + auto resource = find_resource(resources, id_type); + if (resources.end() != resource) + { + slog::log(gate, SLOG_FLF) << "Patching " << id_type; + + details::handle_annotation_patch(resources, *resource, body, merge_patch, gate); + + set_reply(res, status_codes::OK, nmos::make_annotation_response(*resource)); + + model.notify(); + } + else + { + set_reply(res, status_codes::NotFound); + } + + return true; + }); + }); + + return annotation_api; + } +} diff --git a/Development/nmos/annotation_api.h b/Development/nmos/annotation_api.h new file mode 100644 index 000000000..9986bd070 --- /dev/null +++ b/Development/nmos/annotation_api.h @@ -0,0 +1,53 @@ +#ifndef NMOS_RWNODE_API_H +#define NMOS_RWNODE_API_H + +#include "cpprest/api_router.h" + +namespace slog +{ + class base_gate; +} + +// Annotation API implementation +// See https://specs.amwa.tv/is-13/branches/v1.0-dev/APIs/AnnotationAPI.html +namespace nmos +{ + struct model; + struct resource; + + // Annotation API callbacks + + // an annotation_patch_merger validates the specified patch data for the specified IS-04 resource and updates the object to be merged + // or may throw std::runtime_error, which will be mapped to a 500 Internal Error status code with NMOS error "debug" information including the exception message + // (the default patch merger, nmos::merge_annotation_patch, implements the minimum requirements) + typedef std::function annotation_patch_merger; + + // Annotation API factory functions + + // callbacks from this function are called with the model locked, and may read but should not write directly to the model + web::http::experimental::listener::api_router make_annotation_api(nmos::model& model, annotation_patch_merger merge_patch, slog::base_gate& gate); + + // Helper functions for the Annotation API callbacks + + namespace details + { + typedef std::function annotation_tag_predicate; + + // BCP-002-01 Group Hint tag and BCP-002-02 Asset Distinguishing Information tags are read-only + // all other tags are read/write + bool is_read_only_tag(const utility::string_t& name); + + // this function merges the patch into the value with few additional constraints + // when any fields are reset using null, default values are applied if specified or + // read-write tags are removed, and label and description are set to the empty string + void merge_annotation_patch(web::json::value& value, const web::json::value& patch, annotation_tag_predicate is_read_only_tag = &nmos::details::is_read_only_tag, const web::json::value& default_value = {}); + } + + // this is the default patch merger + inline void merge_annotation_patch(const nmos::resource& resource, web::json::value& value, const web::json::value& patch) + { + details::merge_annotation_patch(value, patch); + } +} + +#endif diff --git a/Development/nmos/api_utils.h b/Development/nmos/api_utils.h index 0f8b132a3..53958eb2f 100644 --- a/Development/nmos/api_utils.h +++ b/Development/nmos/api_utils.h @@ -57,6 +57,8 @@ namespace nmos const route_pattern channelmapping_api = make_route_pattern(U("api"), U("channelmapping")); // IS-09 System API (originally specified in JT-NM TR-1001-1:2018 Annex A) const route_pattern system_api = make_route_pattern(U("api"), U("system")); + // IS-13 Annotation API + const route_pattern annotation_api = make_route_pattern(U("api"), U("annotation")); // API version pattern const route_pattern version = make_route_pattern(U("version"), U("v[0-9]+\\.[0-9]+")); diff --git a/Development/nmos/is13_schemas/is13_schemas.h b/Development/nmos/is13_schemas/is13_schemas.h new file mode 100644 index 000000000..6a3f76634 --- /dev/null +++ b/Development/nmos/is13_schemas/is13_schemas.h @@ -0,0 +1,22 @@ +#ifndef NMOS_IS13_SCHEMAS_H +#define NMOS_IS13_SCHEMAS_H + +// Extern declarations for auto-generated constants +// could be auto-generated, but isn't currently! +namespace nmos +{ + namespace is13_schemas + { + namespace v1_0_dev + { + extern const char* annotationapi_base; + extern const char* annotationapi_node_base; + extern const char* error; + extern const char* resource_core; + extern const char* resource_core_patch; + extern const char* resource_list; + } + } +} + +#endif diff --git a/Development/nmos/is13_versions.h b/Development/nmos/is13_versions.h new file mode 100644 index 000000000..29e1a30f5 --- /dev/null +++ b/Development/nmos/is13_versions.h @@ -0,0 +1,26 @@ +#ifndef NMOS_IS13_VERSIONS_H +#define NMOS_IS13_VERSIONS_H + +#include +#include +#include "nmos/api_version.h" +#include "nmos/settings.h" + +namespace nmos +{ + namespace is13_versions + { + const api_version v1_0{ 1, 0 }; + + const std::set all{ nmos::is13_versions::v1_0 }; + + inline std::set from_settings(const nmos::settings& settings) + { + return settings.has_field(nmos::fields::is13_versions) + ? boost::copy_range>(nmos::fields::is13_versions(settings) | boost::adaptors::transformed([](const web::json::value& v) { return nmos::parse_api_version(v.as_string()); })) + : nmos::is13_versions::all; + } + } +} + +#endif diff --git a/Development/nmos/json_schema.cpp b/Development/nmos/json_schema.cpp index d24f2a5df..2b7076d2b 100644 --- a/Development/nmos/json_schema.cpp +++ b/Development/nmos/json_schema.cpp @@ -12,6 +12,8 @@ #include "nmos/is10_schemas/is10_schemas.h" #include "nmos/is12_versions.h" #include "nmos/is12_schemas/is12_schemas.h" +#include "nmos/is13_versions.h" +#include "nmos/is13_schemas/is13_schemas.h" #include "nmos/type.h" namespace nmos @@ -170,6 +172,23 @@ namespace nmos const web::uri controlprotocolapi_subscription_message_schema_uri = make_schema_uri(tag, _XPLATSTR("subscription-message.json")); } } + + namespace is13_schemas + { + web::uri make_schema_uri(const utility::string_t& tag, const utility::string_t& ref = {}) + { + return{ _XPLATSTR("https://github.com/AMWA-TV/is-13/raw/") + tag + _XPLATSTR("/APIs/schemas/") + ref }; + } + + // See https://github.com/AMWA-TV/is-13/blob/v1.0-dev/APIs/schemas/ + namespace v1_0 + { + using namespace nmos::is13_schemas::v1_0_dev; + const utility::string_t tag(_XPLATSTR("v1.0-dev")); + + const web::uri annotationapi_resource_core_patch_request_uri = make_schema_uri(tag, _XPLATSTR("resource_core_patch.json")); + } + } } namespace nmos @@ -391,6 +410,17 @@ namespace nmos }; } + static std::map make_is13_schemas() + { + using namespace nmos::is13_schemas; + + return + { + // v1.0 + { make_schema_uri(v1_0::tag, _XPLATSTR("resource_core_patch.json")), make_schema(v1_0::resource_core_patch) } + }; + } + inline void merge(std::map& to, std::map&& from) { to.insert(from.begin(), from.end()); // std::map::merge in C++17 @@ -404,6 +434,7 @@ namespace nmos merge(result, make_is09_schemas()); merge(result, make_is10_schemas()); merge(result, make_is12_schemas()); + merge(result, make_is13_schemas()); return result; } @@ -510,6 +541,11 @@ namespace nmos return is12_schemas::v1_0::controlprotocolapi_subscription_message_schema_uri; } + web::uri make_annotationapi_resource_core_patch_request_schema_uri(const nmos::api_version& version) + { + return is13_schemas::v1_0::annotationapi_resource_core_patch_request_uri; + } + // load the json schema for the specified base URI web::json::value load_json_schema(const web::uri& id) { diff --git a/Development/nmos/json_schema.h b/Development/nmos/json_schema.h index 57cb0996b..e2f5a983b 100644 --- a/Development/nmos/json_schema.h +++ b/Development/nmos/json_schema.h @@ -29,6 +29,8 @@ namespace nmos web::uri make_channelmappingapi_map_activations_post_request_schema_uri(const nmos::api_version& version); + web::uri make_annotationapi_resource_core_patch_request_schema_uri(const nmos::api_version& version); + web::uri make_authapi_auth_metadata_schema_uri(const nmos::api_version& version); web::uri make_authapi_jwks_response_schema_uri(const nmos::api_version& version); web::uri make_authapi_register_client_response_uri(const nmos::api_version& version); diff --git a/Development/nmos/node_resource.cpp b/Development/nmos/node_resource.cpp index 8e5af4744..b12c12b39 100644 --- a/Development/nmos/node_resource.cpp +++ b/Development/nmos/node_resource.cpp @@ -5,6 +5,7 @@ #include "nmos/clock_name.h" #include "nmos/clock_ref_type.h" #include "nmos/is04_versions.h" +#include "nmos/is13_versions.h" #include "nmos/resource.h" namespace nmos @@ -44,6 +45,26 @@ namespace nmos data[U("services")] = value::array(); + if (0 <= nmos::fields::annotation_port(settings)) + { + for (const auto& version : nmos::is13_versions::from_settings(settings)) + { + auto annotation_uri = web::uri_builder() + .set_scheme(nmos::http_scheme(settings)) + .set_port(nmos::fields::annotation_port(settings)) + .set_path(U("/x-nmos/annotation/") + make_api_version(version)); + auto type = U("urn:x-nmos:service:annotation/") + make_api_version(version); + + for (const auto& host : hosts) + { + web::json::push_back(data[U("services")], value_of({ + { U("href"), annotation_uri.set_host(host).to_uri().to_string() }, + { U("type"), type } + })); + } + } + } + data[U("clocks")] = !web::json::empty(clocks) ? clocks : value::array(); data[U("interfaces")] = !web::json::empty(interfaces) ? interfaces : value::array(); diff --git a/Development/nmos/node_resources.cpp b/Development/nmos/node_resources.cpp index 2d6d8d232..34cab371c 100644 --- a/Development/nmos/node_resources.cpp +++ b/Development/nmos/node_resources.cpp @@ -42,6 +42,8 @@ namespace nmos const auto hosts = nmos::get_hosts(settings); + data[U("controls")] = value::array(); + if (0 <= nmos::fields::connection_port(settings)) { for (const auto& version : nmos::is05_versions::from_settings(settings)) diff --git a/Development/nmos/node_server.cpp b/Development/nmos/node_server.cpp index 00258389d..0e67de64e 100644 --- a/Development/nmos/node_server.cpp +++ b/Development/nmos/node_server.cpp @@ -1,6 +1,7 @@ #include "nmos/node_server.h" #include "cpprest/ws_utils.h" +#include "nmos/annotation_api.h" #include "nmos/api_utils.h" #include "nmos/channelmapping_activation.h" #include "nmos/control_protocol_ws_api.h" @@ -60,6 +61,7 @@ namespace nmos nmos::node_api_target_handler target_handler = nmos::make_node_api_target_handler(node_model, node_implementation.load_ca_certificates, node_implementation.parse_transport_file, node_implementation.validate_staged, node_implementation.get_authorization_bearer_token); auto validate_authorization = node_implementation.validate_authorization; node_server.api_routers[{ {}, nmos::fields::node_port(node_model.settings) }].mount({}, nmos::make_node_api(node_model, target_handler, validate_authorization ? validate_authorization(nmos::experimental::scopes::node) : nullptr, gate)); + node_server.api_routers[{ {}, nmos::fields::annotation_port(node_model.settings) }].mount({}, nmos::make_annotation_api(node_model, node_implementation.merge_annotation_patch, gate)); node_server.api_routers[{ {}, nmos::experimental::fields::manifest_port(node_model.settings) }].mount({}, nmos::experimental::make_manifest_api(node_model, gate)); // Configure the Connection API diff --git a/Development/nmos/node_server.h b/Development/nmos/node_server.h index 25a15d4b7..fd7f195cc 100644 --- a/Development/nmos/node_server.h +++ b/Development/nmos/node_server.h @@ -1,6 +1,7 @@ #ifndef NMOS_NODE_SERVER_H #define NMOS_NODE_SERVER_H +#include "nmos/annotation_api.h" #include "nmos/authorization_handlers.h" #include "nmos/certificate_handlers.h" #include "nmos/channelmapping_api.h" @@ -70,6 +71,7 @@ namespace nmos node_implementation& on_connection_activated(nmos::connection_activation_handler connection_activated) { this->connection_activated = std::move(connection_activated); return *this; } node_implementation& on_validate_channelmapping_output_map(nmos::details::channelmapping_output_map_validator validate_map) { this->validate_map = std::move(validate_map); return *this; } node_implementation& on_channelmapping_activated(nmos::channelmapping_activation_handler channelmapping_activated) { this->channelmapping_activated = std::move(channelmapping_activated); return *this; } + node_implementation& on_merge_annotation_patch(nmos::annotation_patch_merger merge_annotation_patch) { this->merge_annotation_patch = std::move(merge_annotation_patch); return *this; } node_implementation& on_get_ocsp_response(nmos::ocsp_response_handler get_ocsp_response) { this->get_ocsp_response = std::move(get_ocsp_response); return *this; } node_implementation& on_get_authorization_bearer_token(get_authorization_bearer_token_handler get_authorization_bearer_token) { this->get_authorization_bearer_token = std::move(get_authorization_bearer_token); return *this; } node_implementation& on_validate_authorization(validate_authorization_handler validate_authorization) { this->validate_authorization = std::move(validate_authorization); return *this; } @@ -110,6 +112,8 @@ namespace nmos nmos::channelmapping_activation_handler channelmapping_activated; + nmos::annotation_patch_merger merge_annotation_patch; + nmos::ocsp_response_handler get_ocsp_response; get_authorization_bearer_token_handler get_authorization_bearer_token; diff --git a/Development/nmos/settings.cpp b/Development/nmos/settings.cpp index 5608fbcac..51f0c96c5 100644 --- a/Development/nmos/settings.cpp +++ b/Development/nmos/settings.cpp @@ -72,6 +72,7 @@ namespace nmos if (registry) web::json::insert(settings, std::make_pair(nmos::fields::query_ws_port, ws_port)); if (registry) web::json::insert(settings, std::make_pair(nmos::fields::registration_port, http_port)); web::json::insert(settings, std::make_pair(nmos::fields::node_port, http_port)); + if (!registry) web::json::insert(settings, std::make_pair(nmos::fields::annotation_port, http_port)); if (registry) web::json::insert(settings, std::make_pair(nmos::fields::system_port, http_port)); if (!registry) web::json::insert(settings, std::make_pair(nmos::fields::connection_port, http_port)); if (!registry) web::json::insert(settings, std::make_pair(nmos::fields::events_port, http_port)); diff --git a/Development/nmos/settings.h b/Development/nmos/settings.h index c0981f8bf..267f9c358 100644 --- a/Development/nmos/settings.h +++ b/Development/nmos/settings.h @@ -107,6 +107,9 @@ namespace nmos // is12_versions [node]: used to specify the enabled API versions for a version-locked configuration const web::json::field_as_array is12_versions{ U("is12_versions") }; // when omitted, nmos::is12_versions::all is used + // is13_versions [node]: used to specify the enabled API versions for a version-locked configuration + const web::json::field_as_array is13_versions{ U("is13_versions") }; // when omitted, nmos::is13_versions::all is used + // pri [registry, node]: used for the 'pri' TXT record; specifying nmos::service_priorities::no_priority (maximum value) disables advertisement completely const web::json::field_as_integer_or pri{ U("pri"), 100 }; // default to highest_development_priority @@ -147,6 +150,7 @@ namespace nmos const web::json::field_as_integer_or events_port{ U("events_port"), 3216 }; const web::json::field_as_integer_or events_ws_port{ U("events_ws_port"), 3217 }; const web::json::field_as_integer_or channelmapping_port{ U("channelmapping_port"), 3215 }; + const web::json::field_as_integer_or annotation_port{ U("annotation_port"), 3212 }; // system_port [node]: used to construct request URLs for the System API (if not discovered via DNS-SD) const web::json::field_as_integer_or system_port{ U("system_port"), 10641 }; // control_protocol_ws_port [node]: used to construct request URLs for the Control Protocol websocket, or negative to disable the control protocol features diff --git a/Development/nmos/test/annotation_api_test.cpp b/Development/nmos/test/annotation_api_test.cpp new file mode 100644 index 000000000..f471fd34c --- /dev/null +++ b/Development/nmos/test/annotation_api_test.cpp @@ -0,0 +1,105 @@ +// The first "test" is of course whether the header compiles standalone +#include "nmos/annotation_api.h" + +#include "bst/test/test.h" +#include "nmos/group_hint.h" +#include "nmos/json_fields.h" + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testMergeAnnotationPatch) +{ + using web::json::value; + using web::json::value_of; + + const auto source = value_of({ + { nmos::fields::label, U("meow") }, + { nmos::fields::description, U("purr") }, + { nmos::fields::tags, value_of({ + { U("foo"), value_of({ U("hiss"), U("yowl") }) }, + { nmos::fields::group_hint, value_of({ nmos::make_group_hint({ U("bar"), U("baz") }) }) } + }) } + }); + + // empty patch + { + auto merged(source); + nmos::details::merge_annotation_patch(merged, value::object()); + BST_REQUIRE_EQUAL(source, merged); + } + + // reset everything + { + auto merged(source); + nmos::details::merge_annotation_patch(merged, value_of({ + { nmos::fields::label, {} }, + { nmos::fields::description, {} }, + { nmos::fields::tags, {} } + })); + BST_REQUIRE(nmos::fields::label(merged).empty()); + BST_REQUIRE(nmos::fields::description(merged).empty()); + const auto& tags = merged.at(nmos::fields::tags); + BST_REQUIRE_EQUAL(1, tags.size()); + const auto& group_hint = nmos::fields::group_hint(tags); + BST_REQUIRE_EQUAL(1, group_hint.size()); + BST_REQUIRE_EQUAL(U("bar:baz"), group_hint.at(0).as_string()); + } + + // try to reset read-only tag + { + auto merged(source); + BST_REQUIRE_THROW(nmos::details::merge_annotation_patch(merged, value_of({ + { nmos::fields::tags, value_of({ + { nmos::fields::group_hint, {} } + }) } + })), std::runtime_error); + } + + // try to update read-only tag + { + auto merged(source); + BST_REQUIRE_THROW(nmos::details::merge_annotation_patch(merged, value_of({ + { nmos::fields::tags, value_of({ + { nmos::fields::group_hint, value_of({ nmos::make_group_hint({ U("qux"), U("quux") }) }) } + }) } + })), std::runtime_error); + } + + // add and remove tags + { + auto merged(source); + nmos::details::merge_annotation_patch(merged, value_of({ + { nmos::fields::tags, value_of({ + { U("foo"), {} }, + { U("bar"), value_of({ U("woof"), U("bark") }) }, + { U("baz"), {} } + }) } + })); + const auto& tags = merged.at(nmos::fields::tags); + BST_REQUIRE_EQUAL(2, tags.size()); + BST_REQUIRE(!tags.has_field(U("foo"))); + BST_REQUIRE(tags.has_field(U("bar"))); + const auto& bar = tags.at(U("bar")); + BST_REQUIRE_EQUAL(2, bar.size()); + BST_REQUIRE_EQUAL(U("bark"), bar.at(1).as_string()); + } + + // change label, description and tags + { + auto merged(source); + nmos::details::merge_annotation_patch(merged, value_of({ + { nmos::fields::label, U("woof") }, + { nmos::fields::description, U("bark") }, + { nmos::fields::tags, value_of({ + { U("foo"), value_of({ U("growl") })} + }) } + })); + BST_REQUIRE_EQUAL(U("woof"), nmos::fields::label(merged)); + BST_REQUIRE_EQUAL(U("bark"), nmos::fields::description(merged)); + const auto& tags = merged.at(nmos::fields::tags); + BST_REQUIRE_EQUAL(2, tags.size()); + BST_REQUIRE(tags.has_field(U("foo"))); + const auto& foo = tags.at(U("foo")); + BST_REQUIRE_EQUAL(1, foo.size()); + BST_REQUIRE_EQUAL(U("growl"), foo.at(0).as_string()); + } +} diff --git a/Development/third_party/README.md b/Development/third_party/README.md index 553116255..5f29fd91c 100644 --- a/Development/third_party/README.md +++ b/Development/third_party/README.md @@ -22,5 +22,7 @@ Third-party source files used by the nmos-cpp libraries The JSON Schema files used for validation of System API requests and responses - [is-10](is-10) The JSON Schema files used for validation of Authorization API requests and responses +- [is-13](is-13) + The JSON Schema files used for validation of Annotation API requests and responses - [WpdPack](WpdPack) Libraries and header files from the [WinPcap](https://www.winpcap.org/) Developer's Pack diff --git a/Development/third_party/is-13/README.md b/Development/third_party/is-13/README.md new file mode 100644 index 000000000..d6fa7c7b2 --- /dev/null +++ b/Development/third_party/is-13/README.md @@ -0,0 +1,8 @@ +# AMWA IS-13 NMOS Annotation Specification + +This directory contains files from the [AMWA IS-13 NMOS Annotation Specification](https://github.com/AMWA-TV/is-13), in particular tagged versions of the JSON schemas used by the API specifications. + +Original source code: + +- (c) AMWA 2023 +- Licensed under the Apache License, Version 2.0; http://www.apache.org/licenses/LICENSE-2.0 diff --git a/Development/third_party/is-13/v1.0-dev/APIs/schemas/annotationapi-base.json b/Development/third_party/is-13/v1.0-dev/APIs/schemas/annotationapi-base.json new file mode 100644 index 000000000..2c846e14f --- /dev/null +++ b/Development/third_party/is-13/v1.0-dev/APIs/schemas/annotationapi-base.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Describes the Annotation API base resource", + "title": "Annotation API base resource", + "items": { + "type": "string", + "enum": [ + "node/" + ] + }, + "minItems": 1, + "maxItems": 1, + "uniqueItems": true +} diff --git a/Development/third_party/is-13/v1.0-dev/APIs/schemas/annotationapi-node-base.json b/Development/third_party/is-13/v1.0-dev/APIs/schemas/annotationapi-node-base.json new file mode 100644 index 000000000..9d89a6399 --- /dev/null +++ b/Development/third_party/is-13/v1.0-dev/APIs/schemas/annotationapi-node-base.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Describes the Annotation API node resource", + "title": "Annotation API node resource", + "items": { + "type": "string", + "enum": [ + "self/", + "sources/", + "flows/", + "devices/", + "senders/", + "receivers/" + ] + }, + "minItems": 6, + "maxItems": 6, + "uniqueItems": true +} diff --git a/Development/third_party/is-13/v1.0-dev/APIs/schemas/error.json b/Development/third_party/is-13/v1.0-dev/APIs/schemas/error.json new file mode 100644 index 000000000..402147b52 --- /dev/null +++ b/Development/third_party/is-13/v1.0-dev/APIs/schemas/error.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Describes the standard error response which is returned with HTTP codes 400 and above", + "title": "Error response", + "required": [ + "code", + "error", + "debug" + ], + "properties": { + "code": { + "description": "HTTP error code", + "type": "integer", + "minimum": 400, + "maximum": 599 + }, + "error": { + "description": "Human readable message which is suitable for user interface display, and helpful to the user", + "type": "string" + }, + "debug": { + "description": "Debug information which may assist a programmer working with the API", + "type": ["null", "string"] + } + } +} diff --git a/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource-list.json b/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource-list.json new file mode 100644 index 000000000..7239fc400 --- /dev/null +++ b/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource-list.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema", + "type": "array", + "description": "List of resource ID paths", + "title": "Resources base resource", + "items": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/$" + }, + "uniqueItems": true +} diff --git a/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource_core.json b/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource_core.json new file mode 100644 index 000000000..4bb69f8dc --- /dev/null +++ b/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource_core.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Describes the foundations of all NMOS resources", + "title": "Base resource", + "required": [ + "id", + "version", + "label", + "description", + "tags" + ], + "properties": { + "id": { + "description": "Globally unique identifier for the resource", + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "version": { + "description": "String formatted TAI timestamp (:) indicating precisely when an attribute of the resource last changed", + "type": "string", + "pattern": "^[0-9]+:[0-9]+$" + }, + "label": { + "description": "Freeform string label for the resource", + "type": "string" + }, + "description": { + "description": "Detailed description of the resource", + "type": "string" + }, + "tags": { + "description": "Key value set of freeform string tags to aid in filtering resources. Values should be represented as an array of strings. Can be empty.", + "type": "object", + "patternProperties": { + "": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource_core_patch.json b/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource_core_patch.json new file mode 100644 index 000000000..e49745c76 --- /dev/null +++ b/Development/third_party/is-13/v1.0-dev/APIs/schemas/resource_core_patch.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Describes the foundations of all NMOS resources", + "title": "Base resource", + "additionalProperties": false, + "properties": { + "label": { + "description": "Freeform string label for the resource. Set to null to restore default label.", + "type": ["null", "string"] + }, + "description": { + "description": "Detailed description of the resource. Set to null to restore default description.", + "type": ["null", "string"] + }, + "tags": { + "description": "Key value set of freeform string tags to aid in filtering resources. Set to null to restore default tags. Values should be represented as an array of strings. Can be empty. Set to null to delete or restore default values for a tag.", + "type": ["null", "object"], + "patternProperties": { + "": { + "type": ["null", "array"], + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/README.md b/README.md index e34716fdb..9feba6026 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ This repository contains an implementation of the [AMWA Networked Media Open Spe - [AMWA IS-08 NMOS Audio Channel Mapping Specification](https://specs.amwa.tv/is-08/) - [AMWA IS-09 NMOS System Parameters Specification](https://specs.amwa.tv/is-09/) (originally defined in JT-NM TR-1001-1:2018 Annex A) - [AMWA IS-10 NMOS Authorization Specification](https://specs.amwa.tv/is-10/) -- [AMWA IS-12 AMWA IS-12 NMOS Control Protocol](https://specs.amwa.tv/is-12/) +- [AMWA IS-12 NMOS Control Protocol](https://specs.amwa.tv/is-12/) +- [AMWA IS-13 NMOS Annotation Specification](https://specs.amwa.tv/is-13/) - [AMWA BCP-002-01 NMOS Grouping Recommendations - Natural Grouping](https://specs.amwa.tv/bcp-002-01/) - [AMWA BCP-002-02 NMOS Asset Distinguishing Information](https://specs.amwa.tv/bcp-002-02/) - [AMWA BCP-003-01 Secure Communication in NMOS Systems](https://specs.amwa.tv/bcp-003-01/) @@ -122,6 +123,7 @@ The implementation is designed to be extended. Development is ongoing, following Recent activity on the project (newest first): +- Added support for IS-13 v1.0-dev - Added support for the IS-12 NMOS Control Protocol - Update to Conan 2; Conan 1.X is no longer supported - Added support for IS-10 Authorization