diff --git a/a2a_agents/cpp/.gitignore b/a2a_agents/cpp/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/a2a_agents/cpp/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/a2a_agents/cpp/CMakeLists.txt b/a2a_agents/cpp/CMakeLists.txt new file mode 100644 index 000000000..f267c403d --- /dev/null +++ b/a2a_agents/cpp/CMakeLists.txt @@ -0,0 +1,46 @@ +cmake_minimum_required(VERSION 3.14) +project(A2UI_CPP_Agent) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED True) + +include(FetchContent) + +# nlohmann/json +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 +) +FetchContent_MakeAvailable(nlohmann_json) + +# pboettch/json-schema-validator +FetchContent_Declare( + nlohmann_json_schema_validator + GIT_REPOSITORY https://github.com/pboettch/json-schema-validator.git + GIT_TAG 2.3.0 +) +FetchContent_MakeAvailable(nlohmann_json_schema_validator) + +# GoogleTest +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) +# For Windows: Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +# library: a2ui_validation +add_library(a2ui_validation src/validation.cpp) +target_include_directories(a2ui_validation PUBLIC include) +target_link_libraries(a2ui_validation PUBLIC nlohmann_json::nlohmann_json nlohmann_json_schema_validator) + +# executable: test_validation +enable_testing() +add_executable(test_validation tests/test_validation.cpp) +target_link_libraries(test_validation PRIVATE a2ui_validation GTest::gtest_main GTest::gmock) + +include(GoogleTest) +gtest_discover_tests(test_validation) diff --git a/a2a_agents/cpp/README.md b/a2a_agents/cpp/README.md new file mode 100644 index 000000000..dde9ac4fe --- /dev/null +++ b/a2a_agents/cpp/README.md @@ -0,0 +1,40 @@ +# A2UI C++ Agent implementation + +The `a2a_agents/cpp/` directory contains the C++ implementation of the A2UI agent library. + +## Components + +The library provides validation logic for A2UI protocol messages: + +* **`a2ui_validation`**: A library for validating A2UI JSON messages against the schema and semantic rules. + * Header: `include/a2ui/validation.hpp` + * Source: `src/validation.cpp` + +## Running tests + +The project uses CMake and GoogleTest. + +1. Navigate to the C++ agent directory: + ```bash + cd a2a_agents/cpp + ``` + +2. Create a build directory and configure: + ```bash + mkdir -p build + cd build + cmake .. + ``` + +3. Build and run the tests: + ```bash + make + ./test_validation + ``` + +## Dependencies + +The following dependencies are automatically fetched via CMake `FetchContent`: +* [nlohmann/json](https://github.com/nlohmann/json): JSON for Modern C++ +* [pboettch/json-schema-validator](https://github.com/pboettch/json-schema-validator): JSON Schema Validator for JSON for Modern C++ +* [GoogleTest](https://github.com/google/googletest): Google Testing and Mocking Framework \ No newline at end of file diff --git a/a2a_agents/cpp/include/a2ui/validation.hpp b/a2a_agents/cpp/include/a2ui/validation.hpp new file mode 100644 index 000000000..93ccc903e --- /dev/null +++ b/a2a_agents/cpp/include/a2ui/validation.hpp @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace a2ui { + +/** + * Validates the A2UI JSON payload against the provided schema and checks for integrity. + * + * The payload can be a single message object or an array of message objects. + * + * Note: Does not support streaming or partial messages. + * TODO: Support streaming. + * + * Checks performed: + * 1. **JSON Schema Validation**: Ensures payload adheres to the A2UI schema. + * 2. **Component Integrity**: + * - All component IDs are unique. + * - A 'root' component exists. + * - All unique component references point to valid IDs. + * 3. **Topology**: + * - No circular references (including self-references). + * - No orphaned components (all components must be reachable from 'root'). + * 4. **Recursion Limits**: + * - Global recursion depth limit (50). + * - FunctionCall recursion depth limit (5). + * 5. **Path Syntax**: + * - Validates JSON Pointer syntax for data paths. + * + * @param a2ui_json The JSON payload to validate. + * @param a2ui_schema The schema to validate against. + * + * @throws std::invalid_argument If integrity, topology, or recursion checks fail, or if payload does not match schema. + */ +void validate_a2ui_json(const nlohmann::json& a2ui_json, const nlohmann::json& a2ui_schema); + +} // namespace a2ui diff --git a/a2a_agents/cpp/src/validation.cpp b/a2a_agents/cpp/src/validation.cpp new file mode 100644 index 000000000..5eed62aa3 --- /dev/null +++ b/a2a_agents/cpp/src/validation.cpp @@ -0,0 +1,323 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "a2ui/validation.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace a2ui { + +namespace { + +const std::regex JSON_POINTER_PATTERN(R"(^(?:\/(?:[^~\/]|~[01])*)*$)"); +const int MAX_GLOBAL_DEPTH = 50; +const int MAX_FUNC_CALL_DEPTH = 5; + +const std::string COMPONENTS = "components"; +const std::string ID = "id"; +const std::string COMPONENT_PROPERTIES = "componentProperties"; +const std::string ROOT = "root"; +const std::string PATH = "path"; +const std::string FUNCTION_CALL = "functionCall"; +const std::string CALL = "call"; +const std::string ARGS = "args"; + +struct RefMap { + std::unordered_set single_refs; + std::unordered_set list_refs; +}; + +using RefFieldsMap = std::unordered_map; + +bool is_component_id_ref(const nlohmann::json& prop_schema) { + auto it = prop_schema.find("$ref"); + if (it != prop_schema.end() && it->is_string()) { + std::string ref = it->get(); + if (ref.size() >= 11 && ref.substr(ref.size() - 11) == "ComponentId") { + return true; + } + } + return false; +} + +bool is_child_list_ref(const nlohmann::json& prop_schema) { + auto it = prop_schema.find("$ref"); + if (it != prop_schema.end() && it->is_string()) { + std::string ref = it->get(); + if (ref.size() >= 9 && ref.substr(ref.size() - 9) == "ChildList") { + return true; + } + } + auto type_it = prop_schema.find("type"); + if (type_it != prop_schema.end() && *type_it == "array") { + auto items_it = prop_schema.find("items"); + if (items_it != prop_schema.end()) { + return is_component_id_ref(*items_it); + } + } + return false; +} + +RefFieldsMap _extract_component_ref_fields(const nlohmann::json& schema) { + RefFieldsMap ref_map; + + const auto& all_components = schema.at(nlohmann::json::json_pointer("/properties/components/items/properties/componentProperties/properties")); + for (auto it = all_components.begin(); it != all_components.end(); ++it) { + std::string comp_name = it.key(); + auto comp_schema = it.value(); + + RefMap refs; + if (!comp_schema.contains("properties") || !comp_schema["properties"].is_object()) continue; + const auto& props = comp_schema["properties"]; + for (auto prop_it = props.begin(); prop_it != props.end(); ++prop_it) { + std::string prop_name = prop_it.key(); + auto prop_schema = prop_it.value(); + if (is_component_id_ref(prop_schema)) { + refs.single_refs.insert(prop_name); + } else if (is_child_list_ref(prop_schema)) { + refs.list_refs.insert(prop_name); + } + } + if (!refs.single_refs.empty() || !refs.list_refs.empty()) { + ref_map[comp_name] = refs; + } + } + return ref_map; +} + +std::vector> _get_component_references( + const nlohmann::json& component, const RefFieldsMap& ref_fields_map) { + + std::vector> refs; + + auto comp_props_it = component.find(COMPONENT_PROPERTIES); + if (comp_props_it == component.end() || !comp_props_it->is_object()) return refs; + auto comp_props = *comp_props_it; + + for (auto it = comp_props.begin(); it != comp_props.end(); ++it) { + std::string comp_type = it.key(); + auto props = it.value(); + if (!props.is_object()) continue; + + auto map_it = ref_fields_map.find(comp_type); + if (map_it == ref_fields_map.end()) continue; + const auto& [single_refs, list_refs] = map_it->second; + + for (auto prop_it = props.begin(); prop_it != props.end(); ++prop_it) { + std::string key = prop_it.key(); + const auto& value = prop_it.value(); + + if (single_refs.count(key) && value.is_string()) { + refs.push_back({value.get(), key}); + } else if (list_refs.count(key) && value.is_array()) { + for (const auto& item : value) { + if (item.is_string()) { + refs.push_back({item.get(), key}); + } + } + } + } + } + return refs; +} + +void _validate_component_integrity(const nlohmann::json& components, const RefFieldsMap& ref_fields_map) { + std::unordered_set ids; + + for (const auto& comp : components) { + if (!comp.is_object()) { throw std::invalid_argument("Component must be an object."); } + auto id_it = comp.find(ID); + if (id_it == comp.end()) { throw std::invalid_argument("Component missing 'id' field."); } + if (!id_it->is_string()) { throw std::invalid_argument("Component 'id' must be a string."); } + std::string comp_id = id_it->get(); + + if (ids.count(comp_id)) { + throw std::invalid_argument("Duplicate component ID found: '" + comp_id + "'"); + } + ids.insert(comp_id); + } + + if (!ids.count(ROOT)) { + throw std::invalid_argument("Missing 'root' component: One component must have 'id' set to 'root'."); + } + + for (const auto& comp : components) { + std::string comp_id = comp.value(ID, ""); + auto refs = _get_component_references(comp, ref_fields_map); + for (const auto& ref : refs) { + if (!ids.count(ref.first)) { + throw std::invalid_argument("Component '" + comp_id + "' references missing ID '" + ref.first + "' in field '" + ref.second + "'"); + } + } + } +} + +void _validate_topology(const nlohmann::json& components, const RefFieldsMap& ref_fields_map) { + std::unordered_map> adj_list; + std::unordered_set all_ids; + + for (const auto& comp : components) { + if (!comp.is_object()) { throw std::invalid_argument("Component must be an object."); } + auto id_it = comp.find(ID); + if (id_it == comp.end()) { throw std::invalid_argument("Component missing 'id' field."); } + if (!id_it->is_string()) { throw std::invalid_argument("Component 'id' must be a string."); } + std::string comp_id = id_it->get(); + + all_ids.insert(comp_id); + if (adj_list.find(comp_id) == adj_list.end()) { + adj_list[comp_id] = {}; + } + + auto refs = _get_component_references(comp, ref_fields_map); + for (const auto& ref : refs) { + if (ref.first == comp_id) { + throw std::invalid_argument("Self-reference detected: Component '" + comp_id + "' references itself in field '" + ref.second + "'"); + } + adj_list[comp_id].push_back(ref.first); + } + } + + std::unordered_set visited; + std::unordered_set recursion_stack; + + std::function dfs = [&](const std::string& node_id) { + visited.insert(node_id); + recursion_stack.insert(node_id); + + for (const auto& neighbor : adj_list[node_id]) { + if (!visited.count(neighbor)) { + dfs(neighbor); + } else if (recursion_stack.count(neighbor)) { + throw std::invalid_argument("Circular reference detected involving component '" + neighbor + "'"); + } + } + + recursion_stack.erase(node_id); + }; + + if (all_ids.count(ROOT)) { + dfs(ROOT); + } + + std::unordered_set orphans; + for (const auto& id : all_ids) { + if (!visited.count(id)) { + orphans.insert(id); + } + } + + if (!orphans.empty()) { + std::string err = "Orphaned components detected (not reachable from 'root'): ["; + std::vector sorted_orphans(orphans.begin(), orphans.end()); + std::sort(sorted_orphans.begin(), sorted_orphans.end()); + bool first = true; + for (const auto& orphan : sorted_orphans) { + if (!first) err += ", "; + err += "'" + orphan + "'"; + first = false; + } + err += "]"; + throw std::invalid_argument(err); + } +} + +void _traverse(const nlohmann::json& item, int global_depth, int func_depth) { + if (global_depth > MAX_GLOBAL_DEPTH) { + throw std::invalid_argument("Global recursion limit exceeded: Depth > " + std::to_string(MAX_GLOBAL_DEPTH)); + } + + if (item.is_array()) { + for (const auto& x : item) { + _traverse(x, global_depth + 1, func_depth); + } + return; + } + + if (item.is_object()) { + auto path_it = item.find(PATH); + if (path_it != item.end() && path_it->is_string()) { + std::string path = path_it->get(); + if (!std::regex_match(path, JSON_POINTER_PATTERN)) { + throw std::invalid_argument("Invalid JSON Pointer syntax: '" + path + "'"); + } + } + + bool is_func = item.contains(CALL) && item.contains(ARGS); + if (is_func) { + if (func_depth >= MAX_FUNC_CALL_DEPTH) { + throw std::invalid_argument("Recursion limit exceeded: " + FUNCTION_CALL + " depth > " + std::to_string(MAX_FUNC_CALL_DEPTH)); + } + for (auto it = item.begin(); it != item.end(); ++it) { + if (it.key() == ARGS) { + _traverse(it.value(), global_depth + 1, func_depth + 1); + } else { + _traverse(it.value(), global_depth + 1, func_depth); + } + } + } else { + for (auto it = item.begin(); it != item.end(); ++it) { + _traverse(it.value(), global_depth + 1, func_depth); + } + } + } +} + +void _validate_recursion_and_paths(const nlohmann::json& data) { + _traverse(data, 0, 0); +} + +} // namespace + +void validate_a2ui_json(const nlohmann::json& a2ui_json, const nlohmann::json& a2ui_schema) { + auto process_message = [&](const nlohmann::json& message) { + if (!message.is_object()) return; + + auto comps_it = message.find(COMPONENTS); + if (comps_it != message.end() && comps_it->is_array()) { + auto ref_map = _extract_component_ref_fields(a2ui_schema); + _validate_component_integrity(*comps_it, ref_map); + _validate_topology(*comps_it, ref_map); + } + + _validate_recursion_and_paths(message); + }; + + if (a2ui_json.is_array()) { + for (const auto& item : a2ui_json) { + process_message(item); + } + } else { + process_message(a2ui_json); + } + + nlohmann::json_schema::json_validator validator; + try { + validator.set_root_schema(a2ui_schema); + validator.validate(a2ui_json); + } catch (const std::exception& e) { + throw std::invalid_argument(std::string("Schema validation failed: ") + e.what()); + } +} + +} // namespace a2ui diff --git a/a2a_agents/cpp/tests/test_validation.cpp b/a2a_agents/cpp/tests/test_validation.cpp new file mode 100644 index 000000000..412003dc0 --- /dev/null +++ b/a2a_agents/cpp/tests/test_validation.cpp @@ -0,0 +1,428 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include "a2ui/validation.hpp" + +using json = nlohmann::json; + +class ValidationTest : public ::testing::Test { +protected: + void SetUp() override { + schema = json::parse(R"({ + "type": "object", + "$defs": { + "ComponentId": {"type": "string"}, + "ChildList": {"type": "array", "items": {"$ref": "#/$defs/ComponentId"}} + }, + "properties": { + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/$defs/ComponentId"}, + "componentProperties": { + "type": "object", + "properties": { + "Column": { + "type": "object", + "properties": { + "children": {"$ref": "#/$defs/ChildList"} + } + }, + "Row": { + "type": "object", + "properties": { + "children": {"$ref": "#/$defs/ChildList"} + } + }, + "Container": { + "type": "object", + "properties": { + "children": {"$ref": "#/$defs/ChildList"} + } + }, + "Card": { + "type": "object", + "properties": { + "child": {"$ref": "#/$defs/ComponentId"} + } + }, + "Button": { + "type": "object", + "properties": { + "child": {"$ref": "#/$defs/ComponentId"}, + "action": { + "properties": { + "functionCall": { + "properties": { + "call": {"type": "string"}, + "args": {"type": "object"} + } + } + } + } + } + }, + "Text": { + "type": "object", + "properties": { + "text": { + "oneOf": [ + {"type": "string"}, + {"type": "object"} + ] + } + } + } + } + } + }, + "required": ["id"] + } + } + } + })"); + } + + json schema; +}; + +TEST_F(ValidationTest, ValidIntegrity) { + json payload = json::parse(R"({ + "components": [ + {"id": "root", "componentProperties": {"Column": {"children": ["child1"]}}}, + {"id": "child1", "componentProperties": {"Text": {"text": "Hello"}}} + ] + })"); + EXPECT_NO_THROW(a2ui::validate_a2ui_json(payload, schema)); +} + +TEST_F(ValidationTest, DuplicateIds) { + json payload = json::parse(R"({ + "components": [ + {"id": "root", "componentProperties": {}}, + {"id": "root", "componentProperties": {}} + ] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Duplicate component ID found: 'root'"), std::string::npos); + } +} + +TEST_F(ValidationTest, MissingRoot) { + json payload = json::parse(R"({ + "components": [{"id": "not-root", "componentProperties": {}}] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Missing 'root' component"), std::string::npos); + } +} + +TEST_F(ValidationTest, DanglingReferencesCard) { + json payload = json::parse(R"({ + "components": [{"id": "root", "componentProperties": {"Card": {"child": "missing_child"}}}] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Component 'root' references missing ID 'missing_child' in field 'child'"), std::string::npos); + } +} + +TEST_F(ValidationTest, DanglingReferencesColumn) { + json payload = json::parse(R"({ + "components": [ + {"id": "root", "componentProperties": {"Column": {"children": ["child1", "missing_child"]}}}, + {"id": "child1", "componentProperties": {}} + ] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Component 'root' references missing ID 'missing_child' in field 'children'"), std::string::npos); + } +} + +TEST_F(ValidationTest, SelfReference) { + json payload = json::parse(R"({ + "components": [ + {"id": "root", "componentProperties": {"Container": {"children": ["root"]}}} + ] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Self-reference detected: Component 'root' references itself in field 'children'"), std::string::npos); + } +} + +TEST_F(ValidationTest, CircularReference) { + json payload = json::parse(R"({ + "components": [ + { + "id": "root", + "componentProperties": {"Container": {"children": ["child1"]}} + }, + { + "id": "child1", + "componentProperties": {"Container": {"children": ["root"]}} + } + ] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Circular reference detected involving component"), std::string::npos); + } +} + +TEST_F(ValidationTest, OrphanedComponent) { + json payload = json::parse(R"({ + "components": [ + {"id": "root", "componentProperties": {"Container": {"children": []}}}, + {"id": "orphan", "componentProperties": {}} + ] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Orphaned components detected (not reachable from 'root'): ['orphan']"), std::string::npos); + } +} + +TEST_F(ValidationTest, ValidTopologyComplex) { + json payload = json::parse(R"({ + "components": [ + { + "id": "root", + "componentProperties": {"Container": {"children": ["child1", "child2"]}} + }, + {"id": "child1", "componentProperties": {"Text": {"text": "Hello"}}}, + { + "id": "child2", + "componentProperties": {"Container": {"children": ["child3"]}} + }, + {"id": "child3", "componentProperties": {"Text": {"text": "World"}}} + ] + })"); + EXPECT_NO_THROW(a2ui::validate_a2ui_json(payload, schema)); +} + +TEST_F(ValidationTest, RecursionLimitExceeded) { + json args = json::object(); + json* current = &args; + for (int i = 0; i < 5; ++i) { + (*current)["arg"] = {{"call", "fn" + std::to_string(i)}, {"args", json::object()}}; + current = &(*current)["arg"]["args"]; + } + json payload = { + {"components", { + { + {"id", "root"}, + {"componentProperties", { + {"Button", { + {"label", "Click me"}, + {"action", { + {"functionCall", { + {"call", "fn_top"}, + {"args", args} + }} + }} + }} + }} + } + }} + }; + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Recursion limit exceeded"), std::string::npos); + } +} + +TEST_F(ValidationTest, RecursionLimitValid) { + json args = json::object(); + json* current = &args; + for (int i = 0; i < 4; ++i) { + (*current)["arg"] = {{"call", "fn" + std::to_string(i)}, {"args", json::object()}}; + current = &(*current)["arg"]["args"]; + } + json payload = { + {"components", { + { + {"id", "root"}, + {"componentProperties", { + {"Button", { + {"label", "Click me"}, + {"action", { + {"functionCall", { + {"call", "fn_top"}, + {"args", args} + }} + }} + }} + }} + } + }} + }; + EXPECT_NO_THROW(a2ui::validate_a2ui_json(payload, schema)); +} + +TEST_F(ValidationTest, InvalidPath1) { + json payload = json::parse(R"({ + "updateDataModel": { + "surfaceId": "surface1", + "path": "invalid//path", + "value": "data" + } + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Invalid JSON Pointer syntax"), std::string::npos); + } +} + +TEST_F(ValidationTest, InvalidPath2) { + json payload = json::parse(R"({ + "components": [{ + "id": "root", + "componentProperties": { + "Text": {"text": {"path": "invalid path with spaces"}} + } + }] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Invalid JSON Pointer syntax"), std::string::npos); + } +} + +TEST_F(ValidationTest, InvalidPath3) { + json payload = json::parse(R"({ + "updateDataModel": { + "surfaceId": "surface1", + "path": "/invalid/escape/~2", + "value": "data" + } + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Invalid JSON Pointer syntax"), std::string::npos); + } +} + +TEST_F(ValidationTest, GlobalRecursionLimitExceeded) { + json deep_payload = {{"level", 0}}; + json* current = &deep_payload; + for (int i = 0; i < 55; ++i) { + (*current)["next"] = {{"level", i + 1}}; + current = &(*current)["next"]; + } + try { + a2ui::validate_a2ui_json(deep_payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Global recursion limit exceeded"), std::string::npos); + } +} + +TEST_F(ValidationTest, NonStringId) { + json payload = json::parse(R"({ + "components": [ + {"id": 123, "componentProperties": {}} + ] + })"); + try { + a2ui::validate_a2ui_json(payload, schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Component 'id' must be a string."), std::string::npos); + } +} + +TEST_F(ValidationTest, CustomSchemaReference) { + json custom_schema = json::parse(R"({ + "type": "object", + "$defs": { + "ComponentId": {"type": "string"}, + "ChildList": {"type": "array", "items": {"$ref": "#/$defs/ComponentId"}} + }, + "properties": { + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"$ref": "#/$defs/ComponentId"}, + "componentProperties": { + "type": "object", + "properties": { + "CustomLink": { + "type": "object", + "properties": { + "linkedComponentId": { + "$ref": "#/$defs/ComponentId" + } + } + } + } + } + }, + "required": ["id"] + } + } + } + })"); + + json payload = json::parse(R"({ + "components": [ + { + "id": "root", + "componentProperties": { + "CustomLink": {"linkedComponentId": "missing_target"} + } + } + ] + })"); + + try { + a2ui::validate_a2ui_json(payload, custom_schema); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("Component 'root' references missing ID 'missing_target' in field 'linkedComponentId'"), std::string::npos); + } +} +