From ef0893c25911cc9df209becf46d9e436ebe3cb1c Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Fri, 7 Aug 2026 00:01:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 87 ++++- backend/CMakeLists.txt | 65 +++- backend/include/adminive/adapter.hpp | 81 ++++- backend/include/adminive/adapters/drogon.hpp | 140 ++++++- backend/include/adminive/adapters/httplib.hpp | 32 +- .../adminive/adapters/nlohmann_json.hpp | 22 ++ backend/include/adminive/amis.hpp | 63 ++-- backend/include/adminive/concepts.hpp | 44 ++- backend/include/adminive/descriptor.hpp | 24 +- backend/include/adminive/http.hpp | 160 ++++++-- backend/include/adminive/json.hpp | 299 +++++++++------ backend/src/config_store.cpp | 91 +++-- backend/src/config_store.hpp | 4 + backend/src/device_tables.cpp | 16 +- backend/src/example.hpp | 192 +++------- backend/src/example_descriptors.hpp | 4 +- backend/src/main.cpp | 126 ++----- backend/src/server_app.cpp | 10 +- backend/tests/advanced_adapter_test.cpp | 19 +- backend/tests/config_store_test.cpp | 27 ++ backend/tests/core_adapter_test.cpp | 45 ++- backend/tests/drogon_adapter_test.cpp | 65 +++- backend/tests/fake_drogon/drogon/drogon.h | 43 ++- backend/tests/safety_test.cpp | 343 ++++++++++++++++++ backend/tests/test.cpp | 18 +- cmake/AdminiveConfig.cmake.in | 20 + 26 files changed, 1474 insertions(+), 566 deletions(-) create mode 100644 backend/tests/config_store_test.cpp create mode 100644 backend/tests/safety_test.cpp create mode 100644 cmake/AdminiveConfig.cmake.in diff --git a/README.md b/README.md index 9e20a65..b0cea2f 100644 --- a/README.md +++ b/README.md @@ -113,18 +113,24 @@ struct adminive::Object_Adapter { }; ``` -`Resource_Service` 只有一个事务入口。未传事务函数时调用 `Object_Adapter::commit()`;传入事务函数后只调用该函数,不会再重复提交: +`Resource_Service` 始终通过 `Object_Adapter::commit()`提交运行时模型。外部持久化和系统副作用通过 `Resource_Transaction` 分成准备、提交和回滚三个阶段: ```cpp -adminive::Resource_Service resource( - runtime, - "/config", - [](Runtime_Config& target, Runtime_Config_Model candidate) { - persist(candidate); - target.apply(candidate); - }); +adminive::Resource_Transaction transaction; +transaction.prepare = [](const Runtime_Config_Model& candidate, const adminive::Request_Context& context) { + validate_external_state(candidate, context); +}; +transaction.commit = [](const Runtime_Config_Model& candidate, const adminive::Request_Context&) { + persist(candidate); +}; +transaction.rollback = [](const Runtime_Config_Model& original, const adminive::Request_Context&) { + persist(original); +}; +adminive::Resource_Service resource(runtime, "/config", std::move(transaction)); ``` +`prepare` 在资源锁外执行,可用于耗时校验和预生成临时内容;`commit` 在运行时模型提交后、资源锁内执行,应只做短时原子替换。任一阶段失败时,Adminive 恢复原运行时模型并调用 `rollback`。 + ### 外部控件适配 复杂业务类型通过 `Control_Adapter` 明确决定表单控件和列表列。框架不会把 `vector` 或 `map` 隐式决定为表格、列表、分页或标签页;未配置控件的结构类型会在 Schema 生成时给出明确错误。 @@ -157,11 +163,11 @@ struct adminive::Polymorphic_Adapter { ); } static Json encode(const Device& value); - static void decode(Device& target, const Json& value); + static void decode(Device& target, const Json& value, adminive::Write_Context context); }; ``` -`decode_polymorphic_variant()` 只读取当前派生类型声明的字段,可清理切换类型后仍被前端提交的旧类型字段。 +`decode_polymorphic_alternative()` 在判别类型不变时执行补丁更新并保留未提交字段;切换类型时通过该类型的 `Object_Adapter::create()` 创建候选对象,只读取新类型声明的字段。 ### 嵌套错误和 HTTP 反馈 @@ -185,14 +191,25 @@ ADMINIVE_FIELD(T, password).sensitive().editable(); ### Drogon 生命周期和异步提交 -`Drogon_Resource` 的路由回调捕获共享的 `Resource_Service`,绑定完成后销毁 binder 不会留下悬空回调。带文件写入、脚本或系统调用的提交可传入执行器: +`Drogon_Resource` 的路由回调捕获共享的 `Resource_Service`,绑定完成后销毁 binder 不会留下悬空回调。`Drogon_Bind_Options` 同时配置异步执行器、请求上下文和 Drogon Filter: ```cpp -resource.bind(drogon::app(), [](std::function task) { +adminive::Drogon_Bind_Options options; +options.executor = [](std::function task) { worker_pool.submit(std::move(task)); -}); +}; +options.context_factory = [](const drogon::HttpRequestPtr& request) { + adminive::Request_Context context; + context.user = current_user(request); + context.remote_address = request->peerAddr().toIp(); + return context; +}; +options.filters = {"LoginFilter", "AdminPermissionFilter"}; +resource.bind(drogon::app(), std::move(options)); ``` +整体状态使用 `Drogon_Status_Resource` 注册 `/descriptor`、`/amis` 和 `/data`。所有 Drogon 路由都会把异常转换成结构化 HTTP 500。 + ### 描述器驱动状态 整体状态和列表项状态都使用 `adminive.status` 描述器生成,不再包含固定的服务名、字段名或模板。服务标题、字段标签、颜色和轮询周期由调用端传入的状态描述器决定。 @@ -205,6 +222,14 @@ Adminive/ │ ├── include/adminive/ │ ├── src/ │ │ ├── example.hpp +│ │ ├── example.cpp +│ │ ├── example_descriptors.hpp +│ │ ├── config_store.hpp +│ │ ├── config_store.cpp +│ │ ├── device_tables.hpp +│ │ ├── device_tables.cpp +│ │ ├── server_app.hpp +│ │ ├── server_app.cpp │ │ └── main.cpp │ ├── tests/ │ └── third_party/ @@ -347,7 +372,7 @@ appearance.effective_date ```cpp ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint") .editable() - .visible_on("${mode == 'duplex'}"); + .visible_on("${$self.mode == 'duplex'}"); ``` 当 `mode` 不是 `duplex` 时,整个 `Backup Endpoint` 子树隐藏。 @@ -357,11 +382,13 @@ ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint") ```cpp ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook Endpoint") .editable() - .visible_on("${enabled && channel == 'webhook'}"); + .visible_on("${$self.enabled && $self.channel == 'webhook'}"); ``` 只有启用告警且投递通道为 `webhook` 时,才显示 webhook 子配置。 +`$self` 表示当前对象的数据域。顶层字段会解析为 `${mode ...}`,嵌套对象会自动补成 `${parent.mode ...}`,避免复用子配置描述器时引用到错误的数据域。 + ## 完整输入控件 后端根据 C++ 类型自动选择基础控件: @@ -374,6 +401,8 @@ bool -> switch enum class -> select ``` +`std::optional`、`std::optional`、`std::optional`、`std::optional` 和可适配枚举使用相同基础控件并自动启用清空;JSON `null` 对应 `std::nullopt`。`std::string_view` 只允许作为只读存储。 + 日期和颜色通过后端字段描述指定: ```cpp @@ -415,12 +444,13 @@ ADMINIVE_FIELD_LABEL(T, accent_color, "Accent Color") ## 两组配置示例 -页面包含三个后端定义的标签页: +页面包含四个后端定义的标签页: ```text Radio State List Radio Service Configuration Alert Configuration +Device Tables ``` `Radio Service Configuration` 展示字符串、枚举、整数、浮点数、树状子配置、日期和颜色。 @@ -516,6 +546,21 @@ ctest --test-dir build --output-on-failure .\build\backend\Adminive_Server.exe 9999 ``` +安装并通过 CMake 包使用: + +```powershell +cmake --install build --prefix D:/Adminive +``` + +```cmake +find_package(Adminive CONFIG REQUIRED) +target_link_libraries(app PRIVATE Adminive::Core) +find_package(Adminive CONFIG REQUIRED COMPONENTS Drogon) +target_link_libraries(drogon_app PRIVATE Adminive::Drogon) +``` + +MSVC 消费者默认接收 `/utf-8` 和 `/Zc:__cplusplus`。`/permissive-` 只用于 Adminive 自身目标;确实需要传播时配置 `ADMINIVE_PROPAGATE_MSVC_STRICT_MODE=ON`。 + 访问: ```text @@ -528,8 +573,14 @@ http://127.0.0.1:9999 cmake --build build --target package_zip ``` -输出文件位置由第二个参数明确指定,第三个参数传入排除列表变量名: +压缩函数依次接收目标名、输出文件、根目录、包含列表变量名和排除列表变量名。只扫描包含列表指定的路径,再按相对于根目录的规则排除: ```cmake -add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" adminive_package_excludes) +add_project_zip_target( + package_zip + "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" + "${CMAKE_CURRENT_LIST_DIR}" + adminive_package_includes + adminive_package_excludes +) ``` diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 3140ed9..927b1c5 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -1,3 +1,5 @@ +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) @@ -6,44 +8,60 @@ set(adminive_include_dir "${adminive_backend_root}/include") set(adminive_third_party_include_dir "${adminive_backend_root}/third_party/include") set(adminive_frontend_dist_dir "${adminive_backend_root}/../frontend_dist") file(TO_CMAKE_PATH "${adminive_frontend_dist_dir}" adminive_frontend_dist_dir) +option(ADMINIVE_PROPAGATE_MSVC_STRICT_MODE "Propagate /permissive- to Adminive consumers" OFF) add_library(Adminive INTERFACE) -if(MSVC) - target_compile_options(Adminive INTERFACE /utf-8 /permissive- /Zc:__cplusplus) -endif() +set_target_properties(Adminive PROPERTIES EXPORT_NAME Core) add_library(Adminive::Adminive ALIAS Adminive) add_library(Adminive::Core ALIAS Adminive) -target_include_directories(Adminive INTERFACE "${adminive_include_dir}") +target_include_directories(Adminive INTERFACE "$" "$") target_compile_features(Adminive INTERFACE cxx_std_20) +if(MSVC) + target_compile_options(Adminive INTERFACE /utf-8 /Zc:__cplusplus) + if(ADMINIVE_PROPAGATE_MSVC_STRICT_MODE) + target_compile_options(Adminive INTERFACE /permissive-) + endif() +endif() add_library(Adminive_Http INTERFACE) +set_target_properties(Adminive_Http PROPERTIES EXPORT_NAME Http) add_library(Adminive::Http ALIAS Adminive_Http) target_link_libraries(Adminive_Http INTERFACE Adminive::Core) add_library(Adminive_Httplib INTERFACE) +set_target_properties(Adminive_Httplib PROPERTIES EXPORT_NAME Httplib) add_library(Adminive::Httplib ALIAS Adminive_Httplib) target_link_libraries(Adminive_Httplib INTERFACE Adminive::Http) -target_include_directories(Adminive_Httplib INTERFACE "${adminive_third_party_include_dir}") +target_include_directories(Adminive_Httplib INTERFACE "$" "$") if(WIN32) target_compile_definitions(Adminive_Httplib INTERFACE WINVER=0x0A00 _WIN32_WINNT=0x0A00) target_link_libraries(Adminive_Httplib INTERFACE ws2_32) endif() add_library(Adminive_Drogon INTERFACE) +set_target_properties(Adminive_Drogon PROPERTIES EXPORT_NAME Drogon) add_library(Adminive::Drogon ALIAS Adminive_Drogon) -target_link_libraries(Adminive_Drogon INTERFACE Adminive::Http) +target_link_libraries(Adminive_Drogon INTERFACE Adminive::Http "$") if(TARGET Drogon::Drogon) - target_link_libraries(Adminive_Drogon INTERFACE Drogon::Drogon) + target_link_libraries(Adminive_Drogon INTERFACE "$") elseif(TARGET drogon) - target_link_libraries(Adminive_Drogon INTERFACE drogon) + target_link_libraries(Adminive_Drogon INTERFACE "$") endif() add_library(Adminive_Default INTERFACE) +set_target_properties(Adminive_Default PROPERTIES EXPORT_NAME Default) add_library(Adminive::Default ALIAS Adminive_Default) target_link_libraries(Adminive_Default INTERFACE Adminive::Httplib) -target_include_directories(Adminive_Default INTERFACE "${adminive_third_party_include_dir}") +target_include_directories(Adminive_Default INTERFACE "$" "$") +add_library(Adminive_Example STATIC "${adminive_backend_root}/src/example.cpp" "${adminive_backend_root}/src/config_store.cpp" "${adminive_backend_root}/src/device_tables.cpp" "${adminive_backend_root}/src/server_app.cpp") +target_include_directories(Adminive_Example PUBLIC "${adminive_backend_root}/src") +target_link_libraries(Adminive_Example PUBLIC Adminive::Default) add_executable(Adminive_Server "${adminive_backend_root}/src/main.cpp") target_compile_definitions(Adminive_Server PRIVATE ADMINIVE_FRONTEND_DIST_DIR="${adminive_frontend_dist_dir}") -target_link_libraries(Adminive_Server PRIVATE Adminive::Default) +target_link_libraries(Adminive_Server PRIVATE Adminive_Example) +if(MSVC) + target_compile_options(Adminive_Example PRIVATE /permissive-) + target_compile_options(Adminive_Server PRIVATE /permissive-) +endif() if(BUILD_TESTING) add_executable(Adminive_Test "${adminive_backend_root}/tests/test.cpp") target_include_directories(Adminive_Test PRIVATE "${adminive_backend_root}/src") - target_link_libraries(Adminive_Test PRIVATE Adminive::Default) + target_link_libraries(Adminive_Test PRIVATE Adminive_Example) add_test(NAME Adminive_Test COMMAND Adminive_Test) add_executable(Adminive_Core_Adapter_Test "${adminive_backend_root}/tests/core_adapter_test.cpp") target_link_libraries(Adminive_Core_Adapter_Test PRIVATE Adminive::Core) @@ -51,8 +69,33 @@ if(BUILD_TESTING) add_executable(Adminive_Advanced_Adapter_Test "${adminive_backend_root}/tests/advanced_adapter_test.cpp") target_link_libraries(Adminive_Advanced_Adapter_Test PRIVATE Adminive::Default) add_test(NAME Adminive_Advanced_Adapter_Test COMMAND Adminive_Advanced_Adapter_Test) + add_executable(Adminive_Safety_Test "${adminive_backend_root}/tests/safety_test.cpp") + target_link_libraries(Adminive_Safety_Test PRIVATE Adminive::Default) + add_test(NAME Adminive_Safety_Test COMMAND Adminive_Safety_Test) + add_executable(Adminive_Config_Store_Test "${adminive_backend_root}/tests/config_store_test.cpp") + target_include_directories(Adminive_Config_Store_Test PRIVATE "${adminive_backend_root}/src") + target_link_libraries(Adminive_Config_Store_Test PRIVATE Adminive_Example) + add_test(NAME Adminive_Config_Store_Test COMMAND Adminive_Config_Store_Test) add_executable(Adminive_Drogon_Adapter_Test "${adminive_backend_root}/tests/drogon_adapter_test.cpp") target_include_directories(Adminive_Drogon_Adapter_Test BEFORE PRIVATE "${adminive_backend_root}/tests/fake_drogon") target_link_libraries(Adminive_Drogon_Adapter_Test PRIVATE Adminive::Default) add_test(NAME Adminive_Drogon_Adapter_Test COMMAND Adminive_Drogon_Adapter_Test) + if(MSVC) + target_compile_options(Adminive_Test PRIVATE /permissive-) + target_compile_options(Adminive_Core_Adapter_Test PRIVATE /permissive-) + target_compile_options(Adminive_Advanced_Adapter_Test PRIVATE /permissive-) + target_compile_options(Adminive_Drogon_Adapter_Test PRIVATE /permissive-) + target_compile_options(Adminive_Safety_Test PRIVATE /permissive-) + target_compile_options(Adminive_Config_Store_Test PRIVATE /permissive-) + endif() endif() +set(adminive_install_cmake_dir "${CMAKE_INSTALL_LIBDIR}/cmake/Adminive") +configure_package_config_file("${adminive_backend_root}/../cmake/AdminiveConfig.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/AdminiveConfig.cmake" INSTALL_DESTINATION "${adminive_install_cmake_dir}") +write_basic_package_version_file("${CMAKE_CURRENT_BINARY_DIR}/AdminiveConfigVersion.cmake" VERSION "${PROJECT_VERSION}" COMPATIBILITY SameMajorVersion) +install(TARGETS Adminive Adminive_Http Adminive_Httplib Adminive_Default EXPORT AdminiveTargets) +install(TARGETS Adminive_Drogon EXPORT AdminiveDrogonTargets) +install(DIRECTORY "${adminive_include_dir}/" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +install(DIRECTORY "${adminive_third_party_include_dir}/" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +install(EXPORT AdminiveTargets FILE AdminiveTargets.cmake NAMESPACE Adminive:: DESTINATION "${adminive_install_cmake_dir}") +install(EXPORT AdminiveDrogonTargets FILE AdminiveDrogonTargets.cmake NAMESPACE Adminive:: DESTINATION "${adminive_install_cmake_dir}") +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/AdminiveConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/AdminiveConfigVersion.cmake" DESTINATION "${adminive_install_cmake_dir}") diff --git a/backend/include/adminive/adapter.hpp b/backend/include/adminive/adapter.hpp index 0df307c..de0dd5b 100644 --- a/backend/include/adminive/adapter.hpp +++ b/backend/include/adminive/adapter.hpp @@ -9,6 +9,15 @@ #include #include namespace adminive { +enum class Write_Mode { + internal, + create, + update +}; +struct Write_Context { + Write_Mode mode{Write_Mode::internal}; + bool complete{true}; +}; template struct Reflection_Adapter; template @@ -40,17 +49,32 @@ concept Object_Adapter_With_Commit = requires(std::remove_cvref_t& target, Ob }; template struct Json_Adapter; +template +concept Json_Scalar_Makeable = requires(T value) { + { Json_Adapter::make(std::move(value)) } -> std::same_as; +}; +template +concept Json_Scalar_Gettable = requires(const Json& value) { + { Json_Adapter::template get(value) } -> std::same_as; +}; template -concept Json_Type = requires(Json value, const Json const_value, std::string key, std::string_view text, std::size_t index) { +concept Json_Type = requires(Json value, const Json const_value, std::string_view text, std::size_t index) { { Json_Adapter::object() } -> std::same_as; { Json_Adapter::array() } -> std::same_as; + { Json_Adapter::null() } -> std::same_as; { Json_Adapter::parse(text) } -> std::same_as; { Json_Adapter::dump(const_value, 2) } -> std::same_as; + { Json_Adapter::is_null(const_value) } -> std::same_as; { Json_Adapter::is_object(const_value) } -> std::same_as; { Json_Adapter::is_array(const_value) } -> std::same_as; { Json_Adapter::is_string(const_value) } -> std::same_as; + { Json_Adapter::is_signed_integer(const_value) } -> std::same_as; + { Json_Adapter::is_unsigned_integer(const_value) } -> std::same_as; + { Json_Adapter::is_floating_point(const_value) } -> std::same_as; { Json_Adapter::is_number(const_value) } -> std::same_as; { Json_Adapter::is_boolean(const_value) } -> std::same_as; + { Json_Adapter::signed_integer(const_value) } -> std::same_as; + { Json_Adapter::unsigned_integer(const_value) } -> std::same_as; { Json_Adapter::number(const_value) } -> std::convertible_to; { Json_Adapter::contains(const_value, text) } -> std::same_as; { Json_Adapter::size(const_value) } -> std::convertible_to; @@ -62,20 +86,38 @@ concept Json_Type = requires(Json value, const Json const_value, std::string key Json_Adapter::set(value, text, Json{}); Json_Adapter::append(value, Json{}); Json_Adapter::erase(value, text); -}; -template +} && Json_Scalar_Makeable && Json_Scalar_Makeable && Json_Scalar_Makeable && Json_Scalar_Makeable && Json_Scalar_Makeable && Json_Scalar_Gettable && Json_Scalar_Gettable && Json_Scalar_Gettable && Json_Scalar_Gettable && Json_Scalar_Gettable; +template Json json_object() { return Json_Adapter::object(); } -template +template Json json_array() { return Json_Adapter::array(); } -template -Json json_scalar(T&& value) { - return Json_Adapter::make(std::forward(value)); +template +Json json_null() { + return Json_Adapter::null(); } -template +template +Json json_scalar(T&& value) { + using Value = std::remove_cvref_t; + if constexpr(std::is_array_v && std::same_as>, char>) { + return Json_Adapter::make(std::string(value)); + } else if constexpr(std::same_as || std::same_as || std::same_as) { + return Json_Adapter::make(std::string(value)); + } else if constexpr(std::signed_integral && !std::same_as) { + return Json_Adapter::make(static_cast(value)); + } else if constexpr(std::unsigned_integral && !std::same_as) { + return Json_Adapter::make(static_cast(value)); + } else if constexpr(std::floating_point) { + return Json_Adapter::make(static_cast(value)); + } else { + static_assert(Json_Scalar_Makeable, "JSON adapter cannot construct this scalar type"); + return Json_Adapter::make(std::forward(value)); + } +} +template void json_set(Json& object, std::string_view name, T&& value) { if constexpr(std::same_as, Json>) { Json_Adapter::set(object, name, std::forward(value)); @@ -83,7 +125,7 @@ void json_set(Json& object, std::string_view name, T&& value) { Json_Adapter::set(object, name, json_scalar(std::forward(value))); } } -template +template void json_append(Json& array, T&& value) { if constexpr(std::same_as, Json>) { Json_Adapter::append(array, std::forward(value)); @@ -91,15 +133,24 @@ void json_append(Json& array, T&& value) { Json_Adapter::append(array, json_scalar(std::forward(value))); } } -template +template T json_get(const Json& value) { - return Json_Adapter::template get(value); + if constexpr(std::signed_integral && !std::same_as) { + return static_cast(Json_Adapter::template get(value)); + } else if constexpr(std::unsigned_integral && !std::same_as) { + return static_cast(Json_Adapter::template get(value)); + } else if constexpr(std::floating_point) { + return static_cast(Json_Adapter::template get(value)); + } else { + static_assert(Json_Scalar_Gettable, "JSON adapter cannot extract this scalar type"); + return Json_Adapter::template get(value); + } } -template +template Json parse_json(std::string_view value) { return Json_Adapter::parse(value); } -template +template std::string dump_json(const Json& value, int indent = -1) { return Json_Adapter::dump(value, indent); } @@ -182,12 +233,12 @@ auto polymorphic_variant(std::string value, std::string label) { template struct Polymorphic_Adapter; template -concept Polymorphic_Type = requires(const std::remove_cvref_t& value, std::remove_cvref_t& target, const Json& input) { +concept Polymorphic_Type = requires(const std::remove_cvref_t& value, std::remove_cvref_t& target, const Json& input, Write_Context context) { { Polymorphic_Adapter, Json>::discriminator() } -> std::convertible_to; { Polymorphic_Adapter, Json>::discriminator_label() } -> std::convertible_to; { Polymorphic_Adapter, Json>::variants() }; { Polymorphic_Adapter, Json>::encode(value) } -> std::same_as; - Polymorphic_Adapter, Json>::decode(target, input); + Polymorphic_Adapter, Json>::decode(target, input, context); }; template struct Enum_Adapter; diff --git a/backend/include/adminive/adapters/drogon.hpp b/backend/include/adminive/adapters/drogon.hpp index b83ebe8..e8e75a0 100644 --- a/backend/include/adminive/adapters/drogon.hpp +++ b/backend/include/adminive/adapters/drogon.hpp @@ -1,11 +1,12 @@ #pragma once #include "adminive/http.hpp" #include +#include #include #include -#include #include #include +#include namespace adminive { template drogon::HttpResponsePtr make_drogon_response(Http_Response value) { @@ -16,39 +17,138 @@ drogon::HttpResponsePtr make_drogon_response(Http_Response value) { return response; } using Drogon_Executor = std::function)>; +using Drogon_Request_Context_Factory = std::function; +struct Drogon_Bind_Options { + Drogon_Executor executor; + Drogon_Request_Context_Factory context_factory; + std::vector filters; +}; +inline Request_Context make_drogon_request_context(const drogon::HttpRequestPtr& request, const Drogon_Request_Context_Factory& factory) { + return factory ? factory(request) : Request_Context{}; +} +inline std::vector make_drogon_constraints(drogon::HttpMethod method, const std::vector& filters) { + std::vector result; + result.reserve(filters.size() + 1); + result.emplace_back(method); + for(const auto& filter : filters) { + result.emplace_back(filter); + } + return result; +} +template +void complete_drogon_request(std::function& callback, Function&& function) noexcept { + try { + callback(make_drogon_response(std::forward(function)())); + } catch(const std::exception& error) { + callback(make_drogon_response(make_http_error(500, error.what()))); + } catch(...) { + callback(make_drogon_response(make_http_error(500, "unknown server error"))); + } +} +template +void schedule_drogon_request(const Drogon_Executor& executor, std::function&& callback, Function&& function) noexcept { + auto response_callback = std::make_shared>(std::move(callback)); + std::function task = [response_callback, function = std::forward(function)]() mutable { + complete_drogon_request(*response_callback, std::move(function)); + }; + try { + if(executor) { + executor(task); + } else { + task(); + } + } catch(const std::exception& error) { + (*response_callback)(make_drogon_response(make_http_error(500, error.what()))); + } catch(...) { + (*response_callback)(make_drogon_response(make_http_error(500, "failed to schedule request"))); + } +} template -requires Writable_Adapted_Object +requires Writable_Adapted_Object && std::copy_constructible> class Drogon_Resource { public: using Service = Resource_Service; - using Transaction_Function = typename Service::Transaction_Function; - Drogon_Resource(T& value, std::string path, Transaction_Function transaction = {}, std::mutex* shared_mutex = nullptr) : service_(std::make_shared(value, std::move(path), std::move(transaction), shared_mutex)) {} + using Transaction = typename Service::Transaction; + Drogon_Resource(T& value, std::string path, Transaction transaction = {}, std::mutex* shared_mutex = nullptr) : service_(std::make_shared(value, std::move(path), std::move(transaction), shared_mutex)) {} Json amis_schema() const { return service_->amis_schema(); } - void bind(drogon::HttpAppFramework& app, Drogon_Executor executor = {}) const { + void bind(drogon::HttpAppFramework& app, Drogon_Bind_Options options = {}) const { const auto service = service_; + const auto get_constraints = make_drogon_constraints(drogon::Get, options.filters); + const auto post_constraints = make_drogon_constraints(drogon::Post, options.filters); app.registerHandler(service->path() + "/descriptor", [service](const drogon::HttpRequestPtr&, std::function&& callback) { - callback(make_drogon_response(service->descriptor_response())); - }, {drogon::Get}); + complete_drogon_request(callback, [service] { + return service->descriptor_response(); + }); + }, get_constraints); app.registerHandler(service->path() + "/data", [service](const drogon::HttpRequestPtr&, std::function&& callback) { - callback(make_drogon_response(service->data_response())); - }, {drogon::Get}); + complete_drogon_request(callback, [service] { + return service->data_response(); + }); + }, get_constraints); app.registerHandler(service->path() + "/amis", [service](const drogon::HttpRequestPtr&, std::function&& callback) { - callback(make_drogon_response(service->amis_response())); - }, {drogon::Get}); - app.registerHandler(service->path() + "/data", [service, executor = std::move(executor)](const drogon::HttpRequestPtr& request, std::function&& callback) mutable { - auto task = [service, body = std::string(request->getBody()), callback = std::move(callback)]() mutable { - callback(make_drogon_response(service->update_response(body))); - }; - if(executor) { - executor(std::move(task)); - } else { - task(); + complete_drogon_request(callback, [service] { + return service->amis_response(); + }); + }, get_constraints); + app.registerHandler(service->path() + "/data", [service, options](const drogon::HttpRequestPtr& request, std::function&& callback) { + Request_Context context; + try { + context = make_drogon_request_context(request, options.context_factory); + } catch(const std::exception& error) { + callback(make_drogon_response(make_http_error(500, error.what()))); + return; } - }, {drogon::Post}); + schedule_drogon_request(options.executor, std::move(callback), [service, body = std::string(request->getBody()), context] { + return service->update_response(body, context); + }); + }, post_constraints); } private: std::shared_ptr service_; }; +template +class Drogon_Status_Resource { +public: + using Reader = std::function; + Drogon_Status_Resource(std::string path, Reader reader, std::uint64_t interval = 2000) : state_(std::make_shared(State{std::move(path), std::move(reader), interval, to_status_descriptor_json()})) {} + Json amis_schema() const { + return make_amis_status_service(state_->descriptor, state_->path + "/data", state_->interval); + } + void bind(drogon::HttpAppFramework& app, Drogon_Bind_Options options = {}) const { + const auto state = state_; + const auto constraints = make_drogon_constraints(drogon::Get, options.filters); + app.registerHandler(state->path + "/descriptor", [state](const drogon::HttpRequestPtr&, std::function&& callback) { + complete_drogon_request(callback, [state] { + return make_http_success(state->descriptor); + }); + }, constraints); + app.registerHandler(state->path + "/amis", [state](const drogon::HttpRequestPtr&, std::function&& callback) { + complete_drogon_request(callback, [state] { + return make_http_success(make_amis_status_service(state->descriptor, state->path + "/data", state->interval)); + }); + }, constraints); + app.registerHandler(state->path + "/data", [state, options](const drogon::HttpRequestPtr& request, std::function&& callback) { + Request_Context context; + try { + context = make_drogon_request_context(request, options.context_factory); + } catch(const std::exception& error) { + callback(make_drogon_response(make_http_error(500, error.what()))); + return; + } + schedule_drogon_request(options.executor, std::move(callback), [state, context] { + return make_http_success(to_status_json(state->reader(context))); + }); + }, constraints); + } +private: + struct State { + std::string path; + Reader reader; + std::uint64_t interval; + Json descriptor; + }; + std::shared_ptr state_; +}; } diff --git a/backend/include/adminive/adapters/httplib.hpp b/backend/include/adminive/adapters/httplib.hpp index 6ca6e69..a5d5b4e 100644 --- a/backend/include/adminive/adapters/httplib.hpp +++ b/backend/include/adminive/adapters/httplib.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -27,31 +28,32 @@ void write_http_error(httplib::Response& response, int status, const Update_Resu write_http_response(response, make_http_error(status, update)); } template -requires Writable_Adapted_Object +requires Writable_Adapted_Object && std::copy_constructible> class Http_Resource { public: using Service = Resource_Service; - using Transaction_Function = typename Service::Transaction_Function; - Http_Resource(T& value, std::string path, Transaction_Function transaction = {}, std::mutex* shared_mutex = nullptr) : service_(value, std::move(path), std::move(transaction), shared_mutex) {} + using Transaction = typename Service::Transaction; + Http_Resource(T& value, std::string path, Transaction transaction = {}, std::mutex* shared_mutex = nullptr) : service_(std::make_shared(value, std::move(path), std::move(transaction), shared_mutex)) {} Json amis_schema() const { - return service_.amis_schema(); + return service_->amis_schema(); } - void bind(httplib::Server& server) { - server.Get(service_.path() + "/descriptor", [this](const httplib::Request&, httplib::Response& response) { - write_http_response(response, service_.descriptor_response()); + void bind(httplib::Server& server) const { + const auto service = service_; + server.Get(service->path() + "/descriptor", [service](const httplib::Request&, httplib::Response& response) { + write_http_response(response, service->descriptor_response()); }); - server.Get(service_.path() + "/data", [this](const httplib::Request&, httplib::Response& response) { - write_http_response(response, service_.data_response()); + server.Get(service->path() + "/data", [service](const httplib::Request&, httplib::Response& response) { + write_http_response(response, service->data_response()); }); - server.Get(service_.path() + "/amis", [this](const httplib::Request&, httplib::Response& response) { - write_http_response(response, service_.amis_response()); + server.Get(service->path() + "/amis", [service](const httplib::Request&, httplib::Response& response) { + write_http_response(response, service->amis_response()); }); - server.Post(service_.path() + "/data", [this](const httplib::Request& request, httplib::Response& response) { - write_http_response(response, service_.update_response(request.body)); + server.Post(service->path() + "/data", [service](const httplib::Request& request, httplib::Response& response) { + write_http_response(response, service->update_response(request.body)); }); } private: - Service service_; + std::shared_ptr service_; }; template requires std::default_initializable && std::copy_constructible && std::assignable_from @@ -101,7 +103,7 @@ public: return to_amis_crud_status_schema(path_, overview_status_api_, overview_status_descriptor_, overview_status_interval_, descriptor, status_interval_); } void bind(httplib::Server& server) { - server.Get(path_ + "/descriptor", [this](const httplib::Request&, httplib::Response& response) { + server.Get(path_ + "/descriptor", [](const httplib::Request&, httplib::Response& response) { write_http_json(response, make_http_result(0, "", to_descriptor_json())); }); server.Get(path_ + "/amis", [this](const httplib::Request&, httplib::Response& response) { diff --git a/backend/include/adminive/adapters/nlohmann_json.hpp b/backend/include/adminive/adapters/nlohmann_json.hpp index c16f268..8e439db 100644 --- a/backend/include/adminive/adapters/nlohmann_json.hpp +++ b/backend/include/adminive/adapters/nlohmann_json.hpp @@ -1,6 +1,7 @@ #pragma once #include "adminive/adapter.hpp" #include "nlohmann/json.hpp" +#include #include #include #include @@ -15,12 +16,18 @@ struct Json_Adapter { static Json array() { return Json::array(); } + static Json null() { + return nullptr; + } static Json parse(std::string_view value) { return Json::parse(value.begin(), value.end()); } static std::string dump(const Json& value, int indent) { return value.dump(indent); } + static bool is_null(const Json& value) noexcept { + return value.is_null(); + } static bool is_object(const Json& value) noexcept { return value.is_object(); } @@ -30,12 +37,27 @@ struct Json_Adapter { static bool is_string(const Json& value) noexcept { return value.is_string(); } + static bool is_signed_integer(const Json& value) noexcept { + return value.is_number_integer() && !value.is_number_unsigned(); + } + static bool is_unsigned_integer(const Json& value) noexcept { + return value.is_number_unsigned(); + } + static bool is_floating_point(const Json& value) noexcept { + return value.is_number_float(); + } static bool is_number(const Json& value) noexcept { return value.is_number(); } static bool is_boolean(const Json& value) noexcept { return value.is_boolean(); } + static std::int64_t signed_integer(const Json& value) { + return value.get(); + } + static std::uint64_t unsigned_integer(const Json& value) { + return value.get(); + } static long double number(const Json& value) { return value.get(); } diff --git a/backend/include/adminive/amis.hpp b/backend/include/adminive/amis.hpp index 93da30c..ca373df 100644 --- a/backend/include/adminive/amis.hpp +++ b/backend/include/adminive/amis.hpp @@ -31,6 +31,27 @@ inline std::string escape_amis_string_literal(std::string_view value) { } return result; } +inline std::string resolve_visible_on(std::string expression, std::string_view prefix) { + constexpr std::string_view marker{"$self"}; + std::size_t position{}; + while((position = expression.find(marker, position)) != std::string::npos) { + const bool dotted = position + marker.size() < expression.size() && expression[position + marker.size()] == '.'; + std::string replacement(prefix); + if(!replacement.empty() && dotted) { + replacement.push_back('.'); + } + const std::size_t count = marker.size() + (dotted ? 1 : 0); + expression.replace(position, count, replacement); + position += replacement.size(); + } + return expression; +} +template +void apply_visible_on(Json& control, const Json& field, std::string_view prefix) { + if(Json_Adapter::contains(field, "visible_on")) { + json_set(control, "visibleOn", resolve_visible_on(json_get(Json_Adapter::at(field, "visible_on")), prefix)); + } +} template Json make_amis_form_actions(std::string submit_label = "Apply") { Json reset = json_object(); @@ -85,9 +106,7 @@ Json make_amis_polymorphic_control(const Json& field, std::string_view permissio if(Json_Adapter::contains(field, "description")) { json_set(result, "description", Json_Adapter::at(field, "description")); } - if(Json_Adapter::contains(field, "visible_on")) { - json_set(result, "visibleOn", Json_Adapter::at(field, "visible_on")); - } + apply_visible_on(result, field, prefix); return result; } template @@ -126,9 +145,7 @@ Json make_typed_amis_polymorphic_control(const Json& field, std::string_view per if(Json_Adapter::contains(field, "description")) { json_set(result, "description", Json_Adapter::at(field, "description")); } - if(Json_Adapter::contains(field, "visible_on")) { - json_set(result, "visibleOn", Json_Adapter::at(field, "visible_on")); - } + apply_visible_on(result, field, prefix); return result; } template @@ -148,9 +165,7 @@ Json make_default_amis_control(const Json& field, std::string_view permission, s if(Json_Adapter::contains(field, "description")) { json_set(control, "description", Json_Adapter::at(field, "description")); } - if(Json_Adapter::contains(field, "visible_on")) { - json_set(control, "visibleOn", Json_Adapter::at(field, "visible_on")); - } + apply_visible_on(control, field, prefix); return control; } if(value_type == "polymorphic" && Json_Adapter::contains(field, "polymorphic")) { @@ -162,9 +177,7 @@ Json make_default_amis_control(const Json& field, std::string_view permission, s if(Json_Adapter::contains(field, "description")) { json_set(control, "description", Json_Adapter::at(field, "description")); } - if(Json_Adapter::contains(field, "visible_on")) { - json_set(control, "visibleOn", Json_Adapter::at(field, "visible_on")); - } + apply_visible_on(control, field, prefix); if(!json_get(Json_Adapter::at(field, permission))) { json_set(control, "type", "static"); return control; @@ -183,6 +196,9 @@ Json make_default_amis_control(const Json& field, std::string_view permission, s throw std::invalid_argument("field '" + full_name + "' requires an explicit widget or Control_Adapter"); } const std::string control_type = json_get(Json_Adapter::at(control, "type")); + if(Json_Adapter::contains(field, "nullable") && json_get(Json_Adapter::at(field, "nullable"))) { + json_set(control, "clearable", true); + } if(control_type == "input-date") { json_set(control, "valueFormat", "YYYY-MM-DD"); json_set(control, "displayFormat", "YYYY-MM-DD"); @@ -220,18 +236,21 @@ template Json make_typed_amis_control(const Json& field, std::string_view permission, std::string_view prefix = {}) { using Storage = std::remove_cvref_t; using Value = Adapted_Value_Type; + using Control_Value = Optional_Unwrapped_Type; const Control_Context context{permission, prefix}; if constexpr(Control_Adapter_With_Control) { return Control_Adapter::make_control(field, context); } else if constexpr(!std::same_as && Control_Adapter_With_Control) { return Control_Adapter::make_control(field, context); - } else if constexpr(Polymorphic_Type) { - return make_typed_amis_polymorphic_control(field, permission, prefix); - } else if constexpr(Described_Type || Snapshot_Adapted_Object) { - using Model = Object_Model_Type; + } else if constexpr(!std::same_as && Control_Adapter_With_Control) { + return Control_Adapter::make_control(field, context); + } else if constexpr(Polymorphic_Type) { + return make_typed_amis_polymorphic_control(field, permission, prefix); + } else if constexpr(Described_Type || Snapshot_Adapted_Object) { + using Model = Object_Model_Type; const std::string name = json_get(Json_Adapter::at(field, "name")); const std::string full_name = make_field_name(prefix, name); - const Json descriptor = to_descriptor_json(); + const Json descriptor = to_descriptor_json(); Json control = json_object(); json_set(control, "type", "fieldset"); json_set(control, "title", Json_Adapter::at(field, "label")); @@ -241,9 +260,7 @@ Json make_typed_amis_control(const Json& field, std::string_view permission, std if(Json_Adapter::contains(field, "description")) { json_set(control, "description", Json_Adapter::at(field, "description")); } - if(Json_Adapter::contains(field, "visible_on")) { - json_set(control, "visibleOn", Json_Adapter::at(field, "visible_on")); - } + apply_visible_on(control, field, prefix); return control; } else { return make_default_amis_control(field, permission, prefix); @@ -312,10 +329,13 @@ template Json make_typed_amis_column(const Json& field) { using Storage = std::remove_cvref_t; using Value = Adapted_Value_Type; + using Control_Value = Optional_Unwrapped_Type; if constexpr(Control_Adapter_With_Column) { return Control_Adapter::make_column(field); } else if constexpr(!std::same_as && Control_Adapter_With_Column) { return Control_Adapter::make_column(field); + } else if constexpr(!std::same_as && Control_Adapter_With_Column) { + return Control_Adapter::make_column(field); } else { return make_default_amis_column(field); } @@ -335,7 +355,8 @@ void append_typed_amis_columns(std::vector>& columns, using Member = typename Field::member_type; using Storage = std::remove_cvref_t; using Value = Adapted_Value_Type; - constexpr bool custom_column = Control_Adapter_With_Column || (!std::same_as && Control_Adapter_With_Column); + using Control_Value = Optional_Unwrapped_Type; + constexpr bool custom_column = Control_Adapter_With_Column || (!std::same_as && Control_Adapter_With_Column) || (!std::same_as && Control_Adapter_With_Column); const std::string value_type = json_get(Json_Adapter::at(field, "value_type")); const bool structural = value_type == "object" || value_type == "array" || value_type == "map" || value_type == "polymorphic"; if(json_get(Json_Adapter::at(field, "readable")) && json_get(Json_Adapter::at(field, "list_visible")) && (!structural || custom_column)) { diff --git a/backend/include/adminive/concepts.hpp b/backend/include/adminive/concepts.hpp index 131bb57..21d5502 100644 --- a/backend/include/adminive/concepts.hpp +++ b/backend/include/adminive/concepts.hpp @@ -1,31 +1,43 @@ #pragma once #include "adminive/value.hpp" #include -#include +#include #include #include #include -#include namespace adminive { template -concept String_Type = std::same_as, std::string> || std::same_as, std::string_view>; +concept String_Type = std::same_as, std::string>; template -concept Sequence_Type = requires(T value) { +concept String_View_Type = std::same_as, std::string_view>; +template +struct Is_Optional : std::false_type {}; +template +struct Is_Optional> : std::true_type { + using value_type = T; +}; +template +concept Optional_Type = Is_Optional>::value; +template +using Optional_Value_Type = typename Is_Optional>::value_type; +template +struct Optional_Unwrapped { + using type = std::remove_cvref_t; +}; +template +struct Optional_Unwrapped> { + using type = T; +}; +template +using Optional_Unwrapped_Type = typename Optional_Unwrapped>::type; +template +concept Container_Like_Type = requires(T value) { typename std::remove_cvref_t::value_type; value.begin(); value.end(); - value.size(); -} && !String_Type; +} && !String_Type && !String_View_Type && !Optional_Type; template -concept String_Key_Map_Type = requires(T value) { - typename std::remove_cvref_t::key_type; - typename std::remove_cvref_t::mapped_type; - requires String_Type::key_type>; - value.begin(); - value.end(); -}; -template -concept Json_Scalar_Type = std::same_as, bool> || std::integral> || std::floating_point> || String_Type> || std::is_enum_v>; +concept Json_Scalar_Type = std::same_as, bool> || std::integral> || std::floating_point> || String_Type> || String_View_Type> || std::is_enum_v>; template concept Multiple_Of_Validator = requires { { Validator::multiple_of }; @@ -34,4 +46,6 @@ template concept Validator_With_Message = requires { { Validator::message } -> std::convertible_to; }; +template +inline constexpr bool Always_False = false; } diff --git a/backend/include/adminive/descriptor.hpp b/backend/include/adminive/descriptor.hpp index b1143a4..08feef0 100644 --- a/backend/include/adminive/descriptor.hpp +++ b/backend/include/adminive/descriptor.hpp @@ -58,6 +58,22 @@ struct Reflected_Field_Accessor { return Reflection_Adapter::template get(object); } }; +inline bool valid_data_name(std::string_view name) noexcept { + if(name.empty()) { + return false; + } + const auto first = static_cast(name.front()); + if(!(std::isalpha(first) || name.front() == '_')) { + return false; + } + for(const char value : name.substr(1)) { + const auto character = static_cast(value); + if(!(std::isalnum(character) || value == '_')) { + return false; + } + } + return true; +} inline std::string make_label(std::string_view name) { std::string result; result.reserve(name.size()); @@ -426,14 +442,14 @@ private: }; template void validate_descriptor(const Descriptor& descriptor) { - if(descriptor.name().empty()) { - throw std::invalid_argument("descriptor name must not be empty"); + if(!valid_data_name(descriptor.name())) { + throw std::invalid_argument("descriptor name must match [A-Za-z_][A-Za-z0-9_]*: " + descriptor.name()); } std::set field_names; std::apply([&](const auto&... field) { ([&] { - if(field.name().empty()) { - throw std::invalid_argument("descriptor field name must not be empty"); + if(!valid_data_name(field.name())) { + throw std::invalid_argument("descriptor field name must match [A-Za-z_][A-Za-z0-9_]*: " + field.name()); } if(!field_names.insert(field.name()).second) { throw std::invalid_argument("duplicate descriptor field name: " + field.name()); diff --git a/backend/include/adminive/http.hpp b/backend/include/adminive/http.hpp index 5e08cb1..c18a035 100644 --- a/backend/include/adminive/http.hpp +++ b/backend/include/adminive/http.hpp @@ -1,10 +1,18 @@ #pragma once #include "adminive/amis.hpp" +#include #include +#include #include #include #include namespace adminive { +struct Request_Context { + std::string user; + std::string request_id; + std::string remote_address; + std::map attributes; +}; template struct Http_Response { int status{200}; @@ -39,14 +47,29 @@ template Http_Response make_http_error(int status, std::string message) { return Http_Response{status, make_http_result(status, std::move(message), json_object())}; } +template +struct Resource_Transaction { + using Prepare_Function = std::function; + using Commit_Function = std::function; + using Rollback_Function = std::function; + Prepare_Function prepare; + Commit_Function commit; + Rollback_Function rollback; +}; +template +Resource_Transaction make_resource_transaction(Function&& function) { + Resource_Transaction result; + result.commit = std::forward(function); + return result; +} template -requires Writable_Adapted_Object +requires Writable_Adapted_Object && std::copy_constructible> class Resource_Service { public: using Object = std::remove_cvref_t; using Model = Object_Model_Type; - using Transaction_Function = std::function; - Resource_Service(T& value, std::string path, Transaction_Function transaction = {}, std::mutex* shared_mutex = nullptr) : value_(value), path_(std::move(path)), transaction_(std::move(transaction)), shared_mutex_(shared_mutex) {} + using Transaction = Resource_Transaction; + Resource_Service(T& value, std::string path, Transaction transaction = {}, std::mutex* shared_mutex = nullptr) : value_(value), path_(std::move(path)), transaction_(std::move(transaction)), shared_mutex_(shared_mutex) {} const std::string& path() const noexcept { return path_; } @@ -54,52 +77,127 @@ public: std::scoped_lock lock(resource_mutex()); return to_amis_form_schema(value_, path_ + "/data", describe().list_options().confirm_label); } - Http_Response descriptor_response() const { - return make_http_success(to_descriptor_json()); + Http_Response descriptor_response() const noexcept { + return safe_response([&] { + return make_http_success(to_descriptor_json()); + }); } - Http_Response data_response() const { - std::scoped_lock lock(resource_mutex()); - return make_http_success(to_frontend_json(value_)); + Http_Response data_response() const noexcept { + return safe_response([&] { + std::scoped_lock lock(resource_mutex()); + return make_http_success(to_frontend_json(value_)); + }); } - Http_Response amis_response() const { - return make_http_success(amis_schema()); + Http_Response amis_response() const noexcept { + return safe_response([&] { + return make_http_success(amis_schema()); + }); } - Http_Response update_response(std::string_view body) { - Json patch; - try { - patch = parse_json(body); - } catch(const std::exception& error) { - return make_http_error(400, error.what()); - } - std::scoped_lock lock(resource_mutex()); - auto candidate = Object_Adapter::snapshot(value_); - auto result = apply_model_json(candidate, patch, Write_Mode::update, false); - if(!result.success) { - return make_http_error(422, result); - } - try { - if(transaction_) { - transaction_(value_, std::move(candidate)); - } else { - Object_Adapter::commit(value_, std::move(candidate)); + Http_Response update_response(std::string_view body, const Request_Context& context = {}) noexcept { + return safe_response([&] { + Json patch; + try { + patch = parse_json(body); + } catch(const std::exception& error) { + return make_http_error(400, error.what()); } + std::scoped_lock transaction_lock(transaction_mutex_); + Model original = snapshot(); + Model candidate = original; + auto result = apply_model_json(candidate, patch, Write_Mode::update, false); + if(!result.success) { + return make_http_error(422, result); + } + bool transaction_started{}; + bool runtime_commit_started{}; + try { + if(transaction_.prepare) { + transaction_started = true; + transaction_.prepare(candidate, context); + } + std::scoped_lock lock(resource_mutex()); + runtime_commit_started = true; + Object_Adapter::commit(value_, candidate); + if(transaction_.commit) { + transaction_started = true; + transaction_.commit(candidate, context); + } + } catch(const Json_Assignment_Error& error) { + rollback_after_failure(original, context, runtime_commit_started, transaction_started); + return make_http_error(422, error.result()); + } catch(const Field_Validation_Error& error) { + rollback_after_failure(original, context, runtime_commit_started, transaction_started); + return make_http_error(422, object_commit_error(error)); + } catch(const std::exception& error) { + const std::string rollback_error = rollback_after_failure(original, context, runtime_commit_started, transaction_started); + return make_http_error(500, rollback_error.empty() ? error.what() : std::string(error.what()) + "; rollback failed: " + rollback_error); + } catch(...) { + const std::string rollback_error = rollback_after_failure(original, context, runtime_commit_started, transaction_started); + return make_http_error(500, rollback_error.empty() ? "unknown server error" : "unknown server error; rollback failed: " + rollback_error); + } + return data_response_with_message(result.message); + }); + } +private: + template + static Http_Response safe_response(Function&& function) noexcept { + try { + return std::forward(function)(); } catch(const Json_Assignment_Error& error) { return make_http_error(422, error.result()); } catch(const Field_Validation_Error& error) { return make_http_error(422, object_commit_error(error)); } catch(const std::exception& error) { return make_http_error(500, error.what()); + } catch(...) { + return make_http_error(500, "unknown server error"); } - return make_http_success(to_frontend_json(value_), result.message); } -private: + Model snapshot() const { + std::scoped_lock lock(resource_mutex()); + return Object_Adapter::snapshot(value_); + } + Http_Response data_response_with_message(std::string message) const { + std::scoped_lock lock(resource_mutex()); + return make_http_success(to_frontend_json(value_), std::move(message)); + } + std::string rollback_after_failure(const Model& original, const Request_Context& context, bool restore_runtime, bool transaction_started) noexcept { + std::string message; + if(restore_runtime) { + try { + std::scoped_lock lock(resource_mutex()); + Object_Adapter::commit(value_, original); + } catch(const std::exception& error) { + message = error.what(); + } catch(...) { + message = "runtime object rollback failed"; + } + } + if(transaction_started && transaction_.rollback) { + try { + transaction_.rollback(original, context); + } catch(const std::exception& error) { + if(!message.empty()) { + message += "; "; + } + message += error.what(); + } catch(...) { + if(!message.empty()) { + message += "; "; + } + message += "external rollback failed"; + } + } + return message; + } std::mutex& resource_mutex() const noexcept { return shared_mutex_ ? *shared_mutex_ : mutex_; } T& value_; std::string path_; - Transaction_Function transaction_; + Transaction transaction_; std::mutex* shared_mutex_{}; mutable std::mutex mutex_; + mutable std::mutex transaction_mutex_; }; } diff --git a/backend/include/adminive/json.hpp b/backend/include/adminive/json.hpp index 99e6606..4c5b32f 100644 --- a/backend/include/adminive/json.hpp +++ b/backend/include/adminive/json.hpp @@ -1,13 +1,17 @@ #pragma once #include "adminive/adapter.hpp" #include "adminive/descriptor.hpp" +#include #include +#include +#include #include #include #include #include #include #include +#include namespace adminive { struct Update_Result { bool success{}; @@ -22,6 +26,7 @@ struct Update_Result { for(const auto& [name, error] : field_errors) { json_set(errors, name, error); } + json_set(result, "errors", errors); json_set(result, "field_errors", std::move(errors)); return result; } @@ -44,10 +49,19 @@ public: private: std::string path_; }; -enum class Write_Mode { - internal, - create, - update +class Validation_Context { +public: + void error(std::string path, std::string message) { + errors_.insert_or_assign(std::move(path), std::move(message)); + } + bool empty() const noexcept { + return errors_.empty(); + } + const std::map& errors() const noexcept { + return errors_; + } +private: + std::map errors_; }; inline std::string join_field_path(std::string_view prefix, std::string_view child) { if(prefix.empty()) { @@ -61,25 +75,6 @@ inline std::string join_field_path(std::string_view prefix, std::string_view chi } return std::string(prefix) + "." + std::string(child); } -inline std::string map_key_path(std::string_view key) { - std::string result{"[\""}; - for(const char value : key) { - if(value == '\\' || value == '"') { - result.push_back('\\'); - } - result.push_back(value); - } - result += "\"]"; - return result; -} -inline Update_Result prefix_update_result(const Update_Result& source, std::string_view prefix) { - Update_Result result = source; - result.field_errors.clear(); - for(const auto& [path, message] : source.field_errors) { - result.field_errors.emplace(join_field_path(prefix, path), message); - } - return result; -} inline void merge_update_errors(Update_Result& target, const Update_Result& source, std::string_view prefix = {}) { if(target.message.empty() && !source.message.empty()) { target.message = source.message; @@ -114,8 +109,14 @@ requires Described_Type> || Object_Adapter_With_Snapshot< Json to_frontend_json(const T& value); template Update_Result assign_json(T& target, const Json& value); +template +Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, bool complete); template -void assign_json_value(T& target, const Json& value); +void assign_json_value(T& target, const Json& value, Write_Context context); +template +void assign_json_value(T& target, const Json& value) { + assign_json_value(target, value, Write_Context{}); +} template Json encode_json_value(const T& value) { using Storage = std::remove_cvref_t; @@ -124,6 +125,11 @@ Json encode_json_value(const T& value) { return Value_Adapter::encode(value); } else if constexpr(!std::same_as) { return encode_json_value(read_adapted_value(value)); + } else if constexpr(Optional_Type) { + if(!value) { + return json_null(); + } + return encode_json_value(*value); } else if constexpr(Polymorphic_Type) { return Polymorphic_Adapter::encode(value); } else if constexpr(std::is_enum_v) { @@ -135,104 +141,113 @@ Json encode_json_value(const T& value) { return json_scalar(static_cast>(value)); } else if constexpr(Described_Type || Snapshot_Adapted_Object) { return to_json(value); - } else if constexpr(String_Key_Map_Type) { - Json result = json_object(); - for(const auto& [key, item] : value) { - json_set(result, std::string(key), encode_json_value(item)); - } - return result; - } else if constexpr(Sequence_Type) { - Json result = json_array(); - for(const auto& item : value) { - json_append(result, encode_json_value(item)); - } - return result; + } else if constexpr(Container_Like_Type) { + static_assert(Always_False, "container types require an explicit adminive::Value_Adapter; Adminive does not infer vector/map serialization or UI semantics"); } else { + static_assert(Json_Scalar_Makeable, "JSON adapter cannot encode this scalar type"); return json_scalar(value); } } +template +Value decode_integral_value(const Json& value) { + static_assert(std::integral && !std::same_as); + if(Json_Adapter::is_signed_integer(value)) { + const std::int64_t source = Json_Adapter::signed_integer(value); + if constexpr(std::unsigned_integral) { + if(source < 0 || static_cast(source) > static_cast(std::numeric_limits::max())) { + throw std::out_of_range("integer value is outside the target type range"); + } + } else if(source < static_cast(std::numeric_limits::min()) || source > static_cast(std::numeric_limits::max())) { + throw std::out_of_range("integer value is outside the target type range"); + } + return static_cast(source); + } + if(Json_Adapter::is_unsigned_integer(value)) { + const std::uint64_t source = Json_Adapter::unsigned_integer(value); + if(source > static_cast(std::numeric_limits::max())) { + throw std::out_of_range("integer value is outside the target type range"); + } + return static_cast(source); + } + throw std::invalid_argument("value must be an integer"); +} +template +Value decode_floating_value(const Json& value) { + static_assert(std::floating_point); + if(!Json_Adapter::is_number(value)) { + throw std::invalid_argument("value must be a number"); + } + const long double source = Json_Adapter::number(value); + if(!std::isfinite(source) || source < static_cast(std::numeric_limits::lowest()) || source > static_cast(std::numeric_limits::max())) { + throw std::out_of_range("number is outside the target type range"); + } + return static_cast(source); +} +template +Value decode_scalar_value(const Json& value) { + if constexpr(std::same_as) { + if(!Json_Adapter::is_boolean(value)) { + throw std::invalid_argument("value must be a boolean"); + } + return json_get(value); + } else if constexpr(std::integral) { + return decode_integral_value(value); + } else if constexpr(std::floating_point) { + return decode_floating_value(value); + } else if constexpr(String_Type) { + if(!Json_Adapter::is_string(value)) { + throw std::invalid_argument("value must be a string"); + } + return json_get(value); + } else if constexpr(String_View_Type) { + static_assert(Always_False, "std::string_view is read-only storage; use std::string or provide a Value_Adapter"); + } else { + static_assert(Json_Scalar_Gettable, "JSON adapter cannot decode this scalar type"); + return json_get(value); + } +} template -void assign_json_value(T& target, const Json& value) { +void assign_json_value(T& target, const Json& value, Write_Context context) { using Storage = std::remove_cvref_t; using Value = Adapted_Value_Type; if constexpr(Value_Adapter_With_Decode) { Value_Adapter::decode(target, value); } else if constexpr(!std::same_as) { Value parsed{}; - assign_json_value(parsed, value); + assign_json_value(parsed, value, context); write_adapted_value(target, std::move(parsed)); + } else if constexpr(Optional_Type) { + if(Json_Adapter::is_null(value)) { + target.reset(); + return; + } + Optional_Value_Type parsed = target ? *target : Optional_Value_Type{}; + assign_json_value(parsed, value, context); + target = std::move(parsed); } else if constexpr(Polymorphic_Type) { - Polymorphic_Adapter::decode(target, value); + Polymorphic_Adapter::decode(target, value, context); } else if constexpr(std::is_enum_v) { static_assert(Enum_Type, "enum type requires an adminive::Enum_Adapter specialization"); std::optional parsed; if(Json_Adapter::is_string(value)) { parsed = enum_cast(json_get(value)); } else if constexpr(Enum_Adapter_With_Underlying_Cast) { - parsed = Enum_Adapter::cast(json_get>(value)); + using Underlying = std::underlying_type_t; + parsed = Enum_Adapter::cast(decode_integral_value(value)); } if(!parsed) { throw std::invalid_argument("unknown enum value"); } target = *parsed; } else if constexpr(Writable_Adapted_Object) { - const auto result = assign_json(target, value); + const auto result = apply_object_json(target, value, context.mode, context.complete); if(!result.success) { throw Json_Assignment_Error(result); } - } else if constexpr(String_Key_Map_Type) { - if(!Json_Adapter::is_object(value)) { - throw std::invalid_argument("value must be a JSON object"); - } - Value parsed; - for(const auto& key : Json_Adapter::keys(value)) { - typename Value::mapped_type item{}; - try { - assign_json_value(item, Json_Adapter::at(value, key)); - } catch(const Json_Assignment_Error& error) { - throw Json_Assignment_Error(prefix_update_result(error.result(), map_key_path(key))); - } catch(const Field_Validation_Error& error) { - Update_Result result; - result.message = "one or more fields are invalid"; - result.field_errors[join_field_path(map_key_path(key), error.path())] = error.what(); - throw Json_Assignment_Error(std::move(result)); - } catch(const std::exception& error) { - Update_Result result; - result.message = "one or more fields are invalid"; - result.field_errors[map_key_path(key)] = error.what(); - throw Json_Assignment_Error(std::move(result)); - } - parsed.emplace(key, std::move(item)); - } - target = std::move(parsed); - } else if constexpr(Sequence_Type) { - if(!Json_Adapter::is_array(value)) { - throw std::invalid_argument("value must be a JSON array"); - } - Value parsed; - for(std::size_t index = 0; index < Json_Adapter::size(value); ++index) { - typename Value::value_type item{}; - const std::string path = "[" + std::to_string(index) + "]"; - try { - assign_json_value(item, Json_Adapter::at(value, index)); - } catch(const Json_Assignment_Error& error) { - throw Json_Assignment_Error(prefix_update_result(error.result(), path)); - } catch(const Field_Validation_Error& error) { - Update_Result result; - result.message = "one or more fields are invalid"; - result.field_errors[join_field_path(path, error.path())] = error.what(); - throw Json_Assignment_Error(std::move(result)); - } catch(const std::exception& error) { - Update_Result result; - result.message = "one or more fields are invalid"; - result.field_errors[path] = error.what(); - throw Json_Assignment_Error(std::move(result)); - } - parsed.push_back(std::move(item)); - } - target = std::move(parsed); + } else if constexpr(Container_Like_Type) { + static_assert(Always_False, "container types require an explicit adminive::Value_Adapter; Adminive does not infer vector/map serialization or UI semantics"); } else { - target = json_get(value); + target = decode_scalar_value(value); } } template @@ -241,6 +256,8 @@ std::string value_type_name() { using Value = Adapted_Value_Type; if constexpr(Value_Adapter_With_Type_Name) { return std::string(Value_Adapter::type_name); + } else if constexpr(Optional_Type) { + return value_type_name>(); } else if constexpr(Polymorphic_Type) { return "polymorphic"; } else if constexpr(std::is_enum_v) { @@ -251,14 +268,12 @@ std::string value_type_name() { return "integer"; } else if constexpr(std::floating_point) { return "number"; - } else if constexpr(String_Type) { + } else if constexpr(String_Type || String_View_Type) { return "string"; } else if constexpr(Described_Type || Snapshot_Adapted_Object) { return "object"; - } else if constexpr(String_Key_Map_Type) { - return "map"; - } else if constexpr(Sequence_Type) { - return "array"; + } else if constexpr(Container_Like_Type) { + static_assert(Always_False, "container types require an explicit adminive::Value_Adapter with type_name"); } else { return "unknown"; } @@ -284,9 +299,11 @@ void append_constraints(Json& result) { } if constexpr(!std::same_as) { append_constraints(result); + } else if constexpr(Optional_Type) { + append_constraints>(result); } - if constexpr(Polymorphic_Type) { - json_set(result, "polymorphic", polymorphic_descriptor_json()); + if constexpr(Polymorphic_Type, Json>) { + json_set(result, "polymorphic", polymorphic_descriptor_json>()); } if constexpr(Value_Adapter_With_Schema) { Value_Adapter::append_schema(result); @@ -294,7 +311,7 @@ void append_constraints(Json& result) { } template Json enum_options(const std::map& labels) { - using Value = Adapted_Value_Type; + using Value = Optional_Unwrapped_Type>; Json result = json_array(); if constexpr(std::is_enum_v) { static_assert(Enum_Type, "enum type requires an adminive::Enum_Adapter specialization"); @@ -340,8 +357,11 @@ Json polymorphic_descriptor_json() { using Adapter = Polymorphic_Adapter, Json>; const std::string discriminator(Adapter::discriminator()); const std::string discriminator_label(Adapter::discriminator_label()); - if(discriminator.empty()) { - throw std::invalid_argument("polymorphic discriminator must not be empty"); + if(!valid_data_name(discriminator)) { + throw std::invalid_argument("polymorphic discriminator must match [A-Za-z_][A-Za-z0-9_]*: " + discriminator); + } + if(discriminator_label.empty()) { + throw std::invalid_argument("polymorphic discriminator label must not be empty"); } Json variants = json_array(); std::set values; @@ -352,6 +372,15 @@ Json polymorphic_descriptor_json() { if(variant.value.empty()) { throw std::invalid_argument("polymorphic variant value must not be empty"); } + if(variant.label.empty()) { + throw std::invalid_argument("polymorphic variant label must not be empty: " + variant.value); + } + const auto variant_descriptor = describe>(); + std::apply([&](const auto&... field) { + if(((field.name() == discriminator) || ...)) { + throw std::invalid_argument("polymorphic discriminator conflicts with variant field: " + discriminator); + } + }, variant_descriptor.fields()); if(!values.insert(variant.value).second) { throw std::invalid_argument("duplicate polymorphic variant value: " + variant.value); } @@ -371,6 +400,7 @@ Json model_descriptor_json(const Model* defaults = nullptr) { using Field = std::remove_cvref_t; using Member = typename Field::member_type; using Value = Adapted_Value_Type; + using Descriptor_Value = Optional_Unwrapped_Type; Json field_json = json_object(); const int order = item.order() < 0 ? field_index : item.order(); ++field_index; @@ -378,6 +408,7 @@ Json model_descriptor_json(const Model* defaults = nullptr) { json_set(field_json, "label", item.label()); json_set(field_json, "list_label", item.list_label()); json_set(field_json, "value_type", value_type_name()); + json_set(field_json, "nullable", Optional_Type); json_set(field_json, "readable", item.is_readable()); json_set(field_json, "sensitive", item.is_sensitive()); json_set(field_json, "editable", item.is_editable()); @@ -399,8 +430,8 @@ Json model_descriptor_json(const Model* defaults = nullptr) { if(!item.visible_on().empty()) { json_set(field_json, "visible_on", item.visible_on()); } - if constexpr(Described_Type || Snapshot_Adapted_Object) { - json_set(field_json, "children", Json_Adapter::at(to_descriptor_json(), "fields")); + if constexpr(Described_Type || Snapshot_Adapted_Object) { + json_set(field_json, "children", Json_Adapter::at(to_descriptor_json(), "fields")); } append_constraints(field_json); auto options = enum_options(item.enum_labels()); @@ -533,7 +564,7 @@ Update_Result apply_model_json(Model& candidate, const Json& value, Write_Mode m return; } try { - assign_json_value(item.get(candidate), Json_Adapter::at(value, item.name())); + assign_json_value(item.get(candidate), Json_Adapter::at(value, item.name()), Write_Context{mode, complete}); } catch(const Json_Assignment_Error& error) { merge_update_errors(result, error.result(), item.name()); } catch(const Field_Validation_Error& error) { @@ -548,7 +579,15 @@ Update_Result apply_model_json(Model& candidate, const Json& value, Write_Mode m return result; } try { - descriptor.object_validator()(candidate); + if constexpr(std::invocable::validator_type, const Model&, Validation_Context&>) { + Validation_Context validation; + descriptor.object_validator()(candidate, validation); + for(const auto& [path, message] : validation.errors()) { + result.field_errors.insert_or_assign(path, message); + } + } else { + descriptor.object_validator()(candidate); + } } catch(const Json_Assignment_Error& error) { merge_update_errors(result, error.result()); } catch(const Field_Validation_Error& error) { @@ -565,14 +604,14 @@ Update_Result apply_model_json(Model& candidate, const Json& value, Write_Mode m result.message = complete ? "assigned" : "updated"; return result; } -template -requires std::default_initializable && Writable_Adapted_Object -Variant decode_polymorphic_variant(const Json& input) { +template +requires Creatable_Writable_Object +void decode_polymorphic_alternative(Variant& target, const Json& input, Write_Context context) { if(!Json_Adapter::is_object(input)) { throw std::invalid_argument("polymorphic value must be a JSON object"); } Json filtered = json_object(); - const auto descriptor = describe>(); + const auto descriptor = describe>(); std::apply([&](const auto&... field) { ([&] { if(Json_Adapter::contains(input, field.name())) { @@ -580,12 +619,22 @@ Variant decode_polymorphic_variant(const Json& input) { } }(), ...); }, descriptor.fields()); - Variant result{}; - auto update = assign_json(result, filtered); + if(context.mode == Write_Mode::update && std::holds_alternative(target)) { + auto& current = std::get(target); + auto update = apply_object_json(current, filtered, Write_Mode::update, false); + if(!update.success) { + throw Json_Assignment_Error(std::move(update)); + } + return; + } + Alternative created = Object_Adapter::create(); + const Write_Mode mode = context.mode == Write_Mode::update ? Write_Mode::create : context.mode; + const bool complete = context.mode == Write_Mode::update ? true : context.complete; + auto update = apply_object_json(created, filtered, mode, complete); if(!update.success) { throw Json_Assignment_Error(std::move(update)); } - return result; + target = std::move(created); } template Update_Result object_commit_error(const Field_Validation_Error& error) { @@ -659,13 +708,29 @@ Update_Result validate(const T& value) { Update_Result result; try { using Object = std::remove_cvref_t; + auto validate_model = [&](const auto& model) { + const auto descriptor = describe>(); + if constexpr(std::invocable::validator_type, const std::remove_cvref_t&, Validation_Context&>) { + Validation_Context validation; + descriptor.object_validator()(model, validation); + for(const auto& [path, message] : validation.errors()) { + result.field_errors.insert_or_assign(path, message); + } + } else { + descriptor.object_validator()(model); + } + }; if constexpr(std::same_as>) { - describe().object_validator()(value); + validate_model(value); } else { const auto model = Object_Adapter::snapshot(value); - describe>().object_validator()(model); + validate_model(model); + } + result.success = result.field_errors.empty(); + if(!result.success) { + result.message = "one or more fields are invalid"; + return result; } - result.success = true; result.message = "valid"; } catch(const Field_Validation_Error& error) { result.message = "one or more fields are invalid"; diff --git a/backend/src/config_store.cpp b/backend/src/config_store.cpp index f566e82..941e0f1 100644 --- a/backend/src/config_store.cpp +++ b/backend/src/config_store.cpp @@ -1,7 +1,37 @@ #include "config_store.hpp" +#include +#include #include #include +#include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif namespace adminive::example { +namespace { +void replace_file(const std::filesystem::path& temporary, const std::filesystem::path& target) { +#ifdef _WIN32 + const std::wstring temporary_path = temporary.wstring(); + const std::wstring target_path = target.wstring(); + BOOL replaced{}; + if(std::filesystem::exists(target)) { + replaced = ReplaceFileW(target_path.c_str(), temporary_path.c_str(), nullptr, REPLACEFILE_WRITE_THROUGH, nullptr, nullptr); + } else { + replaced = MoveFileExW(temporary_path.c_str(), target_path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); + } + if(!replaced) { + throw std::system_error(static_cast(GetLastError()), std::system_category(), "failed to replace configuration file"); + } +#else + if(std::rename(temporary.c_str(), target.c_str()) != 0) { + throw std::system_error(errno, std::generic_category(), "failed to replace configuration file"); + } +#endif +} +} Config_Store::Config_Store(std::filesystem::path path) : path_(std::move(path)) { load_or_create(); } @@ -17,35 +47,47 @@ std::mutex& Config_Store::mutex() noexcept { const std::filesystem::path& Config_Store::path() const noexcept { return path_; } -void Config_Store::persist_radio_service(const Radio_Service_Config& value) { +void Config_Store::write_radio_service_candidate(const Radio_Service_Config& value) { Application_Config candidate = data_; candidate.radio_service = value; persist_candidate(candidate); - data_ = std::move(candidate); } -void Config_Store::persist_alerts(const Alert_Config& value) { +void Config_Store::write_alerts_candidate(const Alert_Config& value) { Application_Config candidate = data_; candidate.alerts = value; persist_candidate(candidate); - data_ = std::move(candidate); } -void Config_Store::persist_serial_table(const Serial_Table_Config& value) { +void Config_Store::write_serial_table_candidate(const Serial_Table_Config& value) { Application_Config candidate = data_; candidate.serial_table = value; persist_candidate(candidate); - data_ = std::move(candidate); } -void Config_Store::persist_network_table(const Network_Table_Config& value) { +void Config_Store::write_network_table_candidate(const Network_Table_Config& value) { Application_Config candidate = data_; candidate.network_table = value; persist_candidate(candidate); - data_ = std::move(candidate); +} +void Config_Store::persist_radio_service(const Radio_Service_Config& value) { + write_radio_service_candidate(value); + data_.radio_service = value; +} +void Config_Store::persist_alerts(const Alert_Config& value) { + write_alerts_candidate(value); + data_.alerts = value; +} +void Config_Store::persist_serial_table(const Serial_Table_Config& value) { + write_serial_table_candidate(value); + data_.serial_table = value; +} +void Config_Store::persist_network_table(const Network_Table_Config& value) { + write_network_table_candidate(value); + data_.network_table = value; } void Config_Store::persist_default_device_type(Device_Table_Type value) { Application_Config candidate = data_; candidate.default_device_type = value; persist_candidate(candidate); - data_ = std::move(candidate); + data_.default_device_type = value; } void Config_Store::load_or_create() { if(!std::filesystem::exists(path_)) { @@ -57,28 +99,31 @@ void Config_Store::load_or_create() { throw std::runtime_error("failed to open configuration file: " + path_.string()); } const auto source = Json::parse(input); - const auto result = assign_json(data_, source); + const auto result = assign_json(data_, source); if(!result.success) { throw std::runtime_error("invalid configuration file: " + result.message); } } void Config_Store::persist_candidate(const Application_Config& value) { - std::filesystem::create_directories(path_.parent_path()); + const auto parent = path_.parent_path(); + if(!parent.empty()) { + std::filesystem::create_directories(parent); + } auto temporary = path_; temporary += ".tmp"; - { - std::ofstream output(temporary, std::ios::trunc); - if(!output) { - throw std::runtime_error("failed to write configuration file: " + temporary.string()); - } - output << to_json(value).dump(2) << '\n'; + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if(!output) { + throw std::runtime_error("failed to open temporary configuration file: " + temporary.string()); } - std::error_code error; - std::filesystem::remove(path_, error); - error.clear(); - std::filesystem::rename(temporary, path_, error); - if(error) { - throw std::runtime_error("failed to replace configuration file: " + error.message()); + output << to_json(value).dump(2) << '\n'; + output.flush(); + if(!output) { + throw std::runtime_error("failed to write temporary configuration file: " + temporary.string()); } + output.close(); + if(output.fail()) { + throw std::runtime_error("failed to close temporary configuration file: " + temporary.string()); + } + replace_file(temporary, path_); } } diff --git a/backend/src/config_store.hpp b/backend/src/config_store.hpp index 1fc6c60..0ad027e 100644 --- a/backend/src/config_store.hpp +++ b/backend/src/config_store.hpp @@ -15,6 +15,10 @@ public: void persist_serial_table(const Serial_Table_Config& value); void persist_network_table(const Network_Table_Config& value); void persist_default_device_type(Device_Table_Type value); + void write_radio_service_candidate(const Radio_Service_Config& value); + void write_alerts_candidate(const Alert_Config& value); + void write_serial_table_candidate(const Serial_Table_Config& value); + void write_network_table_candidate(const Network_Table_Config& value); private: void load_or_create(); void persist_candidate(const Application_Config& value); diff --git a/backend/src/device_tables.cpp b/backend/src/device_tables.cpp index 68f8e52..4717980 100644 --- a/backend/src/device_tables.cpp +++ b/backend/src/device_tables.cpp @@ -22,9 +22,9 @@ Json make_table_page(std::string_view title, std::string_view color, Json config return Json{{"type", "container"}, {"body", Json::array({std::move(heading), std::move(config_form), std::move(table_page.at("body"))})}}; } } -Serial_Device_Table::Serial_Device_Table(Config_Store& store) : store_(store), config_resource_(store.data().serial_table, "/admin/device_tables/serial/config", [&store](Serial_Table_Config&, Serial_Table_Config value) { - store.persist_serial_table(value); -}, &store.mutex()), rows_("/admin/device_tables/serial/items", make_serial_rows()) {} +Serial_Device_Table::Serial_Device_Table(Config_Store& store) : store_(store), config_resource_(store.data().serial_table, "/admin/device_tables/serial/config", adminive::make_resource_transaction([&store](const Serial_Table_Config& value, const Request_Context&) { + store.write_serial_table_candidate(value); +}), &store.mutex()), rows_("/admin/device_tables/serial/items", make_serial_rows()) {} Device_Table_Type Serial_Device_Table::type() const noexcept { return Device_Table_Type::serial; } @@ -40,15 +40,15 @@ Json Serial_Device_Table::amis_schema() const { std::scoped_lock lock(store_.mutex()); config = store_.data().serial_table; } - return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/serial/config/data", "确认修改"), rows_.amis_schema()); + return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/serial/config/data", "确认修改"), rows_.amis_schema()); } void Serial_Device_Table::bind(httplib::Server& server) { config_resource_.bind(server); rows_.bind(server); } -Network_Device_Table::Network_Device_Table(Config_Store& store) : store_(store), config_resource_(store.data().network_table, "/admin/device_tables/network/config", [&store](Network_Table_Config&, Network_Table_Config value) { - store.persist_network_table(value); -}, &store.mutex()), rows_("/admin/device_tables/network/items", make_network_rows()) {} +Network_Device_Table::Network_Device_Table(Config_Store& store) : store_(store), config_resource_(store.data().network_table, "/admin/device_tables/network/config", adminive::make_resource_transaction([&store](const Network_Table_Config& value, const Request_Context&) { + store.write_network_table_candidate(value); +}), &store.mutex()), rows_("/admin/device_tables/network/items", make_network_rows()) {} Device_Table_Type Network_Device_Table::type() const noexcept { return Device_Table_Type::network; } @@ -64,7 +64,7 @@ Json Network_Device_Table::amis_schema() const { std::scoped_lock lock(store_.mutex()); config = store_.data().network_table; } - return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/network/config/data", "确认修改"), rows_.amis_schema()); + return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/network/config/data", "确认修改"), rows_.amis_schema()); } void Network_Device_Table::bind(httplib::Server& server) { config_resource_.bind(server); diff --git a/backend/src/example.hpp b/backend/src/example.hpp index c55344e..7077e57 100644 --- a/backend/src/example.hpp +++ b/backend/src/example.hpp @@ -1,14 +1,10 @@ #pragma once #include "adminive/adminive.hpp" +#include "adminive/adapters/httplib.hpp" #include "adminive/adapters/magic_enum.hpp" #include "adminive/adapters/nlohmann_json.hpp" -#include "adminive/adapters/httplib.hpp" #include #include -#include -#include -#include -#include #include #include namespace adminive::example { @@ -17,18 +13,7 @@ template using Http_Resource = adminive::Http_Resource; template using Http_Collection_Resource = adminive::Http_Collection_Resource; -inline std::string format_utc_time(std::chrono::system_clock::time_point value) { - const std::time_t raw_time = std::chrono::system_clock::to_time_t(value); - std::tm utc_time{}; -#ifdef _WIN32 - gmtime_s(&utc_time, &raw_time); -#else - gmtime_r(&raw_time, &utc_time); -#endif - std::ostringstream stream; - stream << std::put_time(&utc_time, "%Y-%m-%dT%H:%M:%SZ"); - return stream.str(); -} +std::string format_utc_time(std::chrono::system_clock::time_point value); enum class Radio_Mode { receive, transmit, @@ -53,6 +38,20 @@ enum class Alert_Channel { email, webhook }; +enum class Serial_Parity { + none, + odd, + even +}; +enum class Network_Protocol { + tcp, + udp, + websocket +}; +enum class Device_Table_Type { + serial, + network +}; struct Server_Status { std::string service_state{"running"}; std::string server_time; @@ -95,143 +94,54 @@ struct Alert_Config { std::string highlight_color{"#d97706"}; Endpoint_Config webhook_endpoint{true, "127.0.0.1", Range_Value{9100}}; }; +struct Serial_Table_Config { + std::string group_name{"串口设备"}; + Range_Value default_baud_rate{115200}; + Serial_Parity default_parity{Serial_Parity::none}; + std::string accent_color{"#2563eb"}; +}; +struct Network_Table_Config { + std::string group_name{"网络设备"}; + Network_Protocol default_protocol{Network_Protocol::tcp}; + Range_Value timeout_ms{3000}; + bool tls_enabled{}; + std::string accent_color{"#16a34a"}; +}; +struct Application_Config { + Device_Table_Type default_device_type{Device_Table_Type::serial}; + Radio_Service_Config radio_service{}; + Alert_Config alerts{}; + Serial_Table_Config serial_table{}; + Network_Table_Config network_table{}; +}; +struct Serial_Device_Row { + std::string port_name; + Range_Value baud_rate{115200}; + Serial_Parity parity{Serial_Parity::none}; + bool enabled{true}; +}; +struct Network_Device_Row { + std::string endpoint; + Network_Protocol protocol{Network_Protocol::tcp}; + Range_Value timeout_ms{3000}; + bool tls_enabled{}; +}; struct Radio_State { struct Even_Validator { static constexpr int multiple_of = 2; static constexpr std::string_view message = "buffer_count must be even"; - void operator()(const int& value) const { - if(value % 2 != 0) { - throw std::invalid_argument("buffer_count must be even"); - } - } + void operator()(const int& value) const; }; struct Watermark_Validator { - void operator()(const Radio_State& value) const { - if(value.low_watermark > value.high_watermark) { - throw std::invalid_argument("low_watermark must not exceed high_watermark"); - } - } + void operator()(const Radio_State& value) const; }; Radio_Mode mode{Radio_Mode::receive}; Range_Value port{9999}; Validated_Value buffer_count{2}; int low_watermark{}; int high_watermark{}; - Radio_Item_Status status() { - const auto now = std::chrono::system_clock::now(); - const auto seconds = std::chrono::duration_cast(now.time_since_epoch()).count(); - const auto state_index = (static_cast(seconds / 4) + static_cast(port.value())) % 4; - Radio_Operating_State state{}; - std::string color; - switch(state_index) { - case 0: - state = Radio_Operating_State::starting; - color = "#2563eb"; - break; - case 1: - state = Radio_Operating_State::running; - color = "#16a34a"; - break; - case 2: - state = Radio_Operating_State::degraded; - color = "#d97706"; - break; - default: - state = Radio_Operating_State::stopped; - color = "#dc2626"; - break; - } - ++status_sequence_; - return Radio_Item_Status{status_value(state, std::move(color)), status_value(status_sequence_, "#475569"), status_value(format_utc_time(now), "#475569")}; - } + Radio_Item_Status status(); private: std::uint64_t status_sequence_{}; }; } -namespace adminive { -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Server_Status; - return object("server_status", ADMINIVE_FIELD(T, service_state), ADMINIVE_FIELD(T, server_time), ADMINIVE_FIELD(T, uptime_seconds), ADMINIVE_FIELD(T, poll_sequence), ADMINIVE_FIELD(T, radio_state_count), ADMINIVE_FIELD(T, listen_port)).label("Server Status"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Radio_Item_Status; - return object("radio_item_status", ADMINIVE_FIELD_LABEL(T, operating_state, "State"), ADMINIVE_FIELD_LABEL(T, refresh_sequence, "Refresh Sequence"), ADMINIVE_FIELD_LABEL(T, refresh_time, "Refresh Time")).label("Runtime Status"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Endpoint_Config; - return object( - "endpoint_config", - ADMINIVE_FIELD_LABEL(T, enabled, "Enable Endpoint").editable().creatable().description("Controls whether this endpoint participates in the current configuration"), - ADMINIVE_FIELD_LABEL(T, host, "Host Address").editable().creatable().required().description("String input example"), - ADMINIVE_FIELD_LABEL(T, port, "Port").editable().creatable().required().description("Integer input example") - ).label("Endpoint"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Appearance_Config; - return object( - "appearance_config", - ADMINIVE_FIELD_LABEL(T, panel_title, "Panel Title").editable().creatable().required().description("String input example"), - ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date").editable().creatable().required().widget("input-date").description("Date selector example"), - ADMINIVE_FIELD_LABEL(T, accent_color, "Accent Color").editable().creatable().required().widget("input-color").description("Color selector example") - ).label("Appearance"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Radio_Service_Config; - return object( - "radio_service_config", - ADMINIVE_FIELD_LABEL(T, profile_name, "Profile Name").editable().required().description("Editable string configuration"), - ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode").editable().required().description("Enum options are generated by magic_enum"), - ADMINIVE_FIELD_LABEL(T, worker_count, "Worker Count").editable().required().description("Integer number input"), - ADMINIVE_FIELD_LABEL(T, receive_gain, "Receive Gain").editable().required().description("Floating-point number input"), - ADMINIVE_FIELD_LABEL(T, primary_endpoint, "Primary Endpoint").editable().description("First child configuration group"), - ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint").editable().visible_on("${mode == 'duplex'}").description("Displayed only when Operating Mode is duplex"), - ADMINIVE_FIELD_LABEL(T, appearance, "Appearance").editable().description("Nested date and color controls") - ).label("Radio Service Configuration"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Alert_Config; - return object( - "alert_config", - ADMINIVE_FIELD_LABEL(T, enabled, "Enable Alerts").editable(), - ADMINIVE_FIELD_LABEL(T, rule_name, "Rule Name").editable().required(), - ADMINIVE_FIELD_LABEL(T, channel, "Delivery Channel").editable().required().description("Enum options are generated by magic_enum"), - ADMINIVE_FIELD_LABEL(T, minimum_level, "Minimum Log Level").editable().required().description("Second enum example"), - ADMINIVE_FIELD_LABEL(T, repeat_minutes, "Repeat Interval Minutes").editable().required(), - ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date").editable().required().widget("input-date"), - ADMINIVE_FIELD_LABEL(T, highlight_color, "Highlight Color").editable().required().widget("input-color"), - ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook Endpoint").editable().visible_on("${enabled && channel == 'webhook'}").description("Displayed only when alerts are enabled and channel is webhook") - ).label("Alert Configuration"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Radio_State; - return object( - "radio_state", - ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode").creatable().editable().required().list_label("Mode").order(5).sortable().description("Enum list column generated through magic_enum"), - ADMINIVE_FIELD(T, port).creatable().editable().required().description("Listening port").list_label("Listen Port").order(20).sortable(), - ADMINIVE_FIELD(T, buffer_count).creatable().editable().required().list_label("Buffers").order(10).sortable(), - ADMINIVE_FIELD(T, low_watermark).creatable().editable().order(30), - ADMINIVE_FIELD(T, high_watermark).creatable().editable().order(40) - ).label("Radio State").validator(T::Watermark_Validator{}); - } -}; -} diff --git a/backend/src/example_descriptors.hpp b/backend/src/example_descriptors.hpp index 73117d1..c4782b7 100644 --- a/backend/src/example_descriptors.hpp +++ b/backend/src/example_descriptors.hpp @@ -50,7 +50,7 @@ struct Type_Descriptor { ADMINIVE_FIELD_LABEL(T, worker_count, "工作线程数").editable().required(), ADMINIVE_FIELD_LABEL(T, receive_gain, "接收增益").editable().required(), ADMINIVE_FIELD_LABEL(T, primary_endpoint, "主端点").editable(), - ADMINIVE_FIELD_LABEL(T, backup_endpoint, "备用端点").editable().visible_on("${mode == 'duplex'}"), + ADMINIVE_FIELD_LABEL(T, backup_endpoint, "备用端点").editable().visible_on("${$self.mode == 'duplex'}"), ADMINIVE_FIELD_LABEL(T, appearance, "界面显示").editable() ).label("无线电服务配置").confirm_label("确认修改"); } @@ -68,7 +68,7 @@ struct Type_Descriptor { ADMINIVE_FIELD_LABEL(T, repeat_minutes, "重复间隔(分钟)").editable().required(), ADMINIVE_FIELD_LABEL(T, effective_date, "生效日期").editable().required().widget("input-date"), ADMINIVE_FIELD_LABEL(T, highlight_color, "高亮颜色").editable().required().widget("input-color"), - ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook 端点").editable().visible_on("${enabled && channel == 'webhook'}") + ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook 端点").editable().visible_on("${$self.enabled && $self.channel == 'webhook'}") ).label("告警配置").confirm_label("确认修改"); } }; diff --git a/backend/src/main.cpp b/backend/src/main.cpp index 12cbc99..95c8b3a 100644 --- a/backend/src/main.cpp +++ b/backend/src/main.cpp @@ -1,109 +1,51 @@ -#include "example.hpp" -#include +#include "server_app.hpp" #include -#include #include #include #include -#include #ifndef ADMINIVE_FRONTEND_DIST_DIR #define ADMINIVE_FRONTEND_DIST_DIR "frontend_dist" #endif namespace { -using Alert_Config = adminive::example::Alert_Config; -using Radio_Item_Status = adminive::example::Radio_Item_Status; -using Radio_Mode = adminive::example::Radio_Mode; -using Radio_Service_Config = adminive::example::Radio_Service_Config; -using Radio_State = adminive::example::Radio_State; -using Json = adminive::example::Json; -using State_Resource = adminive::example::Http_Collection_Resource; -using Service_Config_Resource = adminive::example::Http_Resource; -using Alert_Config_Resource = adminive::example::Http_Resource; -Radio_State make_radio_state(Radio_Mode mode, int port, int buffer_count, int low_watermark, int high_watermark) { - Radio_State result; - result.mode = mode; - result.port = port; - result.buffer_count = buffer_count; - result.low_watermark = low_watermark; - result.high_watermark = high_watermark; - return result; +int parse_port(int argc, char** argv) { + if(argc == 1) { + return 9999; + } + if(argc != 2) { + return 0; + } + int port{}; + const std::string_view text(argv[1]); + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), port); + if(error != std::errc{} || end != text.data() + text.size() || port < 1 || port > 65535) { + return 0; + } + return port; } -Json make_admin_schema(const State_Resource& states, const Service_Config_Resource& service_config, const Alert_Config_Resource& alert_config) { - const Json states_schema = states.amis_schema(); - Json tabs = Json::array(); - tabs.push_back(Json{{"title", "Radio State List"}, {"body", states_schema.at("body")}}); - tabs.push_back(Json{{"title", "Radio Service Configuration"}, {"body", service_config.amis_schema()}}); - tabs.push_back(Json{{"title", "Alert Configuration"}, {"body", alert_config.amis_schema()}}); - return Json{{"type", "page"}, {"title", "Adminive Complete Example"}, {"subTitle", "CRUD, enum columns, lazy status, hierarchical configuration, linkage, date and color controls are defined by the backend"}, {"body", Json{{"type", "tabs"}, {"tabs", std::move(tabs)}}}}; +void describe_example() { + using namespace adminive::example; + Radio_State state; + Radio_Service_Config service; + Alert_Config alerts; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_json(state).dump(2) << '\n'; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_amis_form_schema(service, "/admin/config/radio/data", "确认修改").dump(2) << '\n'; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_amis_form_schema(alerts, "/admin/config/alerts/data", "确认修改").dump(2) << '\n'; } } int main(int argc, char** argv) { if(argc == 2 && std::string_view(argv[1]) == "--describe") { - Radio_State state; - Radio_Service_Config service_config; - Alert_Config alert_config; - const auto status_descriptor = adminive::to_status_descriptor_json(); - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_json(state).dump(2) << '\n'; - std::cout << status_descriptor.dump(2) << '\n'; - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_amis_form_schema(service_config, "/admin/config/radio/data", "Confirm Changes").dump(2) << '\n'; - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_amis_form_schema(alert_config, "/admin/config/alerts/data", "Confirm Changes").dump(2) << '\n'; + describe_example(); return 0; } - int port = 9999; - if(argc == 2) { - const std::string_view text(argv[1]); - const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), port); - if(error != std::errc{} || end != text.data() + text.size() || port < 1 || port > 65535) { - std::cerr << "invalid port\n"; - return 2; - } + const int port = parse_port(argc, argv); + if(port == 0) { + std::cerr << "invalid port\n"; + return 2; } - const auto started_at = std::chrono::steady_clock::now(); - std::vector initial; - initial.push_back(make_radio_state(Radio_Mode::receive, 10001, 2, 16, 64)); - initial.push_back(make_radio_state(Radio_Mode::transmit, 10002, 4, 32, 128)); - initial.push_back(make_radio_state(Radio_Mode::duplex, 10003, 8, 64, 256)); - Radio_Service_Config radio_service_config; - Alert_Config alert_config; - httplib::Server server; - State_Resource state_resource("/admin/radio_states", std::move(initial)); - Service_Config_Resource service_config_resource(radio_service_config, "/admin/config/radio"); - Alert_Config_Resource alert_config_resource(alert_config, "/admin/config/alerts"); - state_resource.register_overview_status("/admin/status", 2000); - state_resource.register_status<&Radio_State::status>(2000); - state_resource.bind(server); - service_config_resource.bind(server); - alert_config_resource.bind(server); - server.Get("/admin/amis", [&](const httplib::Request&, httplib::Response& response) { - adminive::write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", make_admin_schema(state_resource, service_config_resource, alert_config_resource)}}); - }); - std::atomic poll_sequence{}; - server.Get("/admin/status", [&](const httplib::Request&, httplib::Response& response) { - const auto now = std::chrono::system_clock::now(); - const auto uptime = std::chrono::duration_cast(std::chrono::steady_clock::now() - started_at).count(); - adminive::example::Server_Status status; - status.server_time = adminive::example::format_utc_time(now); - status.uptime_seconds = static_cast(uptime); - status.poll_sequence = poll_sequence.fetch_add(1, std::memory_order_relaxed) + 1; - status.radio_state_count = state_resource.size(); - status.listen_port = port; - adminive::write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", adminive::to_status_json(status)}}); - }); - const std::filesystem::path frontend_dist = std::filesystem::path(ADMINIVE_FRONTEND_DIST_DIR).lexically_normal(); - if(!server.set_mount_point("/", frontend_dist.string())) { - std::cerr << "frontend build directory does not exist: " << frontend_dist.string() << '\n'; - std::cerr << "run npm run build from the project root before starting the backend\n"; - return 3; - } - std::cout << "Adminive server listening on http://127.0.0.1:" << port << '\n'; - std::cout << "Serving frontend from " << frontend_dist.string() << '\n'; - std::cout << std::flush; - if(!server.listen("0.0.0.0", port)) { - std::cerr << "failed to start server\n"; - return 1; - } - return 0; + const std::filesystem::path config_file = std::filesystem::current_path() / "config" / "adminive.json"; + adminive::example::Server_App app(port, std::filesystem::path(ADMINIVE_FRONTEND_DIST_DIR), config_file); + return app.run(); } diff --git a/backend/src/server_app.cpp b/backend/src/server_app.cpp index 9df0a7e..2e95e18 100644 --- a/backend/src/server_app.cpp +++ b/backend/src/server_app.cpp @@ -12,11 +12,11 @@ Radio_State make_radio_state(Radio_Mode mode, int port, int buffer_count, int lo return result; } } -Server_App::Server_App(int port, std::filesystem::path frontend_dist, std::filesystem::path config_file) : port_(port), frontend_dist_(std::move(frontend_dist)), config_store_(std::move(config_file)), state_resource_("/admin/radio_states", make_radio_states()), service_config_resource_(config_store_.data().radio_service, "/admin/config/radio", [this](Radio_Service_Config&, Radio_Service_Config value) { - config_store_.persist_radio_service(value); -}, &config_store_.mutex()), alert_config_resource_(config_store_.data().alerts, "/admin/config/alerts", [this](Alert_Config&, Alert_Config value) { - config_store_.persist_alerts(value); -}, &config_store_.mutex()), device_tables_(config_store_) { +Server_App::Server_App(int port, std::filesystem::path frontend_dist, std::filesystem::path config_file) : port_(port), frontend_dist_(std::move(frontend_dist)), config_store_(std::move(config_file)), state_resource_("/admin/radio_states", make_radio_states()), service_config_resource_(config_store_.data().radio_service, "/admin/config/radio", adminive::make_resource_transaction([this](const Radio_Service_Config& value, const Request_Context&) { + config_store_.write_radio_service_candidate(value); +}), &config_store_.mutex()), alert_config_resource_(config_store_.data().alerts, "/admin/config/alerts", adminive::make_resource_transaction([this](const Alert_Config& value, const Request_Context&) { + config_store_.write_alerts_candidate(value); +}), &config_store_.mutex()), device_tables_(config_store_) { state_resource_.register_overview_status("/admin/status", 2000); state_resource_.register_status<&Radio_State::status>(2000); bind_routes(); diff --git a/backend/tests/advanced_adapter_test.cpp b/backend/tests/advanced_adapter_test.cpp index b34e559..ced154d 100644 --- a/backend/tests/advanced_adapter_test.cpp +++ b/backend/tests/advanced_adapter_test.cpp @@ -172,14 +172,14 @@ template <> struct Type_Descriptor { static auto get() { using T = advanced_test::Serial_Device; - return object("serial", "Serial", ADMINIVE_FIELD(T, port).editable(), ADMINIVE_FIELD(T, baud_rate).editable(), ADMINIVE_FIELD(T, color).editable()); + return object("serial", "Serial", ADMINIVE_FIELD(T, port).editable().creatable(), ADMINIVE_FIELD(T, baud_rate).editable().creatable(), ADMINIVE_FIELD(T, color).editable().creatable()); } }; template <> struct Type_Descriptor { static auto get() { using T = advanced_test::Network_Device; - return object("network", "Network", ADMINIVE_FIELD(T, host).editable(), ADMINIVE_FIELD(T, port).editable()); + return object("network", "Network", ADMINIVE_FIELD(T, host).editable().creatable(), ADMINIVE_FIELD(T, port).editable().creatable()); } }; template <> @@ -204,14 +204,14 @@ struct Polymorphic_Adapter { return result; }, value); } - static void decode(advanced_test::Device& target, const advanced_test::Json& value) { + static void decode(advanced_test::Device& target, const advanced_test::Json& value, Write_Context context) { const std::string type = value.at("type").get(); if(type == "serial") { - target = decode_polymorphic_variant(value); + decode_polymorphic_alternative(target, value, context); return; } if(type == "network") { - target = decode_polymorphic_variant(value); + decode_polymorphic_alternative(target, value, context); return; } throw Field_Validation_Error("type", "unknown device type"); @@ -257,13 +257,16 @@ int main() { using namespace advanced_test; Runtime_Config runtime; int transaction_count{}; - adminive::Resource_Service service(runtime, "/config", [&transaction_count](Runtime_Config& target, Runtime_Model candidate) { + adminive::Resource_Transaction transaction; + transaction.prepare = [](const Runtime_Model& candidate, const adminive::Request_Context&) { if(candidate.name == "blocked") { throw adminive::Field_Validation_Error("name", "name is blocked"); } - target.apply(candidate); + }; + transaction.commit = [&transaction_count](const Runtime_Model&, const adminive::Request_Context&) { ++transaction_count; - }); + }; + adminive::Resource_Service service(runtime, "/config", std::move(transaction)); const auto invalid = service.update_response(R"({"nested":{"count":0}})"); assert(invalid.status == 422); assert(invalid.body.at("field_errors").contains("nested.count")); diff --git a/backend/tests/config_store_test.cpp b/backend/tests/config_store_test.cpp new file mode 100644 index 0000000..1f9b164 --- /dev/null +++ b/backend/tests/config_store_test.cpp @@ -0,0 +1,27 @@ +#include "config_store.hpp" +#include +#include +#include +int main() { + using namespace adminive::example; + const auto test_directory = std::filesystem::temp_directory_path() / "adminive_config_store_test"; + const auto config_path = test_directory / "config" / "adminive.json"; + std::filesystem::remove_all(test_directory); + { + Config_Store store(config_path); + assert(std::filesystem::exists(config_path)); + assert(store.data().radio_service.profile_name == "Primary Radio Profile"); + auto updated = store.data().radio_service; + updated.profile_name = "Persisted Profile"; + store.persist_radio_service(updated); + assert(store.data().radio_service.profile_name == "Persisted Profile"); + assert(!std::filesystem::exists(config_path.string() + ".tmp")); + } + { + Config_Store store(config_path); + assert(store.data().radio_service.profile_name == "Persisted Profile"); + assert(store.data().default_device_type == Device_Table_Type::serial); + } + std::filesystem::remove_all(test_directory); + return 0; +} diff --git a/backend/tests/core_adapter_test.cpp b/backend/tests/core_adapter_test.cpp index 32926a4..6f65e8f 100644 --- a/backend/tests/core_adapter_test.cpp +++ b/backend/tests/core_adapter_test.cpp @@ -15,7 +15,7 @@ struct Mini_Json; using Mini_Object = std::map; using Mini_Array = std::vector; struct Mini_Json { - using Storage = std::variant; + using Storage = std::variant; Storage value{}; }; struct External_Atomic { @@ -45,12 +45,18 @@ struct Json_Adapter { static Json array() { return Json{adapter_test::Mini_Array{}}; } + static Json null() { + return Json{nullptr}; + } static Json parse(std::string_view) { throw std::runtime_error("parse is not implemented by the test adapter"); } static std::string dump(const Json&, int) { return {}; } + static bool is_null(const Json& value) noexcept { + return std::holds_alternative(value.value); + } static bool is_object(const Json& value) noexcept { return std::holds_alternative(value.value); } @@ -60,13 +66,34 @@ struct Json_Adapter { static bool is_string(const Json& value) noexcept { return std::holds_alternative(value.value); } - static bool is_number(const Json& value) noexcept { + static bool is_signed_integer(const Json& value) noexcept { + return std::holds_alternative(value.value); + } + static bool is_unsigned_integer(const Json& value) noexcept { + return std::holds_alternative(value.value); + } + static bool is_floating_point(const Json& value) noexcept { return std::holds_alternative(value.value); } + static bool is_number(const Json& value) noexcept { + return is_signed_integer(value) || is_unsigned_integer(value) || is_floating_point(value); + } static bool is_boolean(const Json& value) noexcept { return std::holds_alternative(value.value); } + static std::int64_t signed_integer(const Json& value) { + return std::get(value.value); + } + static std::uint64_t unsigned_integer(const Json& value) { + return std::get(value.value); + } static long double number(const Json& value) { + if(is_signed_integer(value)) { + return static_cast(signed_integer(value)); + } + if(is_unsigned_integer(value)) { + return static_cast(unsigned_integer(value)); + } return std::get(value.value); } static bool contains(const Json& value, std::string_view name) { @@ -116,7 +143,11 @@ struct Json_Adapter { using Value = std::remove_cvref_t; if constexpr(std::same_as) { return Json{value}; - } else if constexpr(std::integral || std::floating_point) { + } else if constexpr(std::signed_integral) { + return Json{static_cast(value)}; + } else if constexpr(std::unsigned_integral) { + return Json{static_cast(value)}; + } else if constexpr(std::floating_point) { return Json{static_cast(value)}; } else if constexpr(std::same_as) { return Json{std::forward(value)}; @@ -132,8 +163,12 @@ struct Json_Adapter { static T get(const Json& value) { if constexpr(std::same_as) { return std::get(value.value); - } else if constexpr(std::integral || std::floating_point) { - return static_cast(std::get(value.value)); + } else if constexpr(std::signed_integral) { + return static_cast(std::get(value.value)); + } else if constexpr(std::unsigned_integral) { + return static_cast(std::get(value.value)); + } else if constexpr(std::floating_point) { + return static_cast(number(value)); } else if constexpr(std::same_as) { return std::get(value.value); } else { diff --git a/backend/tests/drogon_adapter_test.cpp b/backend/tests/drogon_adapter_test.cpp index 34df165..e4d0687 100644 --- a/backend/tests/drogon_adapter_test.cpp +++ b/backend/tests/drogon_adapter_test.cpp @@ -9,6 +9,9 @@ namespace drogon_test { struct Config { std::string name{"default"}; }; +struct Status { + std::string user; +}; } namespace adminive { template <> @@ -18,19 +21,50 @@ struct Type_Descriptor { return object("config", "Config", ADMINIVE_FIELD(T, name).editable()); } }; +template <> +struct Type_Descriptor { + static auto get() { + using T = drogon_test::Status; + return object("status", "Status", ADMINIVE_FIELD(T, user).label("User")); + } +}; } int main() { + using Json = nlohmann::json; drogon_test::Config config; drogon::HttpAppFramework app; std::vector> tasks; + std::string committed_user; + adminive::Resource_Transaction transaction; + transaction.commit = [&committed_user](const drogon_test::Config&, const adminive::Request_Context& context) { + committed_user = context.user; + }; + adminive::Drogon_Bind_Options options; + options.executor = [&tasks](std::function task) { + tasks.push_back(std::move(task)); + }; + options.filters = {"LoginFilter", "AdminFilter"}; + options.context_factory = [](const drogon::HttpRequestPtr& request) { + adminive::Request_Context context; + context.user = request->user; + return context; + }; { - adminive::Drogon_Resource resource(config, "/config"); - resource.bind(app, [&tasks](std::function task) { - tasks.push_back(std::move(task)); + adminive::Drogon_Resource resource(config, "/config", std::move(transaction)); + resource.bind(app, options); + adminive::Drogon_Status_Resource status("/status", [](const adminive::Request_Context& context) { + if(context.user == "error") { + throw std::runtime_error("status failed"); + } + return drogon_test::Status{context.user}; }); + status.bind(app, options); } + assert(app.filters("/config/data", drogon::Post).size() == 2); + assert(app.filters("/status/data", drogon::Get).size() == 2); auto request = std::make_shared(); request->body = R"({"name":"updated"})"; + request->user = "wyc"; drogon::HttpResponsePtr response; app.handle("/config/data", drogon::Post, request, [&response](const drogon::HttpResponsePtr& value) { response = value; @@ -38,8 +72,33 @@ int main() { assert(!response); assert(tasks.size() == 1); tasks.front()(); + tasks.erase(tasks.begin()); assert(response); assert(response->status == drogon::k200OK); assert(config.name == "updated"); + assert(committed_user == "wyc"); + response.reset(); + app.handle("/status/data", drogon::Get, request, [&response](const drogon::HttpResponsePtr& value) { + response = value; + }); + assert(!response); + assert(tasks.size() == 1); + tasks.front()(); + tasks.erase(tasks.begin()); + assert(response->status == drogon::k200OK); + assert(Json::parse(response->body).at("data").at("user").at("value") == "wyc"); + request->user = "error"; + response.reset(); + app.handle("/status/data", drogon::Get, request, [&response](const drogon::HttpResponsePtr& value) { + response = value; + }); + tasks.front()(); + tasks.erase(tasks.begin()); + assert(response->status == drogon::k500InternalServerError); + response.reset(); + app.handle("/status/descriptor", drogon::Get, request, [&response](const drogon::HttpResponsePtr& value) { + response = value; + }); + assert(response->status == drogon::k200OK); return 0; } diff --git a/backend/tests/fake_drogon/drogon/drogon.h b/backend/tests/fake_drogon/drogon/drogon.h index c1d774a..7cdd7c8 100644 --- a/backend/tests/fake_drogon/drogon/drogon.h +++ b/backend/tests/fake_drogon/drogon/drogon.h @@ -20,6 +20,26 @@ enum HttpMethod { Get, Post }; +namespace internal { +class HttpConstraint { +public: + HttpConstraint(HttpMethod method) : method_(method), is_method_(true) {} + HttpConstraint(std::string filter) : filter_(std::move(filter)) {} + bool is_method() const noexcept { + return is_method_; + } + HttpMethod method() const noexcept { + return method_; + } + const std::string& filter() const noexcept { + return filter_; + } +private: + HttpMethod method_{Get}; + bool is_method_{}; + std::string filter_; +}; +} class HttpResponse { public: static std::shared_ptr newHttpResponse() { @@ -45,6 +65,7 @@ public: return body; } std::string body; + std::string user; }; using HttpRequestPtr = std::shared_ptr; class HttpAppFramework { @@ -52,10 +73,17 @@ public: using Callback = std::function; using Handler = std::function; template - void registerHandler(const std::string& path, Function&& function, const std::vector& methods) { - for(const auto method : methods) { - routes_.push_back(Route{path, method, Handler(std::forward(function))}); + void registerHandler(const std::string& path, Function&& function, const std::vector& constraints) { + HttpMethod method = Get; + std::vector filters; + for(const auto& constraint : constraints) { + if(constraint.is_method()) { + method = constraint.method(); + } else { + filters.push_back(constraint.filter()); + } } + routes_.push_back(Route{path, method, Handler(std::forward(function)), std::move(filters)}); } void handle(const std::string& path, HttpMethod method, const HttpRequestPtr& request, Callback callback) { for(auto& route : routes_) { @@ -66,11 +94,20 @@ public: } throw std::runtime_error("route is not registered"); } + const std::vector& filters(const std::string& path, HttpMethod method) const { + for(const auto& route : routes_) { + if(route.path == path && route.method == method) { + return route.filters; + } + } + throw std::runtime_error("route is not registered"); + } private: struct Route { std::string path; HttpMethod method; Handler handler; + std::vector filters; }; std::vector routes_; }; diff --git a/backend/tests/safety_test.cpp b/backend/tests/safety_test.cpp new file mode 100644 index 0000000..0f8bd5e --- /dev/null +++ b/backend/tests/safety_test.cpp @@ -0,0 +1,343 @@ +#include "adminive/adapters/nlohmann_json.hpp" +#include "adminive/http.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace safety_test { +using Json = nlohmann::json; +struct Child_Config { + int editable_value{1}; + int locked_value{2}; + int required_value{3}; +}; +struct Root_Config { + Child_Config child; +}; +struct Numeric_Config { + int signed_value{}; + unsigned int unsigned_value{}; +}; +struct Optional_Config { + std::optional count{3}; + std::optional note{"value"}; +}; +struct Multi_Error_Config { + int first{1}; + int second{1}; +}; +struct Runtime_Model { + int value{1}; +}; +class Runtime_Config { +public: + Runtime_Config() = default; + Runtime_Config(const Runtime_Config&) = delete; + Runtime_Config& operator=(const Runtime_Config&) = delete; + int value() const noexcept { + return value_; + } + void apply(const Runtime_Model& model) { + value_ = model.value; + } +private: + int value_{1}; +}; +struct Serial_Device { + std::string port{"COM1"}; + int baud_rate{115200}; +}; +struct Network_Device { + std::string host{"127.0.0.1"}; + int port{9000}; +}; +class Token_Device { +public: + explicit Token_Device(std::string value) : token(std::move(value)) {} + std::string token; +}; +using Device = std::variant; +struct Device_Config { + Device device{Serial_Device{}}; +}; +struct Invalid_Name_Config { + int value{}; +}; +struct Conflict_Device { + std::string type; +}; +using Conflict_Variant = std::variant; +struct Conflict_Config { + Conflict_Variant device{Conflict_Device{}}; +}; +} +namespace adminive { +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Child_Config; + return object("child", "Child", ADMINIVE_FIELD(T, editable_value).editable().creatable().required(), ADMINIVE_FIELD(T, locked_value), ADMINIVE_FIELD(T, required_value).editable().creatable().required().visible_on("${$self.editable_value > 0}")); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Root_Config; + return object("root", "Root", ADMINIVE_FIELD(T, child).editable().creatable().required()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Numeric_Config; + return object("numeric", "Numeric", ADMINIVE_FIELD(T, signed_value).editable(), ADMINIVE_FIELD(T, unsigned_value).editable()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Optional_Config; + return object("optional", "Optional", ADMINIVE_FIELD(T, count).editable(), ADMINIVE_FIELD(T, note).editable()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Multi_Error_Config; + return object("multi_error", "Multi Error", ADMINIVE_FIELD(T, first).editable(), ADMINIVE_FIELD(T, second).editable()).validator([](const T& value, Validation_Context& context) { + if(value.first < 0) { + context.error("first", "first must not be negative"); + } + if(value.second < 0) { + context.error("second", "second must not be negative"); + } + }); + } +}; +template <> +struct Object_Adapter { + using model_type = safety_test::Runtime_Model; + static model_type snapshot(const safety_test::Runtime_Config& value) { + return model_type{value.value()}; + } + static void commit(safety_test::Runtime_Config& target, const model_type& value) { + target.apply(value); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Runtime_Model; + return object("runtime", "Runtime", ADMINIVE_FIELD(T, value).editable()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Serial_Device; + return object("serial", "Serial", ADMINIVE_FIELD(T, port).editable().creatable().required(), ADMINIVE_FIELD(T, baud_rate).editable().creatable().required()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Network_Device; + return object("network", "Network", ADMINIVE_FIELD(T, host).editable().creatable().required(), ADMINIVE_FIELD(T, port).editable().creatable().required()); + } +}; +template <> +struct Object_Adapter { + using model_type = safety_test::Token_Device; + static model_type snapshot(const safety_test::Token_Device& value) { + return value; + } + static model_type create() { + return model_type("default-token"); + } + static void commit(safety_test::Token_Device& target, model_type value) { + target = std::move(value); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Token_Device; + return object("token", "Token", ADMINIVE_FIELD(T, token).editable().creatable().required()); + } +}; +template <> +struct Polymorphic_Adapter { + static constexpr std::string_view discriminator() noexcept { + return "type"; + } + static constexpr std::string_view discriminator_label() noexcept { + return "Device Type"; + } + static auto variants() { + return std::tuple(polymorphic_variant("serial", "Serial"), polymorphic_variant("network", "Network"), polymorphic_variant("token", "Token")); + } + static safety_test::Json encode(const safety_test::Device& value) { + return std::visit([](const auto& item) { + safety_test::Json result = to_json(item); + using T = std::remove_cvref_t; + if constexpr(std::same_as) { + result["type"] = "serial"; + } else if constexpr(std::same_as) { + result["type"] = "network"; + } else { + result["type"] = "token"; + } + return result; + }, value); + } + static void decode(safety_test::Device& target, const safety_test::Json& value, Write_Context context) { + const std::string type = value.at("type").get(); + if(type == "serial") { + decode_polymorphic_alternative(target, value, context); + return; + } + if(type == "network") { + decode_polymorphic_alternative(target, value, context); + return; + } + if(type == "token") { + decode_polymorphic_alternative(target, value, context); + return; + } + throw Field_Validation_Error("type", "unknown device type"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Device_Config; + return object("device_config", "Device Config", ADMINIVE_FIELD(T, device).editable()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Invalid_Name_Config; + return object("invalid_name", "Invalid Name", field<&T::value>("bad.name")); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Conflict_Device; + return object("conflict_device", "Conflict Device", ADMINIVE_FIELD(T, type).editable().creatable()); + } +}; +template <> +struct Polymorphic_Adapter { + static constexpr std::string_view discriminator() noexcept { + return "type"; + } + static constexpr std::string_view discriminator_label() noexcept { + return "Type"; + } + static auto variants() { + return std::tuple(polymorphic_variant("conflict", "Conflict")); + } + static safety_test::Json encode(const safety_test::Conflict_Variant& value) { + safety_test::Json result = to_json(std::get(value)); + result["type"] = "conflict"; + return result; + } + static void decode(safety_test::Conflict_Variant& target, const safety_test::Json& value, Write_Context context) { + decode_polymorphic_alternative(target, value, context); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = safety_test::Conflict_Config; + return object("conflict_config", "Conflict Config", ADMINIVE_FIELD(T, device).editable()); + } +}; +} +int main() { + using namespace safety_test; + Root_Config root; + auto update = adminive::apply_frontend_patch(root, Json{{"child", Json{{"editable_value", 8}}}}); + assert(update.success); + assert(root.child.editable_value == 8); + assert(root.child.required_value == 3); + update = adminive::apply_frontend_patch(root, Json{{"child", Json{{"locked_value", 9}}}}); + assert(!update.success); + assert(update.field_errors.contains("child.locked_value")); + assert(root.child.locked_value == 2); + Root_Config created; + update = adminive::apply_frontend_create(created, Json{{"child", Json{{"editable_value", 5}}}}); + assert(!update.success); + assert(update.field_errors.contains("child.required_value")); + const auto root_form = adminive::to_amis_form_schema(root); + assert(root_form.at("body").at(0).at("body").at(2).at("visibleOn") == "${child.editable_value > 0}"); + Numeric_Config numeric; + update = adminive::apply_frontend_patch(numeric, Json{{"signed_value", 1.5}}); + assert(!update.success); + update = adminive::apply_frontend_patch(numeric, Json{{"unsigned_value", -1}}); + assert(!update.success); + update = adminive::apply_frontend_patch(numeric, Json{{"signed_value", std::numeric_limits::max()}}); + assert(!update.success); + Optional_Config optional; + const auto optional_form = adminive::to_amis_form_schema(optional); + assert(optional_form.at("body").at(0).at("clearable") == true); + assert(optional_form.at("body").at(1).at("clearable") == true); + update = adminive::apply_frontend_patch(optional, Json{{"count", nullptr}, {"note", nullptr}}); + assert(update.success); + assert(!optional.count); + assert(!optional.note); + Multi_Error_Config multi; + update = adminive::apply_frontend_patch(multi, Json{{"first", -1}, {"second", -2}}); + assert(!update.success); + assert(update.field_errors.contains("first")); + assert(update.field_errors.contains("second")); + Runtime_Config runtime; + bool rollback_called{}; + adminive::Resource_Transaction transaction; + transaction.commit = [](const Runtime_Model&, const adminive::Request_Context&) { + throw std::runtime_error("external commit failed"); + }; + transaction.rollback = [&rollback_called](const Runtime_Model& original, const adminive::Request_Context&) { + assert(original.value == 1); + rollback_called = true; + }; + adminive::Resource_Service service(runtime, "/runtime", std::move(transaction)); + const auto response = service.update_response(R"({"value":7})"); + assert(response.status == 500); + assert(runtime.value() == 1); + assert(rollback_called); + Device_Config devices; + update = adminive::apply_frontend_patch(devices, Json{{"device", Json{{"type", "serial"}, {"baud_rate", 9600}}}}); + assert(update.success); + assert(std::get(devices.device).port == "COM1"); + assert(std::get(devices.device).baud_rate == 9600); + update = adminive::apply_frontend_patch(devices, Json{{"device", Json{{"type", "network"}, {"host", "10.0.0.1"}, {"port", 7000}}}}); + assert(update.success); + assert(std::holds_alternative(devices.device)); + update = adminive::apply_frontend_patch(devices, Json{{"device", Json{{"type", "token"}, {"token", "abc"}}}}); + assert(update.success); + assert(std::holds_alternative(devices.device)); + assert(std::get(devices.device).token == "abc"); + bool invalid_name_rejected{}; + try { + static_cast(adminive::to_descriptor_json()); + } catch(const std::invalid_argument&) { + invalid_name_rejected = true; + } + assert(invalid_name_rejected); + bool conflict_rejected{}; + try { + static_cast(adminive::to_descriptor_json()); + } catch(const std::invalid_argument&) { + conflict_rejected = true; + } + assert(conflict_rejected); + return 0; +} diff --git a/backend/tests/test.cpp b/backend/tests/test.cpp index 8ee3bbe..c408f99 100644 --- a/backend/tests/test.cpp +++ b/backend/tests/test.cpp @@ -1,4 +1,4 @@ -#include "example.hpp" +#include "example_descriptors.hpp" #include #include #include @@ -14,8 +14,8 @@ int main() { assert(descriptor.at("name") == "radio_state"); assert(descriptor.at("fields").size() == 5); assert(descriptor.at("fields").at(0).at("name") == "mode"); - assert(descriptor.at("fields").at(0).at("label") == "Operating Mode"); - assert(descriptor.at("fields").at(0).at("list_label") == "Mode"); + assert(descriptor.at("fields").at(0).at("label") == "运行模式"); + assert(descriptor.at("fields").at(0).at("list_label") == "模式"); assert(descriptor.at("fields").at(0).at("value_type") == "enum"); assert(descriptor.at("fields").at(0).at("options").size() == 4); assert(descriptor.at("fields").at(0).at("options").at(0).at("value") == "receive"); @@ -23,7 +23,7 @@ int main() { assert(descriptor.at("fields").at(0).at("order") == 5); assert(descriptor.at("fields").at(0).at("sortable") == true); assert(descriptor.at("fields").at(1).at("name") == "port"); - assert(descriptor.at("fields").at(1).at("list_label") == "Listen Port"); + assert(descriptor.at("fields").at(1).at("list_label") == "监听端口"); assert(descriptor.at("fields").at(1).at("minimum") == 1); assert(descriptor.at("fields").at(1).at("maximum") == 65535); assert(descriptor.at("fields").at(2).at("name") == "buffer_count"); @@ -74,13 +74,13 @@ int main() { assert(crud.at("body").at("columns").size() == 7); assert(crud.at("body").at("columns").at(1).at("name") == "mode"); assert(crud.at("body").at("columns").at(1).at("type") == "mapping"); - assert(crud.at("body").at("columns").at(1).at("map").at("duplex") == "Duplex"); + assert(crud.at("body").at("columns").at(1).at("map").at("duplex") == "双工"); assert(crud.at("body").at("columns").at(2).at("name") == "buffer_count"); assert(crud.at("body").at("columns").at(3).at("name") == "port"); assert(!crud.at("body").contains("interval")); const auto& create_form = crud.at("body").at("headerToolbar").at(2).at("dialog").at("body"); assert(create_form.at("actions").at(1).at("type") == "submit"); - assert(create_form.at("actions").at(1).at("label") == "Create"); + assert(create_form.at("actions").at(1).at("label") == "新增"); const auto status_descriptor = adminive::to_status_descriptor_json(); assert(status_descriptor.at("protocol") == "adminive.status"); assert(status_descriptor.at("fields").size() == 3); @@ -99,7 +99,7 @@ int main() { assert(status_crud.at("body").at(0).at("type") == "service"); assert(status_crud.at("body").at(1).at("columns").size() == 8); const auto& status_column = status_crud.at("body").at(1).at("columns").at(6); - assert(status_column.at("label") == "Runtime Status"); + assert(status_column.at("label") == "实时状态"); assert(status_column.at("popOver").at("trigger") == "click"); assert(status_column.at("popOver").at("body").at("api").at("url") == "/admin/radio_states/${id}/status"); adminive::example::Http_Collection_Resource collection_resource("/admin/radio_states", std::vector{state}); @@ -117,7 +117,7 @@ int main() { assert(service_descriptor.at("fields").at(3).at("value_type") == "number"); assert(service_descriptor.at("fields").at(4).at("value_type") == "object"); assert(service_descriptor.at("fields").at(4).at("children").size() == 3); - assert(service_descriptor.at("fields").at(5).at("visible_on") == "${mode == 'duplex'}"); + assert(service_descriptor.at("fields").at(5).at("visible_on") == "${$self.mode == 'duplex'}"); assert(service_descriptor.at("fields").at(6).at("children").at(1).at("widget") == "input-date"); assert(service_descriptor.at("fields").at(6).at("children").at(2).at("widget") == "input-color"); const auto service_form = adminive::to_amis_form_schema(service_config, "/admin/config/radio/data", "Confirm Changes"); @@ -152,7 +152,7 @@ int main() { const auto resource_form = service_resource.amis_schema(); assert(resource_form.at("api").at("method") == "post"); assert(resource_form.at("api").at("url") == "/admin/config/radio/data"); - assert(resource_form.at("actions").at(1).at("label") == "Confirm Changes"); + assert(resource_form.at("actions").at(1).at("label") == "确认修改"); adminive::example::Server_Status server_status; server_status.server_time = "2026-08-06T00:00:00Z"; server_status.poll_sequence = 7; diff --git a/cmake/AdminiveConfig.cmake.in b/cmake/AdminiveConfig.cmake.in new file mode 100644 index 0000000..940d4e9 --- /dev/null +++ b/cmake/AdminiveConfig.cmake.in @@ -0,0 +1,20 @@ +@PACKAGE_INIT@ +include(CMakeFindDependencyMacro) +include("${CMAKE_CURRENT_LIST_DIR}/AdminiveTargets.cmake") +if(NOT TARGET Adminive::Adminive) + add_library(Adminive::Adminive INTERFACE IMPORTED) + set_property(TARGET Adminive::Adminive PROPERTY INTERFACE_LINK_LIBRARIES Adminive::Core) +endif() +foreach(component IN LISTS Adminive_FIND_COMPONENTS) + if(component STREQUAL "Drogon") + find_dependency(Drogon CONFIG) + if(TARGET drogon AND NOT TARGET Drogon::Drogon) + add_library(Drogon::Drogon ALIAS drogon) + endif() + include("${CMAKE_CURRENT_LIST_DIR}/AdminiveDrogonTargets.cmake") + set(Adminive_Drogon_FOUND TRUE) + elseif(NOT component STREQUAL "Core" AND NOT component STREQUAL "Http" AND NOT component STREQUAL "Httplib" AND NOT component STREQUAL "Default") + set(Adminive_${component}_FOUND FALSE) + endif() +endforeach() +check_required_components(Adminive)