From 14b0b0e3bc17fa83a40472404f2ef7d487c2a303 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Thu, 6 Aug 2026 21:33:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E7=A6=BB=20http=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ed_capabilities_patch_cn_20260806_2122.zip | Bin 0 -> 35556 bytes README.md | 90 +++- backend/CMakeLists.txt | 34 +- backend/include/adminive/adapter.hpp | 49 ++ .../include/adminive/adapters/boost_pfr.hpp | 94 ++++ backend/include/adminive/adapters/drogon.hpp | 44 ++ backend/include/adminive/adapters/httplib.hpp | 380 ++++++++++++++++ backend/include/adminive/amis.hpp | 246 +++++++--- backend/include/adminive/http.hpp | 428 +++--------------- backend/include/adminive/json.hpp | 288 +++++++++--- backend/src/example.hpp | 2 +- backend/src/main.cpp | 5 +- backend/src/server_app.cpp | 5 +- backend/tests/advanced_adapter_test.cpp | 252 +++++++++++ backend/tests/drogon_adapter_test.cpp | 24 + backend/tests/fake_drogon/drogon/drogon.h | 53 +++ backend/tests/test.cpp | 6 +- 17 files changed, 1486 insertions(+), 514 deletions(-) create mode 100644 Adminive_selected_capabilities_patch_cn_20260806_2122.zip create mode 100644 backend/include/adminive/adapters/boost_pfr.hpp create mode 100644 backend/include/adminive/adapters/drogon.hpp create mode 100644 backend/include/adminive/adapters/httplib.hpp create mode 100644 backend/tests/advanced_adapter_test.cpp create mode 100644 backend/tests/drogon_adapter_test.cpp create mode 100644 backend/tests/fake_drogon/drogon/drogon.h diff --git a/Adminive_selected_capabilities_patch_cn_20260806_2122.zip b/Adminive_selected_capabilities_patch_cn_20260806_2122.zip new file mode 100644 index 0000000000000000000000000000000000000000..c2eeec5cdd4da77402df45226d7a5eb304270b7f GIT binary patch literal 35556 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 SWdo@b0K$8~zW0Y#5Dx$rRnDdW literal 0 HcmV?d00001 diff --git a/README.md b/README.md index c326d2e..26ef1fb 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,16 @@ Adminive 使用 C++20 模板、Concepts 和可替换适配器描述业务类型 target_link_libraries(your_target PRIVATE Adminive::Core) ``` -HTTP 资源层单独链接: +HTTP 资源服务与具体服务器分层: ```cmake target_link_libraries(your_target PRIVATE Adminive::Http) +target_link_libraries(httplib_target PRIVATE Adminive::Httplib) +target_link_libraries(drogon_target PRIVATE Adminive::Drogon) ``` +`Adminive::Http` 只提供 `Resource_Service`、HTTP 响应模型和错误结构,不包含服务器头文件。`adminive/adapters/httplib.hpp` 与 `adminive/adapters/drogon.hpp` 分别负责路由注册和响应写回。 + 项目自带的 `nlohmann::json` 和 `magic_enum` 桥接不会被核心头自动包含。需要默认实现时显式包含: ```cpp @@ -68,28 +72,80 @@ struct adminive::Enum_Adapter { ### PFR 或其他结构体反射适配 -核心通过 `Reflection_Adapter` 接收字段数量、字段名称和按索引访问。外部可以在实现层用 Boost.PFR 完成桥接,Adminive 本身不包含 Boost.PFR。描述器使用 `reflected_object()` 或 `reflected_object_with()` 生成字段。 +核心通过 `Reflection_Adapter` 接收字段数量、字段名称和按索引访问。可选桥接头为: + +```cpp +#include "adminive/adapters/boost_pfr.hpp" +``` + +普通聚合类型: ```cpp template <> -struct adminive::Reflection_Adapter { - static constexpr std::size_t field_count = boost::pfr::tuple_size_v; - template - static decltype(auto) get(Config& value) { - return boost::pfr::get(value); - } - template - static decltype(auto) get(const Config& value) { - return boost::pfr::get(value); - } - template - static constexpr std::string_view name() { - return boost::pfr::get_name(); - } +struct adminive::Reflection_Adapter + : adminive::Boost_Pfr_Reflection_Adapter {}; +``` + +由多个数据基类组成的配置类型可以展开所有基类字段: + +```cpp +template <> +struct adminive::Reflection_Adapter + : adminive::Boost_Pfr_Base_Reflection_Adapter< + Mode_ACS_Config, + Mode_ACS_Config_Base_Data, + Mode_ACS_Config_Data> {}; +``` + +较旧 Boost.PFR 不提供字段名时,外部为每个数据基类特化 `Boost_Pfr_Name_Adapter`;较新版本自动使用 `boost::pfr::get_name()`。 + + +### 不可复制运行时对象 + +`Object_Adapter` 将不可复制、含原子字段或带运行时状态的对象映射到纯值配置模型。Adminive 只修改候选模型,校验和持久化成功后才调用 `commit()`: + +```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); }; ``` -公开且无歧义的基类成员也可以直接注册到派生类型描述器,字段所有者不再要求与最终对象类型完全相同。 +### 外部控件适配 + +复杂业务类型通过 `Control_Adapter` 明确决定表单控件和列表列。框架不会把 `vector` 或 `map` 隐式决定为表格、列表、分页或标签页;未配置控件的结构类型会在 Schema 生成时给出明确错误。 + +```cpp +template <> +struct adminive::Control_Adapter { + static Json make_control(const Json& field, adminive::Control_Context context); + static Json make_column(const Json& field); +}; +``` + +### 多态对象 + +`Polymorphic_Adapter` 定义判别字段、各派生类型描述器以及编码解码。默认 amis 表达使用一个类型选择器和按类型联动显示的子表单,外部仍可用 `Control_Adapter` 完全替换界面表达。 + +```cpp +template <> +struct adminive::Polymorphic_Adapter { + static Json descriptor(); + static Json encode(const Device& value); + static void decode(Device& target, const Json& value); +}; +``` + +### 嵌套错误和 HTTP 反馈 + +字段错误使用完整路径,例如 `map_resources.tile_sources[2].resource.maximum_level`。HTTP 422 响应同时返回 `errors` 和 `field_errors`,Drogon 与 cpp-httplib 适配层使用同一响应模型。 + +### 描述器驱动状态 + +整体状态和列表项状态都使用 `adminive.status` 描述器生成,不再包含固定的服务名、字段名或模板。服务标题、字段标签、颜色和轮询周期由调用端传入的状态描述器决定。 ## 目录结构 diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index be12159..3140ed9 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -17,19 +17,30 @@ target_compile_features(Adminive INTERFACE cxx_std_20) add_library(Adminive_Http INTERFACE) add_library(Adminive::Http ALIAS Adminive_Http) target_link_libraries(Adminive_Http INTERFACE Adminive::Core) -target_include_directories(Adminive_Http INTERFACE "${adminive_third_party_include_dir}") -if (WIN32) - target_compile_definitions(Adminive_Http INTERFACE WINVER=0x0A00 _WIN32_WINNT=0x0A00) - target_link_libraries(Adminive_Http INTERFACE ws2_32) -endif () +add_library(Adminive_Httplib INTERFACE) +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}") +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) +add_library(Adminive::Drogon ALIAS Adminive_Drogon) +target_link_libraries(Adminive_Drogon INTERFACE Adminive::Http) +if(TARGET Drogon::Drogon) + target_link_libraries(Adminive_Drogon INTERFACE Drogon::Drogon) +elseif(TARGET drogon) + target_link_libraries(Adminive_Drogon INTERFACE drogon) +endif() add_library(Adminive_Default INTERFACE) add_library(Adminive::Default ALIAS Adminive_Default) -target_link_libraries(Adminive_Default INTERFACE Adminive::Http) +target_link_libraries(Adminive_Default INTERFACE Adminive::Httplib) target_include_directories(Adminive_Default INTERFACE "${adminive_third_party_include_dir}") 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) -if (BUILD_TESTING) +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) @@ -37,4 +48,11 @@ if (BUILD_TESTING) add_executable(Adminive_Core_Adapter_Test "${adminive_backend_root}/tests/core_adapter_test.cpp") target_link_libraries(Adminive_Core_Adapter_Test PRIVATE Adminive::Core) add_test(NAME Adminive_Core_Adapter_Test COMMAND Adminive_Core_Adapter_Test) -endif () + 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_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) +endif() diff --git a/backend/include/adminive/adapter.hpp b/backend/include/adminive/adapter.hpp index 89a0fe1..145f8fa 100644 --- a/backend/include/adminive/adapter.hpp +++ b/backend/include/adminive/adapter.hpp @@ -10,6 +10,33 @@ namespace adminive { template struct Reflection_Adapter; +template +struct Object_Adapter { + using model_type = std::remove_cvref_t; + static model_type snapshot(const model_type& value) requires std::copy_constructible { + return value; + } + static model_type create() requires std::default_initializable { + return {}; + } + static void commit(model_type& target, model_type value) requires std::assignable_from { + target = std::move(value); + } +}; +template +using Object_Model_Type = typename Object_Adapter>::model_type; +template +concept Object_Adapter_With_Snapshot = requires(const std::remove_cvref_t& value) { + { Object_Adapter>::snapshot(value) } -> std::same_as>; +}; +template +concept Object_Adapter_With_Create = requires { + { Object_Adapter>::create() } -> std::same_as>; +}; +template +concept Object_Adapter_With_Commit = requires(std::remove_cvref_t& target, Object_Model_Type value) { + Object_Adapter>::commit(target, std::move(value)); +}; template struct Json_Adapter; template @@ -127,6 +154,28 @@ template concept Value_Adapter_With_Validation_Message = requires { { Value_Adapter, Json>::validation_message } -> std::convertible_to; }; +template +struct Control_Adapter {}; +struct Control_Context { + std::string_view permission; + std::string_view prefix; +}; +template +concept Control_Adapter_With_Control = requires(const Json& field, Control_Context context) { + { Control_Adapter, Json>::make_control(field, context) } -> std::same_as; +}; +template +concept Control_Adapter_With_Column = requires(const Json& field) { + { Control_Adapter, Json>::make_column(field) } -> std::same_as; +}; +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>::encode(value) } -> std::same_as; + Polymorphic_Adapter, Json>::decode(target, input); +}; template struct Enum_Adapter; template diff --git a/backend/include/adminive/adapters/boost_pfr.hpp b/backend/include/adminive/adapters/boost_pfr.hpp new file mode 100644 index 0000000..b762f63 --- /dev/null +++ b/backend/include/adminive/adapters/boost_pfr.hpp @@ -0,0 +1,94 @@ +#pragma once +#include "adminive/adapter.hpp" +#include +#if __has_include() +#include +#define ADMINIVE_BOOST_PFR_HAS_NAMES 1 +#else +#define ADMINIVE_BOOST_PFR_HAS_NAMES 0 +#endif +#include +#include +#include +namespace adminive { +template +struct Boost_Pfr_Name_Adapter; +namespace detail { +template +constexpr std::string_view pfr_name() noexcept { +#if ADMINIVE_BOOST_PFR_HAS_NAMES + return boost::pfr::get_name(); +#else + return Boost_Pfr_Name_Adapter::template name(); +#endif +} +template +decltype(auto) pfr_base_get(Derived& value) noexcept { + constexpr std::size_t count = boost::pfr::tuple_size_v; + if constexpr(Index < count) { + return boost::pfr::get(static_cast(value)); + } else { + static_assert(sizeof...(Bases) != 0); + return pfr_base_get(value); + } +} +template +decltype(auto) pfr_base_get(const Derived& value) noexcept { + constexpr std::size_t count = boost::pfr::tuple_size_v; + if constexpr(Index < count) { + return boost::pfr::get(static_cast(value)); + } else { + static_assert(sizeof...(Bases) != 0); + return pfr_base_get(value); + } +} +template +constexpr std::string_view pfr_base_name() noexcept { + constexpr std::size_t count = boost::pfr::tuple_size_v; + if constexpr(Index < count) { + return pfr_name(); + } else { + static_assert(sizeof...(Bases) != 0); + return pfr_base_name(); + } +} +} +template +struct Boost_Pfr_Reflection_Adapter { + static constexpr std::size_t field_count = boost::pfr::tuple_size_v; + template + static decltype(auto) get(T& value) noexcept { + return boost::pfr::get(value); + } + template + static decltype(auto) get(const T& value) noexcept { + return boost::pfr::get(value); + } + template + static constexpr std::string_view name() noexcept { + return detail::pfr_name(); + } +}; +template +struct Boost_Pfr_Base_Reflection_Adapter { + static_assert(sizeof...(Bases) != 0); + static_assert((std::is_base_of_v && ...)); + static constexpr std::size_t field_count = (boost::pfr::tuple_size_v + ... + 0); + template + static decltype(auto) get(Derived& value) noexcept { + static_assert(Index < field_count); + return detail::pfr_base_get(value); + } + template + static decltype(auto) get(const Derived& value) noexcept { + static_assert(Index < field_count); + return detail::pfr_base_get(value); + } + template + static constexpr std::string_view name() noexcept { + static_assert(Index < field_count); + return detail::pfr_base_name(); + } +}; +} +#undef ADMINIVE_BOOST_PFR_HAS_NAMES diff --git a/backend/include/adminive/adapters/drogon.hpp b/backend/include/adminive/adapters/drogon.hpp new file mode 100644 index 0000000..db745be --- /dev/null +++ b/backend/include/adminive/adapters/drogon.hpp @@ -0,0 +1,44 @@ +#pragma once +#include "adminive/http.hpp" +#include +#include +#include +#include +#include +namespace adminive { +template +drogon::HttpResponsePtr make_drogon_response(Http_Response value) { + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(static_cast(value.status)); + response->setContentTypeCode(drogon::CT_APPLICATION_JSON); + response->setBody(dump_json(value.body, 2)); + return response; +} +template +requires Adapted_Object_Type +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) {} + Json amis_schema() const { + 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())); + }, {drogon::Get}); + app.registerHandler(service_.path() + "/data", [this](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())); + }, {drogon::Get}); + app.registerHandler(service_.path() + "/data", [this](const drogon::HttpRequestPtr& request, std::function&& callback) { + callback(make_drogon_response(service_.update_response(request->getBody()))); + }, {drogon::Post}); + } +private: + Service service_; +}; +} diff --git a/backend/include/adminive/adapters/httplib.hpp b/backend/include/adminive/adapters/httplib.hpp new file mode 100644 index 0000000..a87d218 --- /dev/null +++ b/backend/include/adminive/adapters/httplib.hpp @@ -0,0 +1,380 @@ +#pragma once +#include "adminive/http.hpp" +#include "httplib.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace adminive { +template +void write_http_json(httplib::Response& response, const Json& value) { + response.set_content(dump_json(value, 2), "application/json; charset=utf-8"); +} +template +void write_http_response(httplib::Response& response, Http_Response value) { + response.status = value.status; + write_http_json(response, value.body); +} +template +void write_http_error(httplib::Response& response, int status, const Update_Result& update) { + write_http_response(response, make_http_error(status, update)); +} +template +requires Adapted_Object_Type +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) {} + Json amis_schema() const { + 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()); + }); + server.Get(service_.path() + "/data", [this](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.Post(service_.path() + "/data", [this](const httplib::Request& request, httplib::Response& response) { + write_http_response(response, service_.update_response(request.body)); + }); + } +private: + Service service_; +}; +template +requires std::default_initializable && std::copy_constructible && std::assignable_from +class Http_Collection_Resource { +public: + explicit Http_Collection_Resource(std::string path, std::vector initial = {}) : path_(std::move(path)) { + for(auto& value : initial) { + entries_.push_back(Entry{next_id_++, std::move(value)}); + } + } + template + void register_overview_status(std::string api, std::uint64_t interval = 2000) { + overview_status_api_ = std::move(api); + overview_status_descriptor_ = to_status_descriptor_json(); + overview_status_interval_ = interval; + } + template + void register_status(std::uint64_t interval = 2000) { + using Function_Type = decltype(Function); + static_assert(std::is_member_function_pointer_v); + static_assert(std::is_invocable_v); + using Status = std::remove_cvref_t>; + static_assert(Described_Type); + status_descriptor_ = to_status_descriptor_json(); + status_interval_ = interval; + status_reader_ = [](T& value) { + return to_status_json(std::invoke(Function, value)); + }; + } + std::size_t size() const { + std::scoped_lock lock(mutex_); + return entries_.size(); + } + Json items_json() const { + std::scoped_lock lock(mutex_); + Json items = json_array(); + for(const auto& entry : entries_) { + json_append(items, encode_entry(entry)); + } + return items; + } + Json amis_schema() const { + const Json* descriptor = status_reader_ ? &status_descriptor_ : nullptr; + if(overview_status_api_.empty()) { + return to_amis_crud_schema(path_, descriptor, status_interval_); + } + 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) { + write_http_json(response, make_http_result(0, "", to_descriptor_json())); + }); + server.Get(path_ + "/amis", [this](const httplib::Request&, httplib::Response& response) { + write_http_json(response, make_http_result(0, "", amis_schema())); + }); + if(status_reader_) { + server.Get(path_ + "/status/descriptor", [this](const httplib::Request&, httplib::Response& response) { + write_http_json(response, make_http_result(0, "", status_descriptor_)); + }); + server.Get(status_pattern(), [this](const httplib::Request& request, httplib::Response& response) { + const auto id = read_route_id(request); + std::scoped_lock lock(mutex_); + const auto iterator = find_entry(id); + if(iterator == entries_.end()) { + write_not_found(response); + return; + } + write_http_json(response, make_http_result(0, "", status_reader_(iterator->value))); + }); + } + server.Get(path_, [this](const httplib::Request& request, httplib::Response& response) { + const std::size_t page = read_size_parameter(request, "page", 1); + const std::size_t per_page = read_size_parameter(request, "perPage", 20); + std::scoped_lock lock(mutex_); + std::vector ordered_entries; + ordered_entries.reserve(entries_.size()); + for(const auto& entry : entries_) { + ordered_entries.push_back(&entry); + } + apply_request_sort(request, ordered_entries); + const std::size_t offset = std::min((page - 1) * per_page, ordered_entries.size()); + const std::size_t end = std::min(offset + per_page, ordered_entries.size()); + Json items = json_array(); + for(std::size_t index = offset; index < end; ++index) { + json_append(items, encode_entry(*ordered_entries[index])); + } + Json data = json_object(); + json_set(data, "items", std::move(items)); + json_set(data, "total", ordered_entries.size()); + write_http_json(response, make_http_result(0, "", std::move(data))); + }); + server.Get(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { + const auto id = read_route_id(request); + std::scoped_lock lock(mutex_); + const auto iterator = find_entry(id); + if(iterator == entries_.end()) { + write_not_found(response); + return; + } + write_http_json(response, make_http_result(0, "", encode_entry(*iterator))); + }); + server.Post(path_, [this](const httplib::Request& request, httplib::Response& response) { + try { + Json input = parse_json(request.body); + Json_Adapter::erase(input, "id"); + T value{}; + const auto result = apply_frontend_create(value, input); + if(!result.success) { + write_http_error(response, 422, result); + return; + } + std::scoped_lock lock(mutex_); + entries_.push_back(Entry{next_id_++, std::move(value)}); + write_http_json(response, make_http_result(0, "created", encode_entry(entries_.back()))); + } catch(const std::exception& error) { + response.status = 400; + write_http_json(response, make_http_result(400, error.what(), json_object())); + } + }); + if(describe().list_options().user_reorderable_) { + server.Post(path_ + "/order", [this](const httplib::Request& request, httplib::Response& response) { + try { + const Json input = parse_json(request.body); + const auto ids = read_order_ids(Json_Adapter::at(input, "ids")); + std::scoped_lock lock(mutex_); + reorder(ids); + write_http_json(response, make_http_result(0, "reordered", json_object())); + } catch(const std::exception& error) { + response.status = 400; + write_http_json(response, make_http_result(400, error.what(), json_object())); + } + }); + } + server.Put(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { + update(request, response); + }); + server.Patch(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { + update(request, response); + }); + server.Delete(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { + const auto id = read_route_id(request); + std::scoped_lock lock(mutex_); + const auto iterator = find_entry(id); + if(iterator == entries_.end()) { + write_not_found(response); + return; + } + entries_.erase(iterator); + write_http_json(response, make_http_result(0, "deleted", json_object())); + }); + } +private: + struct Entry { + std::uint64_t id{}; + T value; + }; + using Iterator = typename std::vector::iterator; + static std::size_t read_size_parameter(const httplib::Request& request, const char* name, std::size_t fallback) { + if(!request.has_param(name)) { + return fallback; + } + const auto text = request.get_param_value(name); + std::size_t value{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if(error != std::errc{} || end != text.data() + text.size() || value == 0) { + return fallback; + } + return value; + } + static int compare_json_values(const Json& left, const Json& right) { + if(Json_Adapter::is_number(left) && Json_Adapter::is_number(right)) { + const long double left_value = Json_Adapter::number(left); + const long double right_value = Json_Adapter::number(right); + return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; + } + if(Json_Adapter::is_string(left) && Json_Adapter::is_string(right)) { + const auto left_value = json_get(left); + const auto right_value = json_get(right); + return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; + } + if(Json_Adapter::is_boolean(left) && Json_Adapter::is_boolean(right)) { + const bool left_value = json_get(left); + const bool right_value = json_get(right); + return left_value == right_value ? 0 : left_value ? 1 : -1; + } + const std::string left_value = dump_json(left); + const std::string right_value = dump_json(right); + return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; + } + static bool is_sortable_field(const std::string& name) { + if(name == "id") { + return true; + } + bool sortable{}; + std::apply([&](const auto&... field) { + ((field.name() == name ? sortable = field.is_sortable() : false), ...); + }, describe().fields()); + return sortable; + } + static std::uint64_t read_route_id(const httplib::Request& request) { + const std::string text = request.matches[1].str(); + std::uint64_t id{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), id); + if(error != std::errc{} || end != text.data() + text.size()) { + return 0; + } + return id; + } + static std::vector read_order_ids(const Json& value) { + std::vector result; + if(Json_Adapter::is_array(value)) { + for(std::size_t index = 0; index < Json_Adapter::size(value); ++index) { + const auto& item = Json_Adapter::at(value, index); + result.push_back(Json_Adapter::is_string(item) ? std::stoull(json_get(item)) : json_get(item)); + } + return result; + } + const std::string text = json_get(value); + std::size_t begin{}; + while(begin < text.size()) { + const auto end = text.find(',', begin); + result.push_back(std::stoull(text.substr(begin, end == std::string::npos ? text.size() - begin : end - begin))); + if(end == std::string::npos) { + break; + } + begin = end + 1; + } + return result; + } + void apply_request_sort(const httplib::Request& request, std::vector& entries) const { + const auto descriptor = describe(); + const auto& options = descriptor.list_options(); + std::string order_by = options.default_order_by; + std::string order_dir = options.default_order_dir; + if(request.has_param("orderBy")) { + order_by = request.get_param_value("orderBy"); + order_dir = request.has_param("orderDir") ? request.get_param_value("orderDir") : "asc"; + } + if(order_by.empty() || !is_sortable_field(order_by)) { + return; + } + const bool descending = order_dir == "desc"; + std::stable_sort(entries.begin(), entries.end(), [&](const Entry* left, const Entry* right) { + int comparison{}; + if(order_by == "id") { + comparison = left->id < right->id ? -1 : left->id > right->id ? 1 : 0; + } else { + const auto left_json = to_frontend_json(left->value); + const auto right_json = to_frontend_json(right->value); + comparison = compare_json_values(Json_Adapter::at(left_json, order_by), Json_Adapter::at(right_json, order_by)); + } + return descending ? comparison > 0 : comparison < 0; + }); + } + std::string item_pattern() const { + return path_ + R"(/(\d+))"; + } + std::string status_pattern() const { + return path_ + R"(/(\d+)/status)"; + } + Iterator find_entry(std::uint64_t id) { + return std::find_if(entries_.begin(), entries_.end(), [id](const Entry& entry) { + return entry.id == id; + }); + } + static Json encode_entry(const Entry& entry) { + Json result = to_frontend_json(entry.value); + json_set(result, "id", entry.id); + return result; + } + static void write_not_found(httplib::Response& response) { + response.status = 404; + write_http_json(response, make_http_result(404, "record not found", json_object())); + } + void reorder(const std::vector& ids) { + std::vector remaining = std::move(entries_); + std::vector reordered; + reordered.reserve(remaining.size()); + for(const auto id : ids) { + const auto iterator = std::find_if(remaining.begin(), remaining.end(), [id](const Entry& entry) { + return entry.id == id; + }); + if(iterator != remaining.end()) { + reordered.push_back(std::move(*iterator)); + remaining.erase(iterator); + } + } + for(auto& entry : remaining) { + reordered.push_back(std::move(entry)); + } + entries_ = std::move(reordered); + } + void update(const httplib::Request& request, httplib::Response& response) { + try { + Json patch = parse_json(request.body); + Json_Adapter::erase(patch, "id"); + const auto id = read_route_id(request); + std::scoped_lock lock(mutex_); + const auto iterator = find_entry(id); + if(iterator == entries_.end()) { + write_not_found(response); + return; + } + const auto result = apply_frontend_patch(iterator->value, patch); + if(!result.success) { + write_http_error(response, 422, result); + return; + } + write_http_json(response, make_http_result(0, result.message, encode_entry(*iterator))); + } catch(const std::exception& error) { + response.status = 400; + write_http_json(response, make_http_result(400, error.what(), json_object())); + } + } + std::string path_; + std::string overview_status_api_; + Json overview_status_descriptor_{}; + std::uint64_t overview_status_interval_{2000}; + Json status_descriptor_{}; + std::function status_reader_; + std::uint64_t status_interval_{2000}; + std::vector entries_; + std::uint64_t next_id_{1}; + mutable std::mutex mutex_; +}; +} diff --git a/backend/include/adminive/amis.hpp b/backend/include/adminive/amis.hpp index 364e19c..74b4e28 100644 --- a/backend/include/adminive/amis.hpp +++ b/backend/include/adminive/amis.hpp @@ -2,6 +2,7 @@ #include "adminive/status.hpp" #include #include +#include #include #include #include @@ -28,23 +29,59 @@ Json make_amis_form_actions(std::string submit_label = "Apply") { return result; } template -Json make_amis_control(const Json& field, std::string_view permission, std::string_view prefix = {}) { +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 +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")); + const std::string full_name = make_field_name(prefix, name); + const auto& metadata = Json_Adapter::at(field, "polymorphic"); + const std::string discriminator = json_get(Json_Adapter::at(metadata, "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)); + const auto& variants = Json_Adapter::at(metadata, "variants"); + for(std::size_t index = 0; index < Json_Adapter::size(variants); ++index) { + const auto& variant = Json_Adapter::at(variants, index); + 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, "body", make_amis_form_body(Json_Adapter::at(variant, "descriptor"), permission, full_name)); + json_append(body, std::move(group)); + } + 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")); const std::string full_name = make_field_name(prefix, name); const std::string value_type = json_get(Json_Adapter::at(field, "value_type")); if(value_type == "object" && Json_Adapter::contains(field, "children")) { - Json body = json_array(); - const auto& children = Json_Adapter::at(field, "children"); - for(std::size_t index = 0; index < Json_Adapter::size(children); ++index) { - const auto& child = Json_Adapter::at(children, index); - if(json_get(Json_Adapter::at(child, "visible"))) { - json_append(body, make_amis_control(child, permission, full_name)); - } - } + Json descriptor = json_object(); + json_set(descriptor, "fields", Json_Adapter::at(field, "children")); Json control = json_object(); json_set(control, "type", "fieldset"); json_set(control, "title", Json_Adapter::at(field, "label")); - json_set(control, "body", std::move(body)); + json_set(control, "body", make_amis_form_body(descriptor, permission, full_name)); json_set(control, "collapsable", true); json_set(control, "collapsed", false); if(Json_Adapter::contains(field, "description")) { @@ -55,6 +92,9 @@ Json make_amis_control(const Json& field, std::string_view permission, std::stri } return control; } + if(value_type == "polymorphic" && Json_Adapter::contains(field, "polymorphic")) { + return make_amis_polymorphic_control(field, permission, prefix); + } Json control = json_object(); json_set(control, "name", full_name); json_set(control, "label", Json_Adapter::at(field, "label")); @@ -76,8 +116,10 @@ Json make_amis_control(const Json& field, std::string_view permission, std::stri json_set(control, "type", "input-number"); } else if(value_type == "enum") { json_set(control, "type", "select"); - } else { + } else if(value_type == "string") { json_set(control, "type", "input-text"); + } else { + 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(control_type == "input-date") { @@ -110,19 +152,75 @@ Json make_amis_control(const Json& field, std::string_view permission, std::stri return control; } template -Json make_amis_form_body(const Json& descriptor, std::string_view permission) { +Json make_amis_control(const Json& field, std::string_view permission, std::string_view prefix = {}) { + return make_default_amis_control(field, permission, prefix); +} +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; + 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(Adapted_Object_Type) { + 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(); + Json control = json_object(); + json_set(control, "type", "fieldset"); + json_set(control, "title", Json_Adapter::at(field, "label")); + json_set(control, "body", make_typed_amis_form_body(descriptor, permission, full_name)); + json_set(control, "collapsable", true); + json_set(control, "collapsed", false); + 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")); + } + return control; + } else { + return make_default_amis_control(field, permission, prefix); + } +} +template +Json make_amis_form_body(const Json& descriptor, std::string_view permission, std::string_view prefix) { Json body = json_array(); const auto& fields = Json_Adapter::at(descriptor, "fields"); for(std::size_t index = 0; index < Json_Adapter::size(fields); ++index) { const auto& field = Json_Adapter::at(fields, index); if(json_get(Json_Adapter::at(field, "visible"))) { - json_append(body, make_amis_control(field, permission)); + json_append(body, make_default_amis_control(field, permission, prefix)); } } return body; } +template +void append_typed_amis_form_fields(Json& body, const Json& fields, const Descriptor& descriptor, std::string_view permission, std::string_view prefix) { + using Fields = std::remove_cvref_t; + if constexpr(Index < std::tuple_size_v) { + const auto& item = std::get(descriptor.fields()); + const auto& field = Json_Adapter::at(fields, Index); + if(json_get(Json_Adapter::at(field, "visible"))) { + using Field = std::remove_cvref_t; + json_append(body, make_typed_amis_control(field, permission, prefix)); + } + append_typed_amis_form_fields(body, fields, descriptor, permission, prefix); + } +} +template +Json make_typed_amis_form_body(const Json& descriptor, std::string_view permission, std::string_view prefix) { + Json body = json_array(); + const auto& fields = Json_Adapter::at(descriptor, "fields"); + const auto object_descriptor = describe(); + append_typed_amis_form_fields<0>(body, fields, object_descriptor, permission, prefix); + return body; +} template -Json make_amis_column(const Json& field) { +Json make_default_amis_column(const Json& field) { Json column = json_object(); json_set(column, "name", Json_Adapter::at(field, "name")); json_set(column, "label", Json_Adapter::at(field, "list_label")); @@ -147,31 +245,44 @@ Json make_amis_column(const Json& field) { } return column; } -template -Json make_amis_status_service(std::string status_api) { - Json view = json_object(); - json_set(view, "type", "tpl"); - json_set(view, "tpl", R"(
Service${service_state}
Server time${server_time}
Uptime${uptime_seconds} s
Polling sequence${poll_sequence}
Radio states${radio_state_count}
Listen port${listen_port}
)"); - Json panel = json_object(); - json_set(panel, "type", "panel"); - json_set(panel, "title", "Backend Status"); - json_set(panel, "body", std::move(view)); - Json api = json_object(); - json_set(api, "method", "get"); - json_set(api, "url", std::move(status_api)); - Json result = json_object(); - json_set(result, "type", "service"); - json_set(result, "name", "backend_status"); - json_set(result, "api", std::move(api)); - json_set(result, "initFetch", true); - json_set(result, "interval", 2000); - json_set(result, "silentPolling", true); - json_set(result, "showErrorMsg", true); - json_set(result, "body", std::move(panel)); - return result; +template +Json make_typed_amis_column(const Json& field) { + using Storage = std::remove_cvref_t; + using Value = Adapted_Value_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 { + return make_default_amis_column(field); + } } template -Json make_amis_object_status_body(const Json& descriptor) { +struct Amis_Ordered_Column { + int order{}; + Json column; +}; +template +void append_typed_amis_columns(std::vector>& columns, const Json& fields, const Descriptor& descriptor) { + using Fields = std::remove_cvref_t; + if constexpr(Index < std::tuple_size_v) { + const auto& item = std::get(descriptor.fields()); + const auto& field = Json_Adapter::at(fields, Index); + using Field = std::remove_cvref_t; + 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); + 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)) { + columns.push_back(Amis_Ordered_Column{json_get(Json_Adapter::at(field, "order")), make_typed_amis_column(field)}); + } + append_typed_amis_columns(columns, fields, descriptor); + } +} +template +Json make_amis_status_body(const Json& descriptor) { Json body = json_array(); const auto& fields = Json_Adapter::at(descriptor, "fields"); for(std::size_t index = 0; index < Json_Adapter::size(fields); ++index) { @@ -196,6 +307,26 @@ Json make_amis_object_status_body(const Json& descriptor) { return result; } template +Json make_amis_status_service(const Json& descriptor, std::string status_api, std::uint64_t interval = 2000) { + Json panel = json_object(); + json_set(panel, "type", "panel"); + json_set(panel, "title", Json_Adapter::at(descriptor, "label")); + json_set(panel, "body", make_amis_status_body(descriptor)); + Json api = json_object(); + json_set(api, "method", "get"); + json_set(api, "url", std::move(status_api)); + Json result = json_object(); + json_set(result, "type", "service"); + json_set(result, "name", json_get(Json_Adapter::at(descriptor, "name")) + "_status"); + json_set(result, "api", std::move(api)); + json_set(result, "initFetch", true); + json_set(result, "interval", interval); + json_set(result, "silentPolling", true); + json_set(result, "showErrorMsg", true); + json_set(result, "body", std::move(panel)); + return result; +} +template Json make_amis_object_status_service(const Json& descriptor, std::string status_api, std::uint64_t interval) { Json api = json_object(); json_set(api, "method", "get"); @@ -207,7 +338,7 @@ Json make_amis_object_status_service(const Json& descriptor, std::string status_ json_set(result, "interval", interval); json_set(result, "silentPolling", true); json_set(result, "showErrorMsg", true); - json_set(result, "body", make_amis_object_status_body(descriptor)); + json_set(result, "body", make_amis_status_body(descriptor)); return result; } template @@ -224,14 +355,16 @@ Json make_amis_object_status_column(const Json& descriptor, std::string status_a json_set(result, "popOver", std::move(pop_over)); return result; } -template +template +requires Adapted_Object_Type 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(); Json result = json_object(); json_set(result, "type", "form"); json_set(result, "title", Json_Adapter::at(descriptor, "label")); json_set(result, "data", to_frontend_json(value)); - json_set(result, "body", make_amis_form_body(descriptor, "editable")); + json_set(result, "body", make_typed_amis_form_body(descriptor, "editable")); json_set(result, "actions", make_amis_form_actions(std::move(submit_label))); json_set(result, "affixFooter", true); if(!submit_api.empty()) { @@ -242,8 +375,10 @@ Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::strin } return result; } -template +template +requires Adapted_Object_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(); const Json& list = Json_Adapter::at(descriptor, "list"); const std::string crud_id = json_get(Json_Adapter::at(descriptor, "name")) + "_crud"; @@ -253,19 +388,15 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(id_column, "label", Json_Adapter::at(list, "id_label")); json_set(id_column, "sortable", true); json_append(columns, std::move(id_column)); - std::vector list_fields; + std::vector> ordered_columns; const auto& fields = Json_Adapter::at(descriptor, "fields"); - for(std::size_t index = 0; index < Json_Adapter::size(fields); ++index) { - const auto& field = Json_Adapter::at(fields, index); - if(json_get(Json_Adapter::at(field, "readable")) && json_get(Json_Adapter::at(field, "list_visible")) && json_get(Json_Adapter::at(field, "value_type")) != "object") { - list_fields.push_back(field); - } - } - std::stable_sort(list_fields.begin(), list_fields.end(), [](const Json& left, const Json& right) { - return json_get(Json_Adapter::at(left, "order")) < json_get(Json_Adapter::at(right, "order")); + const auto object_descriptor = describe(); + append_typed_amis_columns<0>(ordered_columns, fields, object_descriptor); + std::stable_sort(ordered_columns.begin(), ordered_columns.end(), [](const Amis_Ordered_Column& left, const Amis_Ordered_Column& right) { + return left.order < right.order; }); - for(const auto& field : list_fields) { - json_append(columns, make_amis_column(field)); + for(auto& column : ordered_columns) { + json_append(columns, std::move(column.column)); } if(status_descriptor) { json_append(columns, make_amis_object_status_column(*status_descriptor, base_api + "/${id}/status", status_interval, json_get(Json_Adapter::at(list, "view_status_label")))); @@ -279,7 +410,7 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(create_form, "type", "form"); json_set(create_form, "api", std::move(create_api)); json_set(create_form, "reload", crud_id); - json_set(create_form, "body", make_amis_form_body(descriptor, "creatable")); + json_set(create_form, "body", make_typed_amis_form_body(descriptor, "creatable")); json_set(create_form, "actions", make_amis_form_actions(create_label)); Json edit_api = json_object(); json_set(edit_api, "method", "put"); @@ -288,7 +419,7 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(edit_form, "type", "form"); json_set(edit_form, "api", std::move(edit_api)); json_set(edit_form, "reload", crud_id); - json_set(edit_form, "body", make_amis_form_body(descriptor, "editable")); + json_set(edit_form, "body", make_typed_amis_form_body(descriptor, "editable")); json_set(edit_form, "actions", make_amis_form_actions(json_get(Json_Adapter::at(list, "confirm_label")))); Json create_dialog = json_object(); json_set(create_dialog, "title", create_label + " " + json_get(Json_Adapter::at(descriptor, "label"))); @@ -366,12 +497,13 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(result, "body", std::move(crud)); return result; } -template -Json to_amis_crud_status_schema(std::string base_api, std::string status_api, const Json* object_status_descriptor = nullptr, std::uint64_t object_status_interval = 2000) { +template +requires Adapted_Object_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")); Json body = json_array(); - json_append(body, make_amis_status_service(std::move(status_api))); + json_append(body, make_amis_status_service(status_descriptor, std::move(status_api), status_interval)); json_append(body, std::move(crud)); json_set(result, "body", std::move(body)); return result; diff --git a/backend/include/adminive/http.hpp b/backend/include/adminive/http.hpp index 8c5917f..10c8945 100644 --- a/backend/include/adminive/http.hpp +++ b/backend/include/adminive/http.hpp @@ -1,22 +1,15 @@ #pragma once #include "adminive/amis.hpp" -#include "httplib.h" -#include -#include -#include -#include #include #include #include -#include -#include #include -#include namespace adminive { template -void write_http_json(httplib::Response& response, const Json& value) { - response.set_content(dump_json(value, 2), "application/json; charset=utf-8"); -} +struct Http_Response { + int status{200}; + Json body{}; +}; template Json make_http_result(int status, std::string message, Data&& data) { Json result = json_object(); @@ -26,57 +19,77 @@ Json make_http_result(int status, std::string message, Data&& data) { return result; } template -void write_http_error(httplib::Response& response, int status, const Update_Result& update) { - response.status = status; +Http_Response make_http_success(Json data, std::string message = {}) { + return Http_Response{200, make_http_result(0, std::move(message), std::move(data))}; +} +template +Http_Response make_http_error(int status, const Update_Result& update) { Json result = make_http_result(status, update.message, json_object()); Json errors = json_object(); + Json field_errors = json_object(); for(const auto& [name, message] : update.field_errors) { json_set(errors, name, message); + json_set(field_errors, name, message); } json_set(result, "errors", std::move(errors)); - write_http_json(response, result); + json_set(result, "field_errors", std::move(field_errors)); + return Http_Response{status, std::move(result)}; } -template -requires std::copy_constructible && std::assignable_from -class Http_Resource { +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 +requires Adapted_Object_Type +class Resource_Service { public: - using Commit_Function = std::function; - Http_Resource(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 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) {} + const std::string& path() const noexcept { + return path_; + } Json amis_schema() const { std::scoped_lock lock(resource_mutex()); - return to_amis_form_schema(value_, path_ + "/data", describe().list_options().confirm_label); + return to_amis_form_schema(value_, path_ + "/data", describe().list_options().confirm_label); } - void bind(httplib::Server& server) { - server.Get(path_ + "/descriptor", [this](const httplib::Request&, httplib::Response& response) { - write_http_json(response, make_http_result(0, "", to_descriptor_json())); - }); - server.Get(path_ + "/data", [this](const httplib::Request&, httplib::Response& response) { - std::scoped_lock lock(resource_mutex()); - write_http_json(response, make_http_result(0, "", to_frontend_json(value_))); - }); - server.Get(path_ + "/amis", [this](const httplib::Request&, httplib::Response& response) { - write_http_json(response, make_http_result(0, "", amis_schema())); - }); - server.Post(path_ + "/data", [this](const httplib::Request& request, httplib::Response& response) { - try { - const Json patch = parse_json(request.body); - std::scoped_lock lock(resource_mutex()); - T candidate = value_; - const auto result = apply_frontend_patch(candidate, patch); - if(!result.success) { - write_http_error(response, 422, result); - return; - } - if(commit_) { - commit_(candidate); - } - value_ = std::move(candidate); - write_http_json(response, make_http_result(0, result.message, to_frontend_json(value_))); - } catch(const std::exception& error) { - response.status = 400; - write_http_json(response, make_http_result(400, error.what(), json_object())); + Http_Response descriptor_response() const { + 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 amis_response() const { + 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(commit_) { + commit_(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) { + return make_http_error(422, object_commit_error(error)); + } catch(const std::exception& error) { + return make_http_error(500, error.what()); + } + return make_http_success(to_frontend_json(value_), result.message); } private: std::mutex& resource_mutex() const noexcept { @@ -88,319 +101,4 @@ private: std::mutex* shared_mutex_{}; mutable std::mutex mutex_; }; -template -requires std::default_initializable && std::copy_constructible && std::assignable_from -class Http_Collection_Resource { -public: - explicit Http_Collection_Resource(std::string path, std::vector initial = {}, std::string overview_status_api = {}) : path_(std::move(path)), overview_status_api_(std::move(overview_status_api)) { - for(auto& value : initial) { - entries_.push_back(Entry{next_id_++, std::move(value)}); - } - } - template - void register_status(std::uint64_t interval = 2000) { - using Function_Type = decltype(Function); - static_assert(std::is_member_function_pointer_v); - static_assert(std::is_invocable_v); - using Status = std::remove_cvref_t>; - static_assert(Described_Type); - status_descriptor_ = to_status_descriptor_json(); - status_interval_ = interval; - status_reader_ = [](T& value) { - return to_status_json(std::invoke(Function, value)); - }; - } - std::size_t size() const { - std::scoped_lock lock(mutex_); - return entries_.size(); - } - Json items_json() const { - std::scoped_lock lock(mutex_); - Json items = json_array(); - for(const auto& entry : entries_) { - json_append(items, encode_entry(entry)); - } - return items; - } - Json amis_schema() const { - const Json* descriptor = status_reader_ ? &status_descriptor_ : nullptr; - if(overview_status_api_.empty()) { - return to_amis_crud_schema(path_, descriptor, status_interval_); - } - return to_amis_crud_status_schema(path_, overview_status_api_, descriptor, status_interval_); - } - void bind(httplib::Server& server) { - server.Get(path_ + "/descriptor", [this](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) { - write_http_json(response, make_http_result(0, "", amis_schema())); - }); - if(status_reader_) { - server.Get(path_ + "/status/descriptor", [this](const httplib::Request&, httplib::Response& response) { - write_http_json(response, make_http_result(0, "", status_descriptor_)); - }); - server.Get(status_pattern(), [this](const httplib::Request& request, httplib::Response& response) { - const auto id = read_route_id(request); - std::scoped_lock lock(mutex_); - const auto iterator = find_entry(id); - if(iterator == entries_.end()) { - write_not_found(response); - return; - } - write_http_json(response, make_http_result(0, "", status_reader_(iterator->value))); - }); - } - server.Get(path_, [this](const httplib::Request& request, httplib::Response& response) { - const std::size_t page = read_size_parameter(request, "page", 1); - const std::size_t per_page = read_size_parameter(request, "perPage", 20); - std::scoped_lock lock(mutex_); - std::vector ordered_entries; - ordered_entries.reserve(entries_.size()); - for(const auto& entry : entries_) { - ordered_entries.push_back(&entry); - } - apply_request_sort(request, ordered_entries); - const std::size_t offset = std::min((page - 1) * per_page, ordered_entries.size()); - const std::size_t end = std::min(offset + per_page, ordered_entries.size()); - Json items = json_array(); - for(std::size_t index = offset; index < end; ++index) { - json_append(items, encode_entry(*ordered_entries[index])); - } - Json data = json_object(); - json_set(data, "items", std::move(items)); - json_set(data, "total", ordered_entries.size()); - write_http_json(response, make_http_result(0, "", std::move(data))); - }); - server.Get(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { - const auto id = read_route_id(request); - std::scoped_lock lock(mutex_); - const auto iterator = find_entry(id); - if(iterator == entries_.end()) { - write_not_found(response); - return; - } - write_http_json(response, make_http_result(0, "", encode_entry(*iterator))); - }); - server.Post(path_, [this](const httplib::Request& request, httplib::Response& response) { - try { - Json input = parse_json(request.body); - Json_Adapter::erase(input, "id"); - T value{}; - const auto result = apply_frontend_create(value, input); - if(!result.success) { - write_http_error(response, 422, result); - return; - } - std::scoped_lock lock(mutex_); - entries_.push_back(Entry{next_id_++, std::move(value)}); - write_http_json(response, make_http_result(0, "created", encode_entry(entries_.back()))); - } catch(const std::exception& error) { - response.status = 400; - write_http_json(response, make_http_result(400, error.what(), json_object())); - } - }); - if(describe().list_options().user_reorderable_) { - server.Post(path_ + "/order", [this](const httplib::Request& request, httplib::Response& response) { - try { - const Json input = parse_json(request.body); - const auto ids = read_order_ids(Json_Adapter::at(input, "ids")); - std::scoped_lock lock(mutex_); - reorder(ids); - write_http_json(response, make_http_result(0, "reordered", json_object())); - } catch(const std::exception& error) { - response.status = 400; - write_http_json(response, make_http_result(400, error.what(), json_object())); - } - }); - } - server.Put(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { - update(request, response); - }); - server.Patch(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { - update(request, response); - }); - server.Delete(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { - const auto id = read_route_id(request); - std::scoped_lock lock(mutex_); - const auto iterator = find_entry(id); - if(iterator == entries_.end()) { - write_not_found(response); - return; - } - entries_.erase(iterator); - write_http_json(response, make_http_result(0, "deleted", json_object())); - }); - } -private: - struct Entry { - std::uint64_t id{}; - T value; - }; - using Iterator = typename std::vector::iterator; - static std::size_t read_size_parameter(const httplib::Request& request, const char* name, std::size_t fallback) { - if(!request.has_param(name)) { - return fallback; - } - const auto text = request.get_param_value(name); - std::size_t value{}; - const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); - if(error != std::errc{} || end != text.data() + text.size() || value == 0) { - return fallback; - } - return value; - } - static int compare_json_values(const Json& left, const Json& right) { - if(Json_Adapter::is_number(left) && Json_Adapter::is_number(right)) { - const long double left_value = Json_Adapter::number(left); - const long double right_value = Json_Adapter::number(right); - return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; - } - if(Json_Adapter::is_string(left) && Json_Adapter::is_string(right)) { - const auto left_value = json_get(left); - const auto right_value = json_get(right); - return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; - } - if(Json_Adapter::is_boolean(left) && Json_Adapter::is_boolean(right)) { - const bool left_value = json_get(left); - const bool right_value = json_get(right); - return left_value == right_value ? 0 : left_value ? 1 : -1; - } - const std::string left_value = dump_json(left); - const std::string right_value = dump_json(right); - return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; - } - static bool is_sortable_field(const std::string& name) { - if(name == "id") { - return true; - } - bool sortable{}; - std::apply([&](const auto&... field) { - ((field.name() == name ? sortable = field.is_sortable() : false), ...); - }, describe().fields()); - return sortable; - } - static std::uint64_t read_route_id(const httplib::Request& request) { - const std::string text = request.matches[1].str(); - std::uint64_t id{}; - const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), id); - if(error != std::errc{} || end != text.data() + text.size()) { - return 0; - } - return id; - } - static std::vector read_order_ids(const Json& value) { - std::vector result; - if(Json_Adapter::is_array(value)) { - for(std::size_t index = 0; index < Json_Adapter::size(value); ++index) { - const auto& item = Json_Adapter::at(value, index); - result.push_back(Json_Adapter::is_string(item) ? std::stoull(json_get(item)) : json_get(item)); - } - return result; - } - const std::string text = json_get(value); - std::size_t begin{}; - while(begin < text.size()) { - const auto end = text.find(',', begin); - result.push_back(std::stoull(text.substr(begin, end == std::string::npos ? text.size() - begin : end - begin))); - if(end == std::string::npos) { - break; - } - begin = end + 1; - } - return result; - } - void apply_request_sort(const httplib::Request& request, std::vector& entries) const { - const auto& options = describe().list_options(); - std::string order_by = options.default_order_by; - std::string order_dir = options.default_order_dir; - if(request.has_param("orderBy")) { - order_by = request.get_param_value("orderBy"); - order_dir = request.has_param("orderDir") ? request.get_param_value("orderDir") : "asc"; - } - if(order_by.empty() || !is_sortable_field(order_by)) { - return; - } - const bool descending = order_dir == "desc"; - std::stable_sort(entries.begin(), entries.end(), [&](const Entry* left, const Entry* right) { - int comparison{}; - if(order_by == "id") { - comparison = left->id < right->id ? -1 : left->id > right->id ? 1 : 0; - } else { - const auto left_json = to_frontend_json(left->value); - const auto right_json = to_frontend_json(right->value); - comparison = compare_json_values(Json_Adapter::at(left_json, order_by), Json_Adapter::at(right_json, order_by)); - } - return descending ? comparison > 0 : comparison < 0; - }); - } - std::string item_pattern() const { - return path_ + R"(/(\d+))"; - } - std::string status_pattern() const { - return path_ + R"(/(\d+)/status)"; - } - Iterator find_entry(std::uint64_t id) { - return std::find_if(entries_.begin(), entries_.end(), [id](const Entry& entry) { - return entry.id == id; - }); - } - static Json encode_entry(const Entry& entry) { - Json result = to_frontend_json(entry.value); - json_set(result, "id", entry.id); - return result; - } - static void write_not_found(httplib::Response& response) { - response.status = 404; - write_http_json(response, make_http_result(404, "record not found", json_object())); - } - void reorder(const std::vector& ids) { - std::vector remaining = std::move(entries_); - std::vector reordered; - reordered.reserve(remaining.size()); - for(const auto id : ids) { - const auto iterator = std::find_if(remaining.begin(), remaining.end(), [id](const Entry& entry) { - return entry.id == id; - }); - if(iterator != remaining.end()) { - reordered.push_back(std::move(*iterator)); - remaining.erase(iterator); - } - } - for(auto& entry : remaining) { - reordered.push_back(std::move(entry)); - } - entries_ = std::move(reordered); - } - void update(const httplib::Request& request, httplib::Response& response) { - try { - Json patch = parse_json(request.body); - Json_Adapter::erase(patch, "id"); - const auto id = read_route_id(request); - std::scoped_lock lock(mutex_); - const auto iterator = find_entry(id); - if(iterator == entries_.end()) { - write_not_found(response); - return; - } - const auto result = apply_frontend_patch(iterator->value, patch); - if(!result.success) { - write_http_error(response, 422, result); - return; - } - write_http_json(response, make_http_result(0, result.message, encode_entry(*iterator))); - } catch(const std::exception& error) { - response.status = 400; - write_http_json(response, make_http_result(400, error.what(), json_object())); - } - } - std::string path_; - std::string overview_status_api_; - Json status_descriptor_{}; - std::function status_reader_; - std::uint64_t status_interval_{2000}; - std::vector entries_; - std::uint64_t next_id_{1}; - mutable std::mutex mutex_; -}; } diff --git a/backend/include/adminive/json.hpp b/backend/include/adminive/json.hpp index fa41671..c4920cd 100644 --- a/backend/include/adminive/json.hpp +++ b/backend/include/adminive/json.hpp @@ -34,20 +34,75 @@ public: private: Update_Result result_; }; +class Field_Validation_Error : public std::invalid_argument { +public: + Field_Validation_Error(std::string path, std::string message) : std::invalid_argument(message), path_(std::move(path)) {} + const std::string& path() const noexcept { + return path_; + } +private: + std::string path_; +}; enum class Write_Mode { internal, create, update }; -template -Json to_json(const T& value); -template -Json to_frontend_json(const T& value); -template -requires std::default_initializable +inline std::string join_field_path(std::string_view prefix, std::string_view child) { + if(prefix.empty()) { + return std::string(child); + } + if(child.empty()) { + return std::string(prefix); + } + if(child.front() == '[') { + return std::string(prefix) + std::string(child); + } + 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; + } + if(source.field_errors.empty() && !prefix.empty() && !source.message.empty()) { + target.field_errors.insert_or_assign(std::string(prefix), source.message); + return; + } + for(const auto& [path, message] : source.field_errors) { + target.field_errors.insert_or_assign(join_field_path(prefix, path), message); + } +} +template +concept Adapted_Object_Type = Object_Adapter_With_Snapshot && Object_Adapter_With_Create && Object_Adapter_With_Commit && Described_Type>; +template +requires Described_Type> && Object_Adapter_With_Create Json to_descriptor_json(); -template -requires std::default_initializable && std::copy_constructible && std::assignable_from +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 Update_Result assign_json(T& target, const Json& value); template void assign_json_value(T& target, const Json& value); @@ -59,6 +114,8 @@ 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(Polymorphic_Type) { + return Polymorphic_Adapter::encode(value); } else if constexpr(std::is_enum_v) { static_assert(Enum_Type, "enum type requires an adminive::Enum_Adapter specialization"); const auto name = enum_name(value); @@ -66,7 +123,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) { + } else if constexpr(Described_Type || (Object_Adapter_With_Snapshot && Described_Type>)) { return to_json(value); } else if constexpr(String_Key_Map_Type) { Json result = json_object(); @@ -94,6 +151,8 @@ void assign_json_value(T& target, const Json& value) { Value parsed{}; assign_json_value(parsed, value); write_adapted_value(target, std::move(parsed)); + } else if constexpr(Polymorphic_Type) { + Polymorphic_Adapter::decode(target, value); } else if constexpr(std::is_enum_v) { static_assert(Enum_Type, "enum type requires an adminive::Enum_Adapter specialization"); std::optional parsed; @@ -106,7 +165,7 @@ void assign_json_value(T& target, const Json& value) { throw std::invalid_argument("unknown enum value"); } target = *parsed; - } else if constexpr(Described_Type) { + } else if constexpr(Adapted_Object_Type) { const auto result = assign_json(target, value); if(!result.success) { throw Json_Assignment_Error(result); @@ -118,7 +177,21 @@ void assign_json_value(T& target, const Json& value) { Value parsed; for(const auto& key : Json_Adapter::keys(value)) { typename Value::mapped_type item{}; - assign_json_value(item, Json_Adapter::at(value, key)); + 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); @@ -129,7 +202,22 @@ void assign_json_value(T& target, const Json& value) { Value parsed; for(std::size_t index = 0; index < Json_Adapter::size(value); ++index) { typename Value::value_type item{}; - assign_json_value(item, Json_Adapter::at(value, index)); + 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); @@ -143,6 +231,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(Polymorphic_Type) { + return "polymorphic"; } else if constexpr(std::is_enum_v) { return "enum"; } else if constexpr(std::same_as) { @@ -153,7 +243,7 @@ std::string value_type_name() { return "number"; } else if constexpr(String_Type) { return "string"; - } else if constexpr(Described_Type) { + } else if constexpr(Described_Type || (Object_Adapter_With_Snapshot && Described_Type>)) { return "object"; } else if constexpr(String_Key_Map_Type) { return "map"; @@ -182,6 +272,9 @@ void append_constraints(Json& result) { if constexpr(!std::same_as) { append_constraints(result); } + if constexpr(Polymorphic_Type) { + json_set(result, "polymorphic", Polymorphic_Adapter::descriptor()); + } if constexpr(Value_Adapter_With_Schema) { Value_Adapter::append_schema(result); } @@ -203,21 +296,45 @@ Json enum_options(const std::map& labels) { } return result; } -template -requires std::default_initializable -Json to_descriptor_json() { - const auto descriptor = describe(); - T defaults{}; +template +Json make_polymorphic_variant(std::string value, std::string label, Json descriptor) { + Json result = json_object(); + json_set(result, "value", std::move(value)); + json_set(result, "label", std::move(label)); + json_set(result, "descriptor", std::move(descriptor)); + return result; +} +template +Json make_polymorphic_descriptor(std::string discriminator, std::string discriminator_label, Json variants) { + Json options = json_array(); + for(std::size_t index = 0; index < Json_Adapter::size(variants); ++index) { + const auto& variant = Json_Adapter::at(variants, index); + Json option = json_object(); + json_set(option, "label", Json_Adapter::at(variant, "label")); + json_set(option, "value", Json_Adapter::at(variant, "value")); + json_append(options, std::move(option)); + } + Json result = json_object(); + json_set(result, "discriminator", std::move(discriminator)); + json_set(result, "discriminator_label", std::move(discriminator_label)); + json_set(result, "options", std::move(options)); + json_set(result, "variants", std::move(variants)); + return result; +} +template +requires Described_Type +Json model_descriptor_json(const Model& defaults) { + const auto descriptor = describe(); Json fields = json_array(); - int default_order{}; + int field_index{}; std::apply([&](const auto&... item) { ([&] { using Field = std::remove_cvref_t; using Member = typename Field::member_type; using Value = Adapted_Value_Type; - const int order = item.order() < 0 ? default_order : item.order(); - ++default_order; Json field_json = json_object(); + const int order = item.order() < 0 ? field_index : item.order(); + ++field_index; json_set(field_json, "name", item.name()); json_set(field_json, "label", item.label()); json_set(field_json, "list_label", item.list_label()); @@ -240,7 +357,7 @@ Json to_descriptor_json() { if(!item.visible_on().empty()) { json_set(field_json, "visible_on", item.visible_on()); } - if constexpr(Described_Type) { + if constexpr(Adapted_Object_Type) { json_set(field_json, "children", Json_Adapter::at(to_descriptor_json(), "fields")); } append_constraints(field_json); @@ -268,7 +385,7 @@ Json to_descriptor_json() { json_set(list, "column_reorderable", options.column_reorderable_); Json result = json_object(); json_set(result, "protocol", "adminive.resource"); - json_set(result, "protocol_version", 1); + json_set(result, "protocol_version", 2); json_set(result, "name", descriptor.name()); json_set(result, "label", descriptor.label()); json_set(result, "value_type", "object"); @@ -276,29 +393,46 @@ Json to_descriptor_json() { json_set(result, "fields", std::move(fields)); return result; } -template -Json to_json(const T& value) { - const auto descriptor = describe(); - Json result = json_object(); - std::apply([&](const auto&... item) { - (json_set(result, item.name(), encode_json_value(item.get(value))), ...); - }, descriptor.fields()); - return result; +template +requires Described_Type> && Object_Adapter_With_Create +Json to_descriptor_json() { + return model_descriptor_json(Object_Adapter>::create()); } template -Json to_frontend_json(const T& value) { +Json model_to_json(const T& value, bool frontend) { const auto descriptor = describe(); Json result = json_object(); std::apply([&](const auto&... item) { ([&] { - if(item.is_readable()) { + if(!frontend || item.is_readable()) { json_set(result, item.name(), encode_json_value(item.get(value))); } }(), ...); }, descriptor.fields()); return result; } -template +template +requires Described_Type> || Object_Adapter_With_Snapshot +Json to_json(const T& value) { + using Object = std::remove_cvref_t; + if constexpr(std::same_as>) { + return model_to_json(value, false); + } else { + return model_to_json(Object_Adapter::snapshot(value), false); + } +} +template +requires Described_Type> || Object_Adapter_With_Snapshot +Json to_frontend_json(const T& value) { + using Object = std::remove_cvref_t; + if constexpr(std::same_as>) { + return model_to_json(value, true); + } else { + return model_to_json(Object_Adapter::snapshot(value), true); + } +} +template +requires Described_Type> || Object_Adapter_With_Snapshot Json to_data_json(const T& value) { return to_frontend_json(value); } @@ -312,16 +446,14 @@ bool field_writable(const Field& field, Write_Mode mode) { } return field.is_editable(); } -template -requires std::copy_constructible && std::assignable_from -Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, bool complete) { +template +Update_Result apply_model_json(Model& candidate, const Json& value, Write_Mode mode, bool complete) { Update_Result result; if(!Json_Adapter::is_object(value)) { result.message = "value must be a JSON object"; return result; } - T candidate = target; - const auto descriptor = describe(); + const auto descriptor = describe(); for(const auto& key : Json_Adapter::keys(value)) { bool known{}; std::apply([&](const auto&... item) { @@ -349,7 +481,9 @@ Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, b try { assign_json_value(item.get(candidate), Json_Adapter::at(value, item.name())); } catch(const Json_Assignment_Error& error) { - result.field_errors[item.name()] = error.what(); + merge_update_errors(result, error.result(), item.name()); + } catch(const Field_Validation_Error& error) { + result.field_errors[join_field_path(item.name(), error.path())] = error.what(); } catch(const std::exception& error) { result.field_errors[item.name()] = error.what(); } @@ -361,47 +495,71 @@ Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, b } try { descriptor.object_validator()(candidate); + } catch(const Json_Assignment_Error& error) { + merge_update_errors(result, error.result()); + } catch(const Field_Validation_Error& error) { + result.field_errors[error.path()] = error.what(); } catch(const std::exception& error) { result.message = error.what(); return result; } - target = std::move(candidate); + if(!result.field_errors.empty()) { + result.message = "one or more fields are invalid"; + return result; + } result.success = true; result.message = complete ? "assigned" : "updated"; return result; } -template -requires std::default_initializable && std::copy_constructible && std::assignable_from -Update_Result assign_json(T& target, const Json& value) { - T candidate{}; - auto result = apply_object_json(candidate, value, Write_Mode::internal, true); - if(result.success) { - target = std::move(candidate); +template +Update_Result object_commit_error(const Field_Validation_Error& error) { + Update_Result result; + result.message = "one or more fields are invalid"; + result.field_errors[error.path()] = error.what(); + return result; +} +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 result = apply_model_json(candidate, value, mode, complete); + if(!result.success) { + return result; + } + try { + Object_Adapter::commit(target, std::move(candidate)); + } catch(const Json_Assignment_Error& error) { + return error.result(); + } catch(const Field_Validation_Error& error) { + return object_commit_error(error); + } catch(const std::exception& error) { + result.success = false; + result.message = error.what(); } return result; } -template -requires std::copy_constructible && std::assignable_from +template +Update_Result assign_json(T& target, const Json& value) { + return apply_object_json(target, value, Write_Mode::internal, true); +} +template Update_Result apply_json_patch(T& target, const Json& patch) { return apply_object_json(target, patch, Write_Mode::internal, false); } -template -requires std::copy_constructible && std::assignable_from +template Update_Result apply_frontend_create(T& target, const Json& value) { return apply_object_json(target, value, Write_Mode::create, true); } -template -requires std::copy_constructible && std::assignable_from +template Update_Result apply_frontend_patch(T& target, const Json& patch) { return apply_object_json(target, patch, Write_Mode::update, false); } -template -requires std::copy_constructible && std::assignable_from +template Update_Result apply_patch(T& target, const Json& patch) { return apply_frontend_patch(target, patch); } -template -requires std::default_initializable && std::copy_constructible && std::assignable_from +template +requires std::default_initializable T from_json(const Json& value) { T result{}; auto update = assign_json(result, value); @@ -410,13 +568,23 @@ T from_json(const Json& value) { } return result; } -template +template +requires Described_Type> || Object_Adapter_With_Snapshot Update_Result validate(const T& value) { Update_Result result; try { - describe().object_validator()(value); + using Object = std::remove_cvref_t; + if constexpr(std::same_as>) { + describe().object_validator()(value); + } else { + const auto model = Object_Adapter::snapshot(value); + describe>().object_validator()(model); + } result.success = true; result.message = "valid"; + } catch(const Field_Validation_Error& error) { + result.message = "one or more fields are invalid"; + result.field_errors[error.path()] = error.what(); } catch(const std::exception& error) { result.message = error.what(); } diff --git a/backend/src/example.hpp b/backend/src/example.hpp index 8536c4f..c55344e 100644 --- a/backend/src/example.hpp +++ b/backend/src/example.hpp @@ -2,7 +2,7 @@ #include "adminive/adminive.hpp" #include "adminive/adapters/magic_enum.hpp" #include "adminive/adapters/nlohmann_json.hpp" -#include "adminive/http.hpp" +#include "adminive/adapters/httplib.hpp" #include #include #include diff --git a/backend/src/main.cpp b/backend/src/main.cpp index ae7ac3b..12cbc99 100644 --- a/backend/src/main.cpp +++ b/backend/src/main.cpp @@ -69,9 +69,10 @@ int main(int argc, char** argv) { Radio_Service_Config radio_service_config; Alert_Config alert_config; httplib::Server server; - State_Resource state_resource("/admin/radio_states", std::move(initial), "/admin/status"); + 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); @@ -89,7 +90,7 @@ int main(int argc, char** argv) { 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_frontend_json(status)}}); + 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())) { diff --git a/backend/src/server_app.cpp b/backend/src/server_app.cpp index 1dc85fc..9ddf023 100644 --- a/backend/src/server_app.cpp +++ b/backend/src/server_app.cpp @@ -12,11 +12,12 @@ 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(), "/admin/status"), 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](const 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_.persist_alerts(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(); } @@ -69,7 +70,7 @@ void Server_App::bind_routes() { status.poll_sequence = poll_sequence_.fetch_add(1, std::memory_order_relaxed) + 1; status.radio_state_count = state_resource_.size(); status.listen_port = port_; - write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", to_frontend_json(status)}}); + write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", to_status_json(status)}}); }); } } diff --git a/backend/tests/advanced_adapter_test.cpp b/backend/tests/advanced_adapter_test.cpp new file mode 100644 index 0000000..d847f98 --- /dev/null +++ b/backend/tests/advanced_adapter_test.cpp @@ -0,0 +1,252 @@ +#include "adminive/adapters/boost_pfr.hpp" +#include "adminive/adapters/nlohmann_json.hpp" +#include "adminive/http.hpp" +#include +#include +#include +namespace advanced_test { +using Json = nlohmann::json; +struct Nested_Config { + int count{1}; +}; +struct Runtime_Model { + Nested_Config nested; + std::string name{"default"}; +}; +class Runtime_Config { +public: + Runtime_Config() = default; + Runtime_Config(const Runtime_Config&) = delete; + Runtime_Config& operator=(const Runtime_Config&) = delete; + int count() const noexcept { + return count_; + } + const std::string& name() const noexcept { + return name_; + } + void apply(const Runtime_Model& value) { + count_ = value.nested.count; + name_ = value.name; + } +private: + int count_{1}; + std::string name_{"default"}; +}; +struct Base_A { + int alpha{3}; +}; +struct Base_B { + std::string beta{"base"}; +}; +struct Inherited_Config : Base_A, Base_B {}; +struct Color_Code { + std::string value{"#112233"}; +}; +struct Control_Config { + Color_Code color; +}; +struct Serial_Device { + std::string port{"COM1"}; + int baud_rate{115200}; +}; +struct Network_Device { + std::string host{"127.0.0.1"}; + int port{9000}; +}; +using Device = std::variant; +struct Device_Config { + Device device{Serial_Device{}}; +}; +struct Overview_Status { + adminive::Status_Value state{"running", "#16a34a"}; + int workers{4}; +}; +} +namespace adminive { +template <> +struct Boost_Pfr_Name_Adapter { + template + static constexpr std::string_view name() noexcept { + static_assert(Index == 0); + return "alpha"; + } +}; +template <> +struct Boost_Pfr_Name_Adapter { + template + static constexpr std::string_view name() noexcept { + static_assert(Index == 0); + return "beta"; + } +}; +template <> +struct Object_Adapter { + using model_type = advanced_test::Runtime_Model; + static model_type snapshot(const advanced_test::Runtime_Config& value) { + model_type result; + result.nested.count = value.count(); + 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); + } +}; +template <> +struct Reflection_Adapter : Boost_Pfr_Base_Reflection_Adapter {}; +template +struct Value_Adapter { + using value_type = std::string; + static const std::string& read(const advanced_test::Color_Code& value) noexcept { + return value.value; + } + static void write(advanced_test::Color_Code& target, std::string value) { + target.value = std::move(value); + } +}; +template <> +struct Control_Adapter { + static advanced_test::Json make_control(const advanced_test::Json& field, Control_Context context) { + advanced_test::Json result; + result["type"] = "input-color"; + result["name"] = make_field_name(context.prefix, field.at("name").get()); + result["label"] = field.at("label"); + result["clearable"] = true; + return result; + } + static advanced_test::Json make_column(const advanced_test::Json& field) { + return advanced_test::Json{{"type", "tpl"}, {"name", field.at("name")}, {"label", field.at("list_label")}, {"tpl", "${color}"}}; + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Nested_Config; + return object("nested", "Nested", ADMINIVE_FIELD(T, count).editable()).validator([](const T& value) { + if(value.count < 1) { + throw Field_Validation_Error("count", "count must be positive"); + } + }); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Runtime_Model; + return object("runtime", "Runtime", ADMINIVE_FIELD(T, nested).editable(), ADMINIVE_FIELD(T, name).editable()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Inherited_Config; + return reflected_object("inherited", "Inherited"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Control_Config; + return object("control", "Control", ADMINIVE_FIELD(T, color).editable().list_label("颜色")); + } +}; +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()); + } +}; +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()); + } +}; +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 advanced_test::Json encode(const advanced_test::Device& value) { + return std::visit([](const auto& device) { + advanced_test::Json result = to_json(device); + using T = std::remove_cvref_t; + result["type"] = std::same_as ? "serial" : "network"; + return result; + }, value); + } + 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); + 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); + return; + } + throw Field_Validation_Error("type", "unknown device type"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Device_Config; + return object("devices", "Devices", ADMINIVE_FIELD(T, device).editable()); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = advanced_test::Overview_Status; + return object("overview", "运行状态", ADMINIVE_FIELD(T, state).label("服务状态"), ADMINIVE_FIELD(T, workers).label("工作线程")); + } +}; +} +int main() { + using namespace advanced_test; + Runtime_Config runtime; + adminive::Resource_Service service(runtime, "/config"); + 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 valid = service.update_response(R"({"nested":{"count":8},"name":"updated"})"); + assert(valid.status == 200); + assert(runtime.count() == 8); + assert(runtime.name() == "updated"); + 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"); + Control_Config controls; + const auto control_form = adminive::to_amis_form_schema(controls); + assert(control_form.at("body").at(0).at("type") == "input-color"); + Device_Config devices; + 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"); + 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); + return 0; +} diff --git a/backend/tests/drogon_adapter_test.cpp b/backend/tests/drogon_adapter_test.cpp new file mode 100644 index 0000000..b9c24a2 --- /dev/null +++ b/backend/tests/drogon_adapter_test.cpp @@ -0,0 +1,24 @@ +#include "adminive/adapters/drogon.hpp" +#include "adminive/adapters/nlohmann_json.hpp" +#include +namespace drogon_test { +struct Config { + std::string name{"default"}; +}; +} +namespace adminive { +template <> +struct Type_Descriptor { + static auto get() { + using T = drogon_test::Config; + return object("config", "Config", ADMINIVE_FIELD(T, name).editable()); + } +}; +} +int main() { + drogon_test::Config config; + adminive::Drogon_Resource resource(config, "/config"); + drogon::HttpAppFramework app; + resource.bind(app); + return 0; +} diff --git a/backend/tests/fake_drogon/drogon/drogon.h b/backend/tests/fake_drogon/drogon/drogon.h new file mode 100644 index 0000000..4380d37 --- /dev/null +++ b/backend/tests/fake_drogon/drogon/drogon.h @@ -0,0 +1,53 @@ +#pragma once +#include +#include +#include +#include +#include +#include +namespace drogon { +enum HttpStatusCode { + k200OK = 200, + k400BadRequest = 400, + k422UnprocessableEntity = 422 +}; +enum ContentType { + CT_APPLICATION_JSON +}; +enum HttpMethod { + Get, + Post +}; +class HttpResponse { +public: + static std::shared_ptr newHttpResponse() { + return std::make_shared(); + } + void setStatusCode(HttpStatusCode value) { + status = value; + } + void setContentTypeCode(ContentType value) { + content_type = value; + } + void setBody(std::string value) { + body = std::move(value); + } + HttpStatusCode status{k200OK}; + ContentType content_type{CT_APPLICATION_JSON}; + std::string body; +}; +using HttpResponsePtr = std::shared_ptr; +class HttpRequest { +public: + std::string_view getBody() const noexcept { + return body; + } + std::string body; +}; +using HttpRequestPtr = std::shared_ptr; +class HttpAppFramework { +public: + template + void registerHandler(const std::string&, Handler&&, const std::vector&) {} +}; +} diff --git a/backend/tests/test.cpp b/backend/tests/test.cpp index 60dd09a..8ee3bbe 100644 --- a/backend/tests/test.cpp +++ b/backend/tests/test.cpp @@ -93,7 +93,8 @@ int main() { assert(!first_status.at("operating_state").at("color").get().empty()); assert(first_status.at("refresh_sequence").at("value") == 1); assert(second_status.at("refresh_sequence").at("value") == 2); - const auto status_crud = adminive::to_amis_crud_status_schema("/admin/radio_states", "/admin/status", &status_descriptor); + const auto overview_descriptor = adminive::to_status_descriptor_json(); + const auto status_crud = adminive::to_amis_crud_status_schema("/admin/radio_states", "/admin/status", overview_descriptor, 2000, &status_descriptor); assert(status_crud.at("body").is_array()); assert(status_crud.at("body").at(0).at("type") == "service"); assert(status_crud.at("body").at(1).at("columns").size() == 8); @@ -101,7 +102,8 @@ int main() { assert(status_column.at("label") == "Runtime Status"); 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}, "/admin/status"); + adminive::example::Http_Collection_Resource collection_resource("/admin/radio_states", std::vector{state}); + collection_resource.register_overview_status("/admin/status", 2000); collection_resource.register_status<&Radio_State::status>(3000); const auto registered_schema = collection_resource.amis_schema(); const auto& registered_status_column = registered_schema.at("body").at(1).at("columns").at(6);