From 114207f85d06652dbe0c57280b2bb314c9d046ec Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Thu, 6 Aug 2026 22:38:23 +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 --- ...ed_capabilities_patch_cn_20260806_2122.zip | Bin 35556 -> 0 bytes CMakeLists.txt | 37 ++--- README.md | 60 ++++++++- backend/include/adminive/adapter.hpp | 15 ++- backend/include/adminive/adapters/drogon.hpp | 38 ++++-- backend/include/adminive/adapters/httplib.hpp | 6 +- backend/include/adminive/amis.hpp | 73 +++++++++- backend/include/adminive/descriptor.hpp | 79 ++++++++++- backend/include/adminive/http.hpp | 15 ++- backend/include/adminive/json.hpp | 127 +++++++++++++++--- backend/src/device_tables.cpp | 4 +- backend/src/server_app.cpp | 4 +- backend/tests/advanced_adapter_test.cpp | 120 +++++++++++++---- backend/tests/core_adapter_test.cpp | 2 +- backend/tests/drogon_adapter_test.cpp | 25 +++- backend/tests/fake_drogon/drogon/drogon.h | 30 ++++- 16 files changed, 524 insertions(+), 111 deletions(-) delete mode 100644 Adminive_selected_capabilities_patch_cn_20260806_2122.zip diff --git a/Adminive_selected_capabilities_patch_cn_20260806_2122.zip b/Adminive_selected_capabilities_patch_cn_20260806_2122.zip deleted file mode 100644 index c2eeec5cdd4da77402df45226d7a5eb304270b7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35556 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 SWdo@b0K$8~zW0Y#5Dx$rRnDdW diff --git a/CMakeLists.txt b/CMakeLists.txt index 1665a70..b1c47d3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,26 +3,27 @@ project(Adminive VERSION 1.0.0 LANGUAGES CXX) include(CTest) add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/backend") set(adminive_package_includes - "/.gitignore" - "/backend/" - "/cmake/" - "/config/" - "/CMakeLists.txt" - "/frontend/" - "/README.md" - "/scripts/" + "/.gitignore" + "/backend/" + "/cmake/" + "/config/" + "/CMakeLists.txt" + "/frontend/" + "/README.md" + "/scripts/" ) set(adminive_package_excludes - "/.git/" - "/.idea/" - "/build/" - "/cmake-build-*/" - "/doc/" - "/node_modules/" - "/frontend/frontend.zip" - "/frontend_dist/" - "*.tsbuildinfo" - "*.zip" + "/.git/" + "/.idea/" + "/build/" + "/cmake-build-*/" + "/doc/" + "/node_modules/" + "/frontend/frontend.zip" + "/frontend/node_modules/" + "/frontend_dist/" + "*.tsbuildinfo" + "*.zip" ) include("${CMAKE_CURRENT_LIST_DIR}/cmake/PackageZip.cmake") 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/README.md b/README.md index 26ef1fb..9e20a65 100644 --- a/README.md +++ b/README.md @@ -102,18 +102,29 @@ struct adminive::Reflection_Adapter ### 不可复制运行时对象 -`Object_Adapter` 将不可复制、含原子字段或带运行时状态的对象映射到纯值配置模型。Adminive 只修改候选模型,校验和持久化成功后才调用 `commit()`: +`Object_Adapter` 将不可复制、含原子字段或带运行时状态的对象映射到纯值配置模型。读取只要求 `snapshot()`,编辑要求 `snapshot()` 和 `commit()`,创建能力才额外要求 `create()`: ```cpp template <> struct adminive::Object_Adapter { using model_type = Runtime_Config_Model; static model_type snapshot(const Runtime_Config& value); - static model_type create(); static void commit(Runtime_Config& target, model_type value); }; ``` +`Resource_Service` 只有一个事务入口。未传事务函数时调用 `Object_Adapter::commit()`;传入事务函数后只调用该函数,不会再重复提交: + +```cpp +adminive::Resource_Service resource( + runtime, + "/config", + [](Runtime_Config& target, Runtime_Config_Model candidate) { + persist(candidate); + target.apply(candidate); + }); +``` + ### 外部控件适配 复杂业务类型通过 `Control_Adapter` 明确决定表单控件和列表列。框架不会把 `vector` 或 `map` 隐式决定为表格、列表、分页或标签页;未配置控件的结构类型会在 Schema 生成时给出明确错误。 @@ -128,20 +139,59 @@ struct adminive::Control_Adapter { ### 多态对象 -`Polymorphic_Adapter` 定义判别字段、各派生类型描述器以及编码解码。默认 amis 表达使用一个类型选择器和按类型联动显示的子表单,外部仍可用 `Control_Adapter` 完全替换界面表达。 +`Polymorphic_Adapter` 返回带真实 C++ 类型的 variant 元组。Adminive 因此能继续调用每个派生类型字段的 `Control_Adapter`,不会退回无类型的 JSON 控件路径。 ```cpp template <> struct adminive::Polymorphic_Adapter { - static Json descriptor(); + static constexpr std::string_view discriminator() noexcept { + return "type"; + } + static constexpr std::string_view discriminator_label() noexcept { + return "设备类型"; + } + static auto variants() { + return std::tuple( + adminive::polymorphic_variant("serial", "串口设备"), + adminive::polymorphic_variant("network", "网络设备") + ); + } static Json encode(const Device& value); static void decode(Device& target, const Json& value); }; ``` +`decode_polymorphic_variant()` 只读取当前派生类型声明的字段,可清理切换类型后仍被前端提交的旧类型字段。 + ### 嵌套错误和 HTTP 反馈 -字段错误使用完整路径,例如 `map_resources.tile_sources[2].resource.maximum_level`。HTTP 422 响应同时返回 `errors` 和 `field_errors`,Drogon 与 cpp-httplib 适配层使用同一响应模型。 +字段错误使用完整路径,例如 `map_resources.tile_sources[2].resource.maximum_level`。字段赋值、对象校验和事务提交抛出的 `Field_Validation_Error` 都会转换为 HTTP 422,并同时返回 `errors` 和 `field_errors`。 + +### 描述器校验和敏感字段 + +描述器生成时会拒绝空字段名、重复字段名以及不存在或不可排序的默认排序字段。普通描述器默认不输出字段默认值;需要默认值时显式调用: + +```cpp +auto descriptor = adminive::to_descriptor_json_with_defaults(); +``` + +敏感字段使用: + +```cpp +ADMINIVE_FIELD(T, password).sensitive().editable(); +``` + +敏感字段不会进入前端数据,也不会进入带默认值的描述器。 + +### Drogon 生命周期和异步提交 + +`Drogon_Resource` 的路由回调捕获共享的 `Resource_Service`,绑定完成后销毁 binder 不会留下悬空回调。带文件写入、脚本或系统调用的提交可传入执行器: + +```cpp +resource.bind(drogon::app(), [](std::function task) { + worker_pool.submit(std::move(task)); +}); +``` ### 描述器驱动状态 diff --git a/backend/include/adminive/adapter.hpp b/backend/include/adminive/adapter.hpp index 145f8fa..0df307c 100644 --- a/backend/include/adminive/adapter.hpp +++ b/backend/include/adminive/adapter.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -168,11 +169,23 @@ template concept Control_Adapter_With_Column = requires(const Json& field) { { Control_Adapter, Json>::make_column(field) } -> std::same_as; }; +template +struct Polymorphic_Variant { + using type = T; + std::string value; + std::string label; +}; +template +auto polymorphic_variant(std::string value, std::string label) { + return Polymorphic_Variant{std::move(value), std::move(label)}; +} template struct Polymorphic_Adapter; template concept Polymorphic_Type = requires(const std::remove_cvref_t& value, std::remove_cvref_t& target, const Json& input) { - { Polymorphic_Adapter, Json>::descriptor() } -> std::same_as; + { 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); }; diff --git a/backend/include/adminive/adapters/drogon.hpp b/backend/include/adminive/adapters/drogon.hpp index db745be..b83ebe8 100644 --- a/backend/include/adminive/adapters/drogon.hpp +++ b/backend/include/adminive/adapters/drogon.hpp @@ -2,6 +2,7 @@ #include "adminive/http.hpp" #include #include +#include #include #include #include @@ -14,31 +15,40 @@ drogon::HttpResponsePtr make_drogon_response(Http_Response value) { response->setBody(dump_json(value.body, 2)); return response; } +using Drogon_Executor = std::function)>; template -requires Adapted_Object_Type +requires Writable_Adapted_Object class Drogon_Resource { public: using Service = Resource_Service; - using Commit_Function = typename Service::Commit_Function; - Drogon_Resource(T& value, std::string path, Commit_Function commit = {}, std::mutex* shared_mutex = nullptr) : service_(value, std::move(path), std::move(commit), shared_mutex) {} + 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)) {} Json amis_schema() const { - return service_.amis_schema(); + return service_->amis_schema(); } - void bind(drogon::HttpAppFramework& app) { - app.registerHandler(service_.path() + "/descriptor", [this](const drogon::HttpRequestPtr&, std::function&& callback) { - callback(make_drogon_response(service_.descriptor_response())); + void bind(drogon::HttpAppFramework& app, Drogon_Executor executor = {}) const { + const auto service = service_; + app.registerHandler(service->path() + "/descriptor", [service](const drogon::HttpRequestPtr&, std::function&& callback) { + callback(make_drogon_response(service->descriptor_response())); }, {drogon::Get}); - app.registerHandler(service_.path() + "/data", [this](const drogon::HttpRequestPtr&, std::function&& callback) { - callback(make_drogon_response(service_.data_response())); + app.registerHandler(service->path() + "/data", [service](const drogon::HttpRequestPtr&, std::function&& callback) { + callback(make_drogon_response(service->data_response())); }, {drogon::Get}); - app.registerHandler(service_.path() + "/amis", [this](const drogon::HttpRequestPtr&, std::function&& callback) { - callback(make_drogon_response(service_.amis_response())); + 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", [this](const drogon::HttpRequestPtr& request, std::function&& callback) { - callback(make_drogon_response(service_.update_response(request->getBody()))); + 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(); + } }, {drogon::Post}); } private: - Service service_; + std::shared_ptr service_; }; } diff --git a/backend/include/adminive/adapters/httplib.hpp b/backend/include/adminive/adapters/httplib.hpp index a87d218..6ca6e69 100644 --- a/backend/include/adminive/adapters/httplib.hpp +++ b/backend/include/adminive/adapters/httplib.hpp @@ -27,12 +27,12 @@ void write_http_error(httplib::Response& response, int status, const Update_Resu write_http_response(response, make_http_error(status, update)); } template -requires Adapted_Object_Type +requires Writable_Adapted_Object class Http_Resource { public: using Service = Resource_Service; - using Commit_Function = typename Service::Commit_Function; - Http_Resource(T& value, std::string path, Commit_Function commit = {}, std::mutex* shared_mutex = nullptr) : service_(value, std::move(path), std::move(commit), shared_mutex) {} + 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) {} Json amis_schema() const { return service_.amis_schema(); } diff --git a/backend/include/adminive/amis.hpp b/backend/include/adminive/amis.hpp index 74b4e28..93da30c 100644 --- a/backend/include/adminive/amis.hpp +++ b/backend/include/adminive/amis.hpp @@ -14,6 +14,23 @@ inline std::string make_field_name(std::string_view prefix, std::string_view nam } return std::string(prefix) + "." + std::string(name); } +inline std::string escape_amis_string_literal(std::string_view value) { + std::string result; + result.reserve(value.size()); + for(const char character : value) { + if(character == '\\' || character == '\'') { + result.push_back('\\'); + } + if(character == '\n') { + result += "\\n"; + } else if(character == '\r') { + result += "\\r"; + } else { + result.push_back(character); + } + } + return result; +} template Json make_amis_form_actions(std::string submit_label = "Apply") { Json reset = json_object(); @@ -32,6 +49,9 @@ template Json make_amis_form_body(const Json& descriptor, std::string_view permission, std::string_view prefix = {}); template Json make_typed_amis_form_body(const Json& descriptor, std::string_view permission, std::string_view prefix = {}); +template +requires Polymorphic_Type +Json make_typed_amis_polymorphic_control(const Json& field, std::string_view permission, std::string_view prefix); template Json make_amis_polymorphic_control(const Json& field, std::string_view permission, std::string_view prefix) { const std::string name = json_get(Json_Adapter::at(field, "name")); @@ -52,7 +72,7 @@ Json make_amis_polymorphic_control(const Json& field, std::string_view permissio const std::string value = json_get(Json_Adapter::at(variant, "value")); Json group = json_object(); json_set(group, "type", "container"); - json_set(group, "visibleOn", "${" + make_field_name(full_name, discriminator) + " == '" + value + "'}"); + json_set(group, "visibleOn", "${" + make_field_name(full_name, discriminator) + " == '" + escape_amis_string_literal(value) + "'}"); json_set(group, "body", make_amis_form_body(Json_Adapter::at(variant, "descriptor"), permission, full_name)); json_append(body, std::move(group)); } @@ -70,6 +90,47 @@ Json make_amis_polymorphic_control(const Json& field, std::string_view permissio } return result; } +template +requires Polymorphic_Type +Json make_typed_amis_polymorphic_control(const Json& field, std::string_view permission, std::string_view prefix) { + using Adapter = Polymorphic_Adapter, Json>; + const std::string name = json_get(Json_Adapter::at(field, "name")); + const std::string full_name = make_field_name(prefix, name); + const auto& metadata = Json_Adapter::at(field, "polymorphic"); + const std::string discriminator(Adapter::discriminator()); + Json selector = json_object(); + json_set(selector, "type", "select"); + json_set(selector, "name", make_field_name(full_name, discriminator)); + json_set(selector, "label", Json_Adapter::at(metadata, "discriminator_label")); + json_set(selector, "options", Json_Adapter::at(metadata, "options")); + json_set(selector, "required", true); + Json body = json_array(); + json_append(body, std::move(selector)); + std::apply([&](const auto&... variant) { + ([&] { + using Variant = typename std::remove_cvref_t::type; + const Json descriptor = to_descriptor_json(); + Json group = json_object(); + json_set(group, "type", "container"); + json_set(group, "visibleOn", "${" + make_field_name(full_name, discriminator) + " == '" + escape_amis_string_literal(variant.value) + "'}"); + json_set(group, "body", make_typed_amis_form_body>(descriptor, permission, full_name)); + json_append(body, std::move(group)); + }(), ...); + }, Adapter::variants()); + Json result = json_object(); + json_set(result, "type", "fieldset"); + json_set(result, "title", Json_Adapter::at(field, "label")); + json_set(result, "body", std::move(body)); + json_set(result, "collapsable", true); + json_set(result, "collapsed", false); + 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")); + } + return result; +} template Json make_default_amis_control(const Json& field, std::string_view permission, std::string_view prefix = {}) { const std::string name = json_get(Json_Adapter::at(field, "name")); @@ -164,7 +225,9 @@ Json make_typed_amis_control(const Json& field, std::string_view permission, std 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(Adapted_Object_Type) { + } 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); @@ -356,7 +419,7 @@ Json make_amis_object_status_column(const Json& descriptor, std::string status_a return result; } template -requires Adapted_Object_Type +requires Described_Type> || Snapshot_Adapted_Object Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::string submit_label = "Apply") { using Model = Object_Model_Type; const Json descriptor = to_descriptor_json(); @@ -376,7 +439,7 @@ Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::strin return result; } template -requires Adapted_Object_Type +requires Described_Type> Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = nullptr, std::uint64_t status_interval = 2000) { using Model = Object_Model_Type; const Json descriptor = to_descriptor_json(); @@ -498,7 +561,7 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n return result; } template -requires Adapted_Object_Type +requires Described_Type> Json to_amis_crud_status_schema(std::string base_api, std::string status_api, const Json& status_descriptor, std::uint64_t status_interval = 2000, const Json* object_status_descriptor = nullptr, std::uint64_t object_status_interval = 2000) { Json result = to_amis_crud_schema(std::move(base_api), object_status_descriptor, object_status_interval); Json crud = std::move(Json_Adapter::at(result, "body")); diff --git a/backend/include/adminive/descriptor.hpp b/backend/include/adminive/descriptor.hpp index fd08ea1..b1143a4 100644 --- a/backend/include/adminive/descriptor.hpp +++ b/backend/include/adminive/descriptor.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -94,6 +95,20 @@ public: result.readable_ = value; return result; } + Derived sensitive(bool value = true) const { + auto result = derived(); + result.sensitive_ = value; + if(value) { + result.readable_ = false; + result.include_default_ = false; + } + return result; + } + Derived include_default(bool value = true) const { + auto result = derived(); + result.include_default_ = value; + return result; + } Derived visible(bool value = true) const { auto result = derived(); result.visible_ = value; @@ -200,6 +215,12 @@ public: bool is_readable() const noexcept { return readable_; } + bool is_sensitive() const noexcept { + return sensitive_; + } + bool includes_default() const noexcept { + return include_default_; + } bool is_visible() const noexcept { return visible_; } @@ -230,6 +251,8 @@ private: bool editable_{}; bool creatable_{}; bool readable_{true}; + bool sensitive_{}; + bool include_default_{true}; bool visible_{true}; bool required_{}; bool list_visible_{true}; @@ -269,12 +292,20 @@ auto field(std::string name, std::string label) { template auto reflected_field() { static_assert(Index < static_cast(Reflection_Adapter::field_count)); - return Reflected_Field_Descriptor(std::string(Reflection_Adapter::template name())); + const std::string name(Reflection_Adapter::template name()); + if(name.empty()) { + throw std::invalid_argument("reflected field name must not be empty"); + } + return Reflected_Field_Descriptor(name); } template auto reflected_field(std::string label) { static_assert(Index < static_cast(Reflection_Adapter::field_count)); - return Reflected_Field_Descriptor(std::string(Reflection_Adapter::template name()), std::move(label)); + const std::string name(Reflection_Adapter::template name()); + if(name.empty()) { + throw std::invalid_argument("reflected field name must not be empty"); + } + return Reflected_Field_Descriptor(name, std::move(label)); } struct No_Object_Validator { template @@ -393,6 +424,46 @@ private: std::tuple fields_; Object_List_Options list_options_; }; +template +void validate_descriptor(const Descriptor& descriptor) { + if(descriptor.name().empty()) { + throw std::invalid_argument("descriptor name must not be empty"); + } + 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(!field_names.insert(field.name()).second) { + throw std::invalid_argument("duplicate descriptor field name: " + field.name()); + } + }(), ...); + }, descriptor.fields()); + const auto& options = descriptor.list_options(); + if(options.default_order_dir != "asc" && options.default_order_dir != "desc") { + throw std::invalid_argument("default sort direction must be 'asc' or 'desc'"); + } + if(options.default_order_by.empty()) { + return; + } + bool found{}; + bool sortable{}; + std::apply([&](const auto&... field) { + ([&] { + if(field.name() == options.default_order_by) { + found = true; + sortable = field.is_sortable(); + } + }(), ...); + }, descriptor.fields()); + if(!found) { + throw std::invalid_argument("default sort field does not exist: " + options.default_order_by); + } + if(!sortable) { + throw std::invalid_argument("default sort field is not sortable: " + options.default_order_by); + } +} template auto object(std::string name, Fields... fields) { static_assert((std::convertible_to && ...)); @@ -433,7 +504,9 @@ auto reflected_object(std::string name, std::string label) { } template auto describe() { - return Type_Descriptor>::get(); + auto descriptor = Type_Descriptor>::get(); + validate_descriptor(descriptor); + return descriptor; } } #define ADMINIVE_FIELD(Type, Member) ::adminive::field<&Type::Member>(#Member) diff --git a/backend/include/adminive/http.hpp b/backend/include/adminive/http.hpp index 10c8945..5e08cb1 100644 --- a/backend/include/adminive/http.hpp +++ b/backend/include/adminive/http.hpp @@ -40,13 +40,13 @@ Http_Response make_http_error(int status, std::string message) { return Http_Response{status, make_http_result(status, std::move(message), json_object())}; } template -requires Adapted_Object_Type +requires Writable_Adapted_Object class Resource_Service { public: using Object = std::remove_cvref_t; using Model = Object_Model_Type; - using Commit_Function = std::function; - Resource_Service(T& value, std::string path, Commit_Function commit = {}, std::mutex* shared_mutex = nullptr) : value_(value), path_(std::move(path)), commit_(std::move(commit)), shared_mutex_(shared_mutex) {} + 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) {} const std::string& path() const noexcept { return path_; } @@ -78,10 +78,11 @@ public: return make_http_error(422, result); } try { - if(commit_) { - commit_(candidate); + if(transaction_) { + transaction_(value_, std::move(candidate)); + } else { + Object_Adapter::commit(value_, std::move(candidate)); } - Object_Adapter::commit(value_, std::move(candidate)); } catch(const Json_Assignment_Error& error) { return make_http_error(422, error.result()); } catch(const Field_Validation_Error& error) { @@ -97,7 +98,7 @@ private: } T& value_; std::string path_; - Commit_Function commit_; + Transaction_Function transaction_; std::mutex* shared_mutex_{}; mutable std::mutex mutex_; }; diff --git a/backend/include/adminive/json.hpp b/backend/include/adminive/json.hpp index c4920cd..99e6606 100644 --- a/backend/include/adminive/json.hpp +++ b/backend/include/adminive/json.hpp @@ -3,6 +3,7 @@ #include "adminive/descriptor.hpp" #include #include +#include #include #include #include @@ -92,17 +93,26 @@ inline void merge_update_errors(Update_Result& target, const Update_Result& sour } } template -concept Adapted_Object_Type = Object_Adapter_With_Snapshot && Object_Adapter_With_Create && Object_Adapter_With_Commit && Described_Type>; +concept Snapshot_Adapted_Object = Object_Adapter_With_Snapshot && Described_Type>; +template +concept Creatable_Adapted_Object = Object_Adapter_With_Create && Described_Type>; +template +concept Writable_Adapted_Object = Snapshot_Adapted_Object && Object_Adapter_With_Commit; +template +concept Creatable_Writable_Object = Writable_Adapted_Object && Object_Adapter_With_Create; template -requires Described_Type> && Object_Adapter_With_Create +requires Described_Type> Json to_descriptor_json(); template +requires Creatable_Adapted_Object +Json to_descriptor_json_with_defaults(); +template requires Described_Type> || Object_Adapter_With_Snapshot Json to_json(const T& value); template requires Described_Type> || Object_Adapter_With_Snapshot Json to_frontend_json(const T& value); -template +template Update_Result assign_json(T& target, const Json& value); template void assign_json_value(T& target, const Json& value); @@ -123,7 +133,7 @@ Json encode_json_value(const T& value) { return json_scalar(std::string(name)); } return json_scalar(static_cast>(value)); - } else if constexpr(Described_Type || (Object_Adapter_With_Snapshot && Described_Type>)) { + } else if constexpr(Described_Type || Snapshot_Adapted_Object) { return to_json(value); } else if constexpr(String_Key_Map_Type) { Json result = json_object(); @@ -165,7 +175,7 @@ void assign_json_value(T& target, const Json& value) { throw std::invalid_argument("unknown enum value"); } target = *parsed; - } else if constexpr(Adapted_Object_Type) { + } else if constexpr(Writable_Adapted_Object) { const auto result = assign_json(target, value); if(!result.success) { throw Json_Assignment_Error(result); @@ -243,7 +253,7 @@ std::string value_type_name() { return "number"; } else if constexpr(String_Type) { return "string"; - } else if constexpr(Described_Type || (Object_Adapter_With_Snapshot && Described_Type>)) { + } else if constexpr(Described_Type || Snapshot_Adapted_Object) { return "object"; } else if constexpr(String_Key_Map_Type) { return "map"; @@ -254,6 +264,9 @@ std::string value_type_name() { } } template +requires Polymorphic_Type +Json polymorphic_descriptor_json(); +template void append_constraints(Json& result) { using Storage = std::remove_cvref_t; using Value = Adapted_Value_Type; @@ -273,7 +286,7 @@ void append_constraints(Json& result) { append_constraints(result); } if constexpr(Polymorphic_Type) { - json_set(result, "polymorphic", Polymorphic_Adapter::descriptor()); + json_set(result, "polymorphic", polymorphic_descriptor_json()); } if constexpr(Value_Adapter_With_Schema) { Value_Adapter::append_schema(result); @@ -321,9 +334,35 @@ Json make_polymorphic_descriptor(std::string discriminator, std::string discrimi json_set(result, "variants", std::move(variants)); return result; } +template +requires Polymorphic_Type +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"); + } + Json variants = json_array(); + std::set values; + std::apply([&](const auto&... variant) { + ([&] { + using Variant = typename std::remove_cvref_t::type; + static_assert(Described_Type>, "polymorphic variant requires a type descriptor"); + if(variant.value.empty()) { + throw std::invalid_argument("polymorphic variant value must not be empty"); + } + if(!values.insert(variant.value).second) { + throw std::invalid_argument("duplicate polymorphic variant value: " + variant.value); + } + json_append(variants, make_polymorphic_variant(variant.value, variant.label, to_descriptor_json())); + }(), ...); + }, Adapter::variants()); + return make_polymorphic_descriptor(discriminator, discriminator_label, std::move(variants)); +} template requires Described_Type -Json model_descriptor_json(const Model& defaults) { +Json model_descriptor_json(const Model* defaults = nullptr) { const auto descriptor = describe(); Json fields = json_array(); int field_index{}; @@ -340,6 +379,7 @@ Json model_descriptor_json(const Model& defaults) { json_set(field_json, "list_label", item.list_label()); json_set(field_json, "value_type", value_type_name()); json_set(field_json, "readable", item.is_readable()); + json_set(field_json, "sensitive", item.is_sensitive()); json_set(field_json, "editable", item.is_editable()); json_set(field_json, "creatable", item.is_creatable()); json_set(field_json, "visible", item.is_visible()); @@ -347,7 +387,9 @@ Json model_descriptor_json(const Model& defaults) { json_set(field_json, "list_visible", item.is_list_visible()); json_set(field_json, "order", order); json_set(field_json, "sortable", item.is_sortable()); - json_set(field_json, "default", encode_json_value(item.get(defaults))); + if(defaults && item.is_readable() && !item.is_sensitive() && item.includes_default()) { + json_set(field_json, "default", encode_json_value(item.get(*defaults))); + } if(!item.description().empty()) { json_set(field_json, "description", item.description()); } @@ -357,7 +399,7 @@ Json model_descriptor_json(const Model& defaults) { if(!item.visible_on().empty()) { json_set(field_json, "visible_on", item.visible_on()); } - if constexpr(Adapted_Object_Type) { + if constexpr(Described_Type || Snapshot_Adapted_Object) { json_set(field_json, "children", Json_Adapter::at(to_descriptor_json(), "fields")); } append_constraints(field_json); @@ -394,9 +436,21 @@ Json model_descriptor_json(const Model& defaults) { return result; } template -requires Described_Type> && Object_Adapter_With_Create +requires Described_Type> Json to_descriptor_json() { - return model_descriptor_json(Object_Adapter>::create()); + using Model = Object_Model_Type; + return model_descriptor_json(); +} +template +requires Creatable_Adapted_Object +Json to_descriptor_json_with_defaults() { + using Object = std::remove_cvref_t; + const auto defaults = Object_Adapter::create(); + return model_descriptor_json(&defaults); +} +template +Json to_descriptor_json(const Model& defaults) { + return model_descriptor_json(&defaults); } template Json model_to_json(const T& value, bool frontend) { @@ -404,7 +458,7 @@ Json model_to_json(const T& value, bool frontend) { Json result = json_object(); std::apply([&](const auto&... item) { ([&] { - if(!frontend || item.is_readable()) { + if(!frontend || (item.is_readable() && !item.is_sensitive())) { json_set(result, item.name(), encode_json_value(item.get(value))); } }(), ...); @@ -511,6 +565,28 @@ 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) { + 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>(); + std::apply([&](const auto&... field) { + ([&] { + if(Json_Adapter::contains(input, field.name())) { + json_set(filtered, field.name(), Json_Adapter::at(input, field.name())); + } + }(), ...); + }, descriptor.fields()); + Variant result{}; + auto update = assign_json(result, filtered); + if(!update.success) { + throw Json_Assignment_Error(std::move(update)); + } + return result; +} template Update_Result object_commit_error(const Field_Validation_Error& error) { Update_Result result; @@ -518,10 +594,19 @@ Update_Result object_commit_error(const Field_Validation_Error& error) { result.field_errors[error.path()] = error.what(); return result; } -template +template Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, bool complete) { using Object = std::remove_cvref_t; - auto candidate = mode == Write_Mode::create ? Object_Adapter::create() : Object_Adapter::snapshot(target); + auto candidate = [&] { + if(mode == Write_Mode::create) { + if constexpr(Object_Adapter_With_Create) { + return Object_Adapter::create(); + } else { + throw std::logic_error("object adapter does not support creation"); + } + } + return Object_Adapter::snapshot(target); + }(); auto result = apply_model_json(candidate, value, mode, complete); if(!result.success) { return result; @@ -538,27 +623,27 @@ Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, b } return result; } -template +template Update_Result assign_json(T& target, const Json& value) { return apply_object_json(target, value, Write_Mode::internal, true); } -template +template Update_Result apply_json_patch(T& target, const Json& patch) { return apply_object_json(target, patch, Write_Mode::internal, false); } -template +template Update_Result apply_frontend_create(T& target, const Json& value) { return apply_object_json(target, value, Write_Mode::create, true); } -template +template Update_Result apply_frontend_patch(T& target, const Json& patch) { return apply_object_json(target, patch, Write_Mode::update, false); } -template +template Update_Result apply_patch(T& target, const Json& patch) { return apply_frontend_patch(target, patch); } -template +template requires std::default_initializable T from_json(const Json& value) { T result{}; diff --git a/backend/src/device_tables.cpp b/backend/src/device_tables.cpp index 3fcc428..68f8e52 100644 --- a/backend/src/device_tables.cpp +++ b/backend/src/device_tables.cpp @@ -22,7 +22,7 @@ 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](const Serial_Table_Config& value) { +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()) {} Device_Table_Type Serial_Device_Table::type() const noexcept { @@ -46,7 +46,7 @@ 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](const Network_Table_Config& value) { +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()) {} Device_Table_Type Network_Device_Table::type() const noexcept { diff --git a/backend/src/server_app.cpp b/backend/src/server_app.cpp index 9ddf023..9df0a7e 100644 --- a/backend/src/server_app.cpp +++ b/backend/src/server_app.cpp @@ -12,9 +12,9 @@ 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](const Radio_Service_Config& value) { +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](const Alert_Config& 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_) { state_resource_.register_overview_status("/admin/status", 2000); diff --git a/backend/tests/advanced_adapter_test.cpp b/backend/tests/advanced_adapter_test.cpp index d847f98..b34e559 100644 --- a/backend/tests/advanced_adapter_test.cpp +++ b/backend/tests/advanced_adapter_test.cpp @@ -3,6 +3,7 @@ #include "adminive/http.hpp" #include #include +#include #include namespace advanced_test { using Json = nlohmann::json; @@ -27,9 +28,14 @@ public: void apply(const Runtime_Model& value) { count_ = value.nested.count; name_ = value.name; + ++apply_count_; + } + int apply_count() const noexcept { + return apply_count_; } private: int count_{1}; + int apply_count_{}; std::string name_{"default"}; }; struct Base_A { @@ -48,6 +54,7 @@ struct Control_Config { struct Serial_Device { std::string port{"COM1"}; int baud_rate{115200}; + Color_Code color; }; struct Network_Device { std::string host{"127.0.0.1"}; @@ -61,6 +68,17 @@ struct Overview_Status { adminive::Status_Value state{"running", "#16a34a"}; int workers{4}; }; +struct Secret_Config { + std::string secret{"token"}; + std::string visible{"public"}; +}; +struct Duplicate_Config { + int first{}; + int second{}; +}; +struct Invalid_Sort_Config { + int value{}; +}; } namespace adminive { template <> @@ -88,9 +106,6 @@ struct Object_Adapter { result.name = value.name(); return result; } - static model_type create() { - return {}; - } static void commit(advanced_test::Runtime_Config& target, model_type value) { target.apply(value); } @@ -157,7 +172,7 @@ 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()); + return object("serial", "Serial", ADMINIVE_FIELD(T, port).editable(), ADMINIVE_FIELD(T, baud_rate).editable(), ADMINIVE_FIELD(T, color).editable()); } }; template <> @@ -169,11 +184,17 @@ struct Type_Descriptor { }; template <> struct Polymorphic_Adapter { - static advanced_test::Json descriptor() { - advanced_test::Json variants = advanced_test::Json::array(); - variants.push_back(make_polymorphic_variant("serial", "串口设备", to_descriptor_json())); - variants.push_back(make_polymorphic_variant("network", "网络设备", to_descriptor_json())); - return make_polymorphic_descriptor("type", "设备类型", std::move(variants)); + static constexpr std::string_view discriminator() noexcept { + return "type"; + } + static constexpr std::string_view discriminator_label() noexcept { + return "设备类型"; + } + static auto variants() { + return std::tuple( + polymorphic_variant("serial", "串口设备"), + polymorphic_variant("network", "网络设备") + ); } static advanced_test::Json encode(const advanced_test::Device& value) { return std::visit([](const auto& device) { @@ -186,23 +207,11 @@ struct Polymorphic_Adapter { static void decode(advanced_test::Device& target, const advanced_test::Json& value) { const std::string type = value.at("type").get(); if(type == "serial") { - advanced_test::Serial_Device result; - auto update = assign_json(result, value); - update.field_errors.erase("type"); - if(!update.field_errors.empty()) { - throw Json_Assignment_Error(std::move(update)); - } - target = std::move(result); + target = decode_polymorphic_variant(value); return; } if(type == "network") { - advanced_test::Network_Device result; - auto update = assign_json(result, value); - update.field_errors.erase("type"); - if(!update.field_errors.empty()) { - throw Json_Assignment_Error(std::move(update)); - } - target = std::move(result); + target = decode_polymorphic_variant(value); return; } throw Field_Validation_Error("type", "unknown device type"); @@ -222,18 +231,52 @@ struct Type_Descriptor { return object("overview", "运行状态", ADMINIVE_FIELD(T, state).label("服务状态"), ADMINIVE_FIELD(T, workers).label("工作线程")); } }; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Secret_Config; + return object("secret", "Secret", ADMINIVE_FIELD(T, secret).sensitive().editable(), ADMINIVE_FIELD(T, visible).editable()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Duplicate_Config; + return object("duplicate", "Duplicate", field<&T::first>("same"), field<&T::second>("same")); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Invalid_Sort_Config; + return object("invalid_sort", "Invalid Sort", ADMINIVE_FIELD(T, value)).default_sort("value"); + } +}; } int main() { using namespace advanced_test; Runtime_Config runtime; - adminive::Resource_Service service(runtime, "/config"); + int transaction_count{}; + adminive::Resource_Service service(runtime, "/config", [&transaction_count](Runtime_Config& target, Runtime_Model candidate) { + if(candidate.name == "blocked") { + throw adminive::Field_Validation_Error("name", "name is blocked"); + } + target.apply(candidate); + ++transaction_count; + }); const auto invalid = service.update_response(R"({"nested":{"count":0}})"); assert(invalid.status == 422); assert(invalid.body.at("field_errors").contains("nested.count")); + const auto rejected_commit = service.update_response(R"({"name":"blocked"})"); + assert(rejected_commit.status == 422); + assert(rejected_commit.body.at("field_errors").at("name") == "name is blocked"); + assert(runtime.name() == "default"); const auto valid = service.update_response(R"({"nested":{"count":8},"name":"updated"})"); assert(valid.status == 200); assert(runtime.count() == 8); assert(runtime.name() == "updated"); + assert(runtime.apply_count() == 1); + assert(transaction_count == 1); const auto inherited = adminive::to_descriptor_json(); assert(inherited.at("fields").at(0).at("name") == "alpha"); assert(inherited.at("fields").at(1).at("name") == "beta"); @@ -244,9 +287,38 @@ int main() { const auto device_form = adminive::to_amis_form_schema(devices); assert(device_form.at("body").at(0).at("type") == "fieldset"); assert(device_form.at("body").at(0).at("body").at(0).at("type") == "select"); + assert(device_form.at("body").at(0).at("body").at(1).at("body").at(2).at("type") == "input-color"); + const auto polymorphic_patch = Json{{"device", Json{{"type", "network"}, {"host", "10.0.0.1"}, {"port", 7000}, {"baud_rate", 9600}}}}; + const auto polymorphic_update = adminive::apply_frontend_patch(devices, polymorphic_patch); + assert(polymorphic_update.success); + assert(std::holds_alternative(devices.device)); + assert(std::get(devices.device).host == "10.0.0.1"); const auto status_descriptor = adminive::to_status_descriptor_json(); const auto status_service = adminive::make_amis_status_service(status_descriptor, "/status", 1000); assert(status_service.at("body").at("title") == "运行状态"); assert(status_service.at("body").at("body").at("body").size() == 2); + const auto descriptor_without_defaults = adminive::to_descriptor_json(); + assert(!descriptor_without_defaults.at("fields").at(0).contains("default")); + const auto descriptor_with_defaults = adminive::to_descriptor_json_with_defaults(); + assert(!descriptor_with_defaults.at("fields").at(0).contains("default")); + assert(descriptor_with_defaults.at("fields").at(1).at("default") == "public"); + Secret_Config secret; + const auto frontend_secret = adminive::to_frontend_json(secret); + assert(!frontend_secret.contains("secret")); + assert(frontend_secret.at("visible") == "public"); + bool duplicate_rejected{}; + try { + static_cast(adminive::to_descriptor_json()); + } catch(const std::invalid_argument&) { + duplicate_rejected = true; + } + assert(duplicate_rejected); + bool invalid_sort_rejected{}; + try { + static_cast(adminive::to_descriptor_json()); + } catch(const std::invalid_argument&) { + invalid_sort_rejected = true; + } + assert(invalid_sort_rejected); return 0; } diff --git a/backend/tests/core_adapter_test.cpp b/backend/tests/core_adapter_test.cpp index ab4d283..32926a4 100644 --- a/backend/tests/core_adapter_test.cpp +++ b/backend/tests/core_adapter_test.cpp @@ -231,7 +231,7 @@ struct Type_Descriptor { int main() { using Json = adapter_test::Mini_Json; using Config = adapter_test::Config; - const Json descriptor = adminive::to_descriptor_json(); + const Json descriptor = adminive::to_descriptor_json_with_defaults(); const auto& fields = adminive::Json_Adapter::at(descriptor, "fields"); assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 0), "name")) == "worker_count")); assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 0), "default")) == 4)); diff --git a/backend/tests/drogon_adapter_test.cpp b/backend/tests/drogon_adapter_test.cpp index b9c24a2..34df165 100644 --- a/backend/tests/drogon_adapter_test.cpp +++ b/backend/tests/drogon_adapter_test.cpp @@ -1,6 +1,10 @@ #include "adminive/adapters/drogon.hpp" #include "adminive/adapters/nlohmann_json.hpp" +#include +#include #include +#include +#include namespace drogon_test { struct Config { std::string name{"default"}; @@ -17,8 +21,25 @@ struct Type_Descriptor { } int main() { drogon_test::Config config; - adminive::Drogon_Resource resource(config, "/config"); drogon::HttpAppFramework app; - resource.bind(app); + std::vector> tasks; + { + adminive::Drogon_Resource resource(config, "/config"); + resource.bind(app, [&tasks](std::function task) { + tasks.push_back(std::move(task)); + }); + } + auto request = std::make_shared(); + request->body = R"({"name":"updated"})"; + drogon::HttpResponsePtr response; + app.handle("/config/data", drogon::Post, request, [&response](const drogon::HttpResponsePtr& value) { + response = value; + }); + assert(!response); + assert(tasks.size() == 1); + tasks.front()(); + assert(response); + assert(response->status == drogon::k200OK); + assert(config.name == "updated"); return 0; } diff --git a/backend/tests/fake_drogon/drogon/drogon.h b/backend/tests/fake_drogon/drogon/drogon.h index 4380d37..c1d774a 100644 --- a/backend/tests/fake_drogon/drogon/drogon.h +++ b/backend/tests/fake_drogon/drogon/drogon.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include #include @@ -9,7 +10,8 @@ namespace drogon { enum HttpStatusCode { k200OK = 200, k400BadRequest = 400, - k422UnprocessableEntity = 422 + k422UnprocessableEntity = 422, + k500InternalServerError = 500 }; enum ContentType { CT_APPLICATION_JSON @@ -47,7 +49,29 @@ public: using HttpRequestPtr = std::shared_ptr; class HttpAppFramework { public: - template - void registerHandler(const std::string&, Handler&&, const std::vector&) {} + 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 handle(const std::string& path, HttpMethod method, const HttpRequestPtr& request, Callback callback) { + for(auto& route : routes_) { + if(route.path == path && route.method == method) { + route.handler(request, std::move(callback)); + return; + } + } + throw std::runtime_error("route is not registered"); + } +private: + struct Route { + std::string path; + HttpMethod method; + Handler handler; + }; + std::vector routes_; }; }