diff --git a/README.md b/README.md index 893a9d6..c326d2e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,146 @@ -# Adminive Backend-Driven Example +# Adminive Backend-Driven Complete Example -Adminive 使用 C++20 模板、成员指针和 `magic_enum` 生成后端描述。前端只负责渲染 `/admin/amis` 返回的 amis Schema,不包含业务字段、枚举映射、列顺序、排序规则或配置联动。 +Adminive 使用 C++20 模板、Concepts 和可替换适配器描述业务类型。同一份后端描述驱动 JSON 双向转换、事务式赋值、字段权限、校验、amis CRUD、枚举列表列、树状配置、联动条件、日期控件、颜色控件和一次性提交。核心层不依赖具体 JSON 库、枚举反射库或结构体反射库。前端只读取 `/admin/amis` 并渲染后端返回的页面 JSON,不包含业务字段或业务判断。 -## 数据名称与界面名称 +## 适配层 -字段成员名继续作为稳定的数据名称: +核心目标只提供描述器、转换流程和 amis Schema 生成: + +```cmake +target_link_libraries(your_target PRIVATE Adminive::Core) +``` + +HTTP 资源层单独链接: + +```cmake +target_link_libraries(your_target PRIVATE Adminive::Http) +``` + +项目自带的 `nlohmann::json` 和 `magic_enum` 桥接不会被核心头自动包含。需要默认实现时显式包含: + +```cpp +#include "adminive/adapters/nlohmann_json.hpp" +#include "adminive/adapters/magic_enum.hpp" +using Json = nlohmann::json; +``` + +### JSON 适配 + +外部 JSON 类型通过 `Json_Adapter` 接入。适配器负责对象、数组、标量、字段访问、解析和输出。所有转换入口显式携带 JSON 类型: + +```cpp +auto descriptor = adminive::to_descriptor_json(); +auto encoded = adminive::to_json(config); +auto result = adminive::apply_frontend_patch(config, input); +``` + +### 外部值类型适配 + +外部包装类型不需要继承 Adminive 类型。可以针对全部 JSON 实现提供通用适配,也可以只针对某个 JSON 类型特化: + +```cpp +template +struct adminive::Value_Adapter { + using value_type = int; + static int read(const Copyable_Atomic_Int& value) noexcept { + return value.load(); + } + static void write(Copyable_Atomic_Int& target, int value) noexcept { + target.store(value); + } +}; +``` + +需要完全控制某种 JSON 的编码时,适配器可以提供 `encode()`、`decode()`、`type_name` 和 `append_schema()`。 + +### 枚举适配 + +核心仅调用 `Enum_Adapter`。`adminive/adapters/magic_enum.hpp` 是可选桥接,也可以为单个枚举自行实现: + +```cpp +template <> +struct adminive::Enum_Adapter { + static std::vector values(); + static std::string_view name(My_Mode value); + static std::optional cast(std::string_view value); +}; +``` + +### PFR 或其他结构体反射适配 + +核心通过 `Reflection_Adapter` 接收字段数量、字段名称和按索引访问。外部可以在实现层用 Boost.PFR 完成桥接,Adminive 本身不包含 Boost.PFR。描述器使用 `reflected_object()` 或 `reflected_object_with()` 生成字段。 + +```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(); + } +}; +``` + +公开且无歧义的基类成员也可以直接注册到派生类型描述器,字段所有者不再要求与最终对象类型完全相同。 + +## 目录结构 + +```text +Adminive/ +├── backend/ +│ ├── include/adminive/ +│ ├── src/ +│ │ ├── example.hpp +│ │ └── main.cpp +│ ├── tests/ +│ └── third_party/ +├── cmake/ +├── frontend/ +├── scripts/ +├── CMakeLists.txt +└── README.md +``` + +`example.hpp` 位于 `backend/src`,只作为完整示例实现,不属于公共库头文件。 + +## 列字段名称、显示名称和排序 + +```cpp +ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") + .creatable() + .editable() + .required() + .list_label("Mode") + .order(5) + .sortable(); +``` + +- `name` 来自成员名称,用于 JSON 字段和接口参数。 +- `label` 用于表单标签。 +- `list_label` 用于列表表头,未设置时回退到 `label`。 +- `order` 控制列表列顺序,未设置时按字段声明顺序生成默认值。 +- `sortable` 控制该列能否排序,后端只接受已声明为可排序的字段。 + +## 枚举列表项 + +```cpp +enum class Radio_Mode { + receive, + transmit, + duplex, + maintenance +}; +``` + +业务结构直接使用枚举: ```cpp struct Radio_State { @@ -12,179 +148,260 @@ struct Radio_State { }; ``` -界面标签、列表表头和枚举显示名称在描述端单独配置: +描述中不需要手写选项: ```cpp -ADMINIVE_FIELD_LABEL(T, mode, "运行模式") - .list_label("模式") - .sortable() - .enum_label("接收") - .enum_label("发送") - .enum_label("双工") - .enum_label("维护"); -``` - -接口仍返回: - -```json -{ - "mode": "duplex" -} -``` - -表格显示为“模式:双工”。`magic_enum` 负责枚举值发现和稳定字符串生成,`.enum_label` 只覆盖界面文字。 - -## 列顺序与用户排序 - -字段的默认列顺序和是否允许按值排序由后端描述: - -```cpp -ADMINIVE_FIELD_LABEL(T, buffer_count, "缓冲区数量") - .list_label("缓冲区") - .order(10) +ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") + .creatable() + .editable() + .required() + .list_label("Mode") + .order(5) .sortable(); ``` -对象描述可以定义默认排序: +示例通过可选的 `magic_enum` 桥接生成;核心只依赖 `Enum_Adapter`: -```cpp -object(/* fields */) - .default_sort("buffer_count"); +```json +{ + "name": "mode", + "value_type": "enum", + "options": [ + {"label": "Receive", "value": "receive"}, + {"label": "Transmit", "value": "transmit"}, + {"label": "Duplex", "value": "duplex"}, + {"label": "Maintenance", "value": "maintenance"} + ] +} ``` -用户点击可排序表头时,前端发送 `orderBy` 和 `orderDir`,后端只接受声明为 `.sortable()` 的字段。 +表格列使用 amis `mapping`,接口数据仍然返回稳定的枚举字符串,例如 `"duplex"`。 -用户也可以拖拽调整行顺序: - -```cpp -object(/* fields */) - .user_reorderable(); -``` - -该配置会生成 `draggable` 和 `saveOrderApi`,后端提供 `POST /order` 保存当前运行期顺序。列选择菜单支持拖拽调整列先后,默认由 `.column_reorderable()` 控制。 - -## CRUD 中文文字 - -CRUD 固定文字也在对象描述中配置: - -```cpp -object(/* fields */) - .label("无线电状态") - .id_label("编号") - .create_label("新增") - .edit_label("编辑") - .delete_label("删除") - .actions_label("操作") - .confirm_label("确认修改") - .view_status_label("查看状态 ▾") - .management_label("无线电状态列表"); -``` - -## 树状配置与持久化 - -嵌套 C++ 对象直接表达树状配置: +## 树状配置 + +树结构由嵌套的已描述对象表达,不额外维护一份前端树配置: ```cpp +struct Endpoint_Config { + bool enabled{true}; + std::string host{"127.0.0.1"}; + Range_Value port{9000}; +}; +struct Appearance_Config { + std::string panel_title{"Radio Control"}; + std::string effective_date{"2026-08-06"}; + std::string accent_color{"#2563eb"}; +}; struct Radio_Service_Config { std::string profile_name{"Primary Radio Profile"}; Radio_Mode mode{Radio_Mode::receive}; + Range_Value worker_count{4}; + double receive_gain{1.25}; Endpoint_Config primary_endpoint{}; Endpoint_Config backup_endpoint{}; Appearance_Config appearance{}; }; ``` -嵌套对象在界面中生成 `fieldset`。字符串、整数、浮点数、布尔、枚举、日期和颜色分别生成对应控件。联动条件仍由后端定义: +界面结构为: + +```text +Radio Service Configuration +├── Profile Name +├── Operating Mode +├── Worker Count +├── Receive Gain +├── Primary Endpoint +│ ├── Enable Endpoint +│ ├── Host Address +│ └── Port +├── Backup Endpoint +│ ├── Enable Endpoint +│ ├── Host Address +│ └── Port +└── Appearance + ├── Panel Title + ├── Effective Date + └── Accent Color +``` + +嵌套对象在 amis 表单中渲染成可折叠 `fieldset`,子字段使用点路径,例如: + +```text +primary_endpoint.host +appearance.effective_date +``` + +提交时仍然形成嵌套 JSON 对象。 + +## 配置联动 + +字段描述通过 `visible_on` 保存 amis 表达式: ```cpp -ADMINIVE_FIELD_LABEL(T, backup_endpoint, "备用端点") +ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint") .editable() .visible_on("${mode == 'duplex'}"); ``` -配置文件位于: +当 `mode` 不是 `duplex` 时,整个 `Backup Endpoint` 子树隐藏。 -```text -config/adminive.json -``` - -文件保存以下持久化内容: - -```text -Application_Config -├── default_device_type -├── radio_service -├── alerts -├── serial_table -└── network_table -``` - -程序启动时读取该文件。文件不存在时,使用 C++ 默认值自动创建。配置表单点击“确认修改”后,后端先在副本上完成赋值和校验,再写入临时文件并替换正式配置文件,成功后才替换内存对象。 - -## 多态类型表格 - -不同表格类型通过 C++ 虚函数注册: +第二个配置示例使用组合条件: ```cpp -class Device_Table_Base { -public: - virtual ~Device_Table_Base() = default; - virtual Device_Table_Type type() const noexcept = 0; - virtual std::string_view type_name() const noexcept = 0; - virtual std::string_view type_label() const noexcept = 0; - virtual Json amis_schema() const = 0; - virtual void bind(httplib::Server& server) = 0; -}; +ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook Endpoint") + .editable() + .visible_on("${enabled && channel == 'webhook'}"); ``` -示例实现两个子类: +只有启用告警且投递通道为 `webhook` 时,才显示 webhook 子配置。 + +## 完整输入控件 + +后端根据 C++ 类型自动选择基础控件: ```text -Serial_Device_Table -Network_Device_Table +std::string -> input-text +整数类型 -> input-number +浮点类型 -> input-number +bool -> switch +enum class -> select ``` -界面切换 `device_type` 后,`service.schemaApi` 重新向后端请求对应子类的 Schema: +日期和颜色通过后端字段描述指定: + +```cpp +ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date") + .editable() + .required() + .widget("input-date"); +ADMINIVE_FIELD_LABEL(T, accent_color, "Accent Color") + .editable() + .required() + .widget("input-color"); +``` + +日期控件自动补充: + +```json +{ + "type": "input-date", + "valueFormat": "YYYY-MM-DD", + "displayFormat": "YYYY-MM-DD", + "clearable": true +} +``` + +## 一次性确认修改 + +配置表单不会在单个输入项改变时调用后端。所有修改先保留在表单数据域,点击 `Confirm Changes` 后一次性提交完整配置: + +```json +{ + "actions": [ + {"type": "reset", "label": "Reset"}, + {"type": "submit", "label": "Confirm Changes", "level": "primary"} + ] +} +``` + +后端先复制当前对象,在副本上完成所有字段赋值和校验,全部成功后才替换原对象,因此一次提交具有对象级事务语义。 + +## 两组配置示例 + +页面包含三个后端定义的标签页: ```text -serial -> 串口、波特率、校验位、启用 -network -> 地址、协议、超时、TLS +Radio State List +Radio Service Configuration +Alert Configuration ``` -因此切换类型后,表格列、编辑表单、配置表单和接口路径都会一起改变,前端不需要识别任何业务类型。 +`Radio Service Configuration` 展示字符串、枚举、整数、浮点数、树状子配置、日期和颜色。 + +`Alert Configuration` 展示布尔开关、字符串、两个枚举、数字、日期、颜色和条件显示的 webhook 子配置。 ## 对象状态 -对象状态使用独立类型,并通过成员函数指针注册: +对象状态使用独立返回类型描述,不混入 CRUD 对象字段: + +```cpp +struct Radio_Item_Status { + Status_Value operating_state; + Status_Value refresh_sequence; + Status_Value refresh_time; +}; +``` + +状态值同时携带颜色: + +```json +{ + "operating_state": { + "value": "running", + "color": "#16a34a" + } +} +``` + +资源通过成员函数指针注册: ```cpp resource.register_status<&Radio_State::status>(2000); ``` -只有用户打开某一行的“查看状态”弹层后,该行状态才开始请求和刷新。颜色由后端状态值返回。 +只有用户打开某一行的 `View status` PopOver 后,该行状态才开始请求和刷新。 -## 主要接口 +## API ```text GET /admin/amis GET /admin/status +GET /admin/radio_states/descriptor +GET /admin/radio_states/status/descriptor +GET /admin/radio_states/amis GET /admin/radio_states +GET /admin/radio_states/{id} +GET /admin/radio_states/{id}/status POST /admin/radio_states PUT /admin/radio_states/{id} +PATCH /admin/radio_states/{id} DELETE /admin/radio_states/{id} -GET /admin/radio_states/{id}/status -POST /admin/device_tables/serial/items/order -POST /admin/device_tables/network/items/order -GET /admin/device_tables/schema?type=serial -GET /admin/device_tables/schema?type=network -POST /admin/device_tables/default +GET /admin/config/radio/descriptor +GET /admin/config/radio/data +GET /admin/config/radio/amis POST /admin/config/radio/data +GET /admin/config/alerts/descriptor +GET /admin/config/alerts/data +GET /admin/config/alerts/amis POST /admin/config/alerts/data -POST /admin/device_tables/serial/config/data -POST /admin/device_tables/network/config/data ``` -## 构建 +## 前端 + +创建外部 `node_modules` 相对符号链接: + +```powershell +pwsh -NoProfile -File .\scripts\link_node_modules.ps1 +``` + +默认结构: + +```text +Adminive/ +├── frontend/ +│ └── node_modules -> ../node_modules +└── node_modules/ +``` + +安装和构建: + +```powershell +npm --prefix .\frontend ci +npm --prefix .\frontend run build +``` + +## 后端 ```powershell cmake -S . -B build -G Ninja @@ -193,46 +410,20 @@ ctest --test-dir build --output-on-failure .\build\backend\Adminive_Server.exe 9999 ``` +访问: + +```text +http://127.0.0.1:9999 +``` + ## 源码压缩目标 -压缩函数参数顺序: - -```cmake -add_project_zip_target( - target_name - output_file - root_directory - include_list_name - exclude_list_name -) +```powershell +cmake --build build --target package_zip ``` -调用示例: +输出文件位置由第二个参数明确指定,第三个参数传入排除列表变量名: ```cmake -set(adminive_package_includes - "/backend/" - "/cmake/" - "/config/" - "/frontend/" - "/scripts/" - "/CMakeLists.txt" - "/README.md" -) -set(adminive_package_excludes - "/.git/" - "/.idea/" - "/build/" - "/cmake-build-*/" - "/doc/" - "/node_modules/" - "/frontend/frontend.zip" - "/frontend_dist/" - "*.tsbuildinfo" - "*.zip" -) -include("${CMAKE_CURRENT_LIST_DIR}/cmake/PackageZip.cmake") -add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" "${CMAKE_CURRENT_LIST_DIR}" adminive_package_includes adminive_package_excludes) +add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" adminive_package_excludes) ``` - -函数只扫描包含列表指定的目录和文件,然后相对于根目录应用排除列表。 diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index f073eab..be12159 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -5,45 +5,36 @@ set(adminive_backend_root "${CMAKE_CURRENT_LIST_DIR}") set(adminive_include_dir "${adminive_backend_root}/include") set(adminive_third_party_include_dir "${adminive_backend_root}/third_party/include") set(adminive_frontend_dist_dir "${adminive_backend_root}/../frontend_dist") -set(adminive_config_file "${adminive_backend_root}/../config/adminive.json") file(TO_CMAKE_PATH "${adminive_frontend_dist_dir}" adminive_frontend_dist_dir) -file(TO_CMAKE_PATH "${adminive_config_file}" adminive_config_file) add_library(Adminive INTERFACE) +if(MSVC) + target_compile_options(Adminive INTERFACE /utf-8 /permissive- /Zc:__cplusplus) +endif() add_library(Adminive::Adminive ALIAS Adminive) -target_include_directories(Adminive INTERFACE - "${adminive_include_dir}" - "${adminive_third_party_include_dir}" -) +add_library(Adminive::Core ALIAS Adminive) +target_include_directories(Adminive INTERFACE "${adminive_include_dir}") target_compile_features(Adminive INTERFACE cxx_std_20) -if(WIN32) - target_compile_definitions(Adminive INTERFACE - WINVER=0x0A00 - _WIN32_WINNT=0x0A00 - ) - target_link_libraries(Adminive INTERFACE ws2_32) -endif() -set(adminive_server_sources - "${adminive_backend_root}/src/config_store.cpp" - "${adminive_backend_root}/src/device_tables.cpp" - "${adminive_backend_root}/src/example.cpp" - "${adminive_backend_root}/src/main.cpp" - "${adminive_backend_root}/src/server_app.cpp" -) -add_executable(Adminive_Server ${adminive_server_sources}) -target_include_directories(Adminive_Server PRIVATE "${adminive_backend_root}/src") -target_compile_definitions(Adminive_Server PRIVATE - ADMINIVE_FRONTEND_DIST_DIR="${adminive_frontend_dist_dir}" - ADMINIVE_CONFIG_FILE="${adminive_config_file}" -) -target_link_libraries(Adminive_Server PRIVATE Adminive::Adminive) -if(BUILD_TESTING) - add_executable(Adminive_Test - "${adminive_backend_root}/src/config_store.cpp" - "${adminive_backend_root}/src/device_tables.cpp" - "${adminive_backend_root}/src/example.cpp" - "${adminive_backend_root}/tests/test.cpp" - ) +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_Default INTERFACE) +add_library(Adminive::Default ALIAS Adminive_Default) +target_link_libraries(Adminive_Default INTERFACE Adminive::Http) +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) + 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::Adminive) + target_link_libraries(Adminive_Test PRIVATE Adminive::Default) add_test(NAME Adminive_Test COMMAND Adminive_Test) -endif() + 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 () diff --git a/backend/include/adminive/adapter.hpp b/backend/include/adminive/adapter.hpp new file mode 100644 index 0000000..89a0fe1 --- /dev/null +++ b/backend/include/adminive/adapter.hpp @@ -0,0 +1,154 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +namespace adminive { +template +struct Reflection_Adapter; +template +struct Json_Adapter; +template +concept Json_Type = requires(Json value, const Json const_value, std::string key, std::string_view text, std::size_t index) { + { Json_Adapter::object() } -> std::same_as; + { Json_Adapter::array() } -> std::same_as; + { Json_Adapter::parse(text) } -> std::same_as; + { Json_Adapter::dump(const_value, 2) } -> std::same_as; + { Json_Adapter::is_object(const_value) } -> std::same_as; + { Json_Adapter::is_array(const_value) } -> std::same_as; + { Json_Adapter::is_string(const_value) } -> std::same_as; + { Json_Adapter::is_number(const_value) } -> std::same_as; + { Json_Adapter::is_boolean(const_value) } -> std::same_as; + { Json_Adapter::number(const_value) } -> std::convertible_to; + { Json_Adapter::contains(const_value, text) } -> std::same_as; + { Json_Adapter::size(const_value) } -> std::convertible_to; + { Json_Adapter::keys(const_value) } -> std::same_as>; + { Json_Adapter::at(value, text) } -> std::same_as; + { Json_Adapter::at(const_value, text) } -> std::same_as; + { Json_Adapter::at(value, index) } -> std::same_as; + { Json_Adapter::at(const_value, index) } -> std::same_as; + Json_Adapter::set(value, text, Json{}); + Json_Adapter::append(value, Json{}); + Json_Adapter::erase(value, text); +}; +template +Json json_object() { + return Json_Adapter::object(); +} +template +Json json_array() { + return Json_Adapter::array(); +} +template +Json json_scalar(T&& value) { + return Json_Adapter::make(std::forward(value)); +} +template +void json_set(Json& object, std::string_view name, T&& value) { + if constexpr(std::same_as, Json>) { + Json_Adapter::set(object, name, std::forward(value)); + } else { + Json_Adapter::set(object, name, json_scalar(std::forward(value))); + } +} +template +void json_append(Json& array, T&& value) { + if constexpr(std::same_as, Json>) { + Json_Adapter::append(array, std::forward(value)); + } else { + Json_Adapter::append(array, json_scalar(std::forward(value))); + } +} +template +T json_get(const Json& value) { + return Json_Adapter::template get(value); +} +template +Json parse_json(std::string_view value) { + return Json_Adapter::parse(value); +} +template +std::string dump_json(const Json& value, int indent = -1) { + return Json_Adapter::dump(value, indent); +} +template +struct Value_Adapter { + using value_type = std::remove_cvref_t; + static const value_type& read(const value_type& value) noexcept { + return value; + } + static void write(value_type& target, value_type value) { + target = std::move(value); + } +}; +template +using Adapted_Value_Type = typename Value_Adapter, Json>::value_type; +template +concept Value_Adapter_With_Encode = requires(const std::remove_cvref_t& value) { + { Value_Adapter, Json>::encode(value) } -> std::same_as; +}; +template +concept Value_Adapter_With_Decode = requires(std::remove_cvref_t& target, const Json& value) { + Value_Adapter, Json>::decode(target, value); +}; +template +concept Value_Adapter_With_Type_Name = requires { + { Value_Adapter, Json>::type_name } -> std::convertible_to; +}; +template +concept Value_Adapter_With_Schema = requires(Json& value) { + Value_Adapter, Json>::append_schema(value); +}; +template +decltype(auto) read_adapted_value(const T& value) { + return Value_Adapter, Json>::read(value); +} +template +void write_adapted_value(T& target, Adapted_Value_Type value) { + Value_Adapter, Json>::write(target, std::move(value)); +} +template +concept Value_Adapter_With_Minimum = requires { + { Value_Adapter, Json>::minimum }; +}; +template +concept Value_Adapter_With_Maximum = requires { + { Value_Adapter, Json>::maximum }; +}; +template +concept Value_Adapter_With_Multiple_Of = requires { + { Value_Adapter, Json>::multiple_of }; +}; +template +concept Value_Adapter_With_Validation_Message = requires { + { Value_Adapter, Json>::validation_message } -> std::convertible_to; +}; +template +struct Enum_Adapter; +template +concept Enum_Type = std::is_enum_v && requires(Enum value, std::string_view name) { + { Enum_Adapter::values() }; + { Enum_Adapter::name(value) } -> std::convertible_to; + { Enum_Adapter::cast(name) } -> std::same_as>; +}; +template +auto enum_values() { + return Enum_Adapter::values(); +} +template +std::string_view enum_name(Enum value) { + return Enum_Adapter::name(value); +} +template +std::optional enum_cast(std::string_view value) { + return Enum_Adapter::cast(value); +} +template +concept Enum_Adapter_With_Underlying_Cast = Enum_Type && requires(std::underlying_type_t value) { + { Enum_Adapter::cast(value) } -> std::same_as>; +}; +} diff --git a/backend/include/adminive/adapters/magic_enum.hpp b/backend/include/adminive/adapters/magic_enum.hpp new file mode 100644 index 0000000..07bbe01 --- /dev/null +++ b/backend/include/adminive/adapters/magic_enum.hpp @@ -0,0 +1,26 @@ +#pragma once +#include "adminive/adapter.hpp" +#include "magic_enum/magic_enum.hpp" +#include +#include +#include +#include +namespace adminive { +template +requires std::is_enum_v +struct Enum_Adapter { + static std::vector values() { + const auto source = magic_enum::enum_values(); + return std::vector(source.begin(), source.end()); + } + static std::string_view name(Enum value) { + return magic_enum::enum_name(value); + } + static std::optional cast(std::string_view value) { + return magic_enum::enum_cast(value); + } + static std::optional cast(std::underlying_type_t value) { + return magic_enum::enum_cast(value); + } +}; +} diff --git a/backend/include/adminive/adapters/nlohmann_json.hpp b/backend/include/adminive/adapters/nlohmann_json.hpp new file mode 100644 index 0000000..c16f268 --- /dev/null +++ b/backend/include/adminive/adapters/nlohmann_json.hpp @@ -0,0 +1,90 @@ +#pragma once +#include "adminive/adapter.hpp" +#include "nlohmann/json.hpp" +#include +#include +#include +#include +namespace adminive { +template <> +struct Json_Adapter { + using Json = nlohmann::json; + static Json object() { + return Json::object(); + } + static Json array() { + return Json::array(); + } + static Json parse(std::string_view value) { + return Json::parse(value.begin(), value.end()); + } + static std::string dump(const Json& value, int indent) { + return value.dump(indent); + } + static bool is_object(const Json& value) noexcept { + return value.is_object(); + } + static bool is_array(const Json& value) noexcept { + return value.is_array(); + } + static bool is_string(const Json& value) noexcept { + return value.is_string(); + } + static bool is_number(const Json& value) noexcept { + return value.is_number(); + } + static bool is_boolean(const Json& value) noexcept { + return value.is_boolean(); + } + static long double number(const Json& value) { + return value.get(); + } + static bool contains(const Json& value, std::string_view name) { + return value.contains(std::string(name)); + } + static std::size_t size(const Json& value) noexcept { + return value.size(); + } + static std::vector keys(const Json& value) { + std::vector result; + result.reserve(value.size()); + for(auto iterator = value.begin(); iterator != value.end(); ++iterator) { + result.push_back(iterator.key()); + } + return result; + } + static Json& at(Json& value, std::string_view name) { + return value.at(std::string(name)); + } + static const Json& at(const Json& value, std::string_view name) { + return value.at(std::string(name)); + } + static Json& at(Json& value, std::size_t index) { + return value.at(index); + } + static const Json& at(const Json& value, std::size_t index) { + return value.at(index); + } + static void set(Json& value, std::string_view name, Json item) { + value[std::string(name)] = std::move(item); + } + static void append(Json& value, Json item) { + value.push_back(std::move(item)); + } + static void erase(Json& value, std::string_view name) { + value.erase(std::string(name)); + } + template + static Json make(T&& value) { + if constexpr(std::same_as, std::string_view>) { + return Json(std::string(value)); + } else { + return Json(std::forward(value)); + } + } + template + static T get(const Json& value) { + return value.get(); + } +}; +} diff --git a/backend/include/adminive/adminive.hpp b/backend/include/adminive/adminive.hpp index 1d88e7d..ea4cbb1 100644 --- a/backend/include/adminive/adminive.hpp +++ b/backend/include/adminive/adminive.hpp @@ -1,8 +1,8 @@ #pragma once +#include "adminive/adapter.hpp" #include "adminive/amis.hpp" #include "adminive/concepts.hpp" #include "adminive/descriptor.hpp" -#include "adminive/http.hpp" #include "adminive/json.hpp" #include "adminive/status.hpp" #include "adminive/value.hpp" diff --git a/backend/include/adminive/amis.hpp b/backend/include/adminive/amis.hpp index 4e1b2b6..364e19c 100644 --- a/backend/include/adminive/amis.hpp +++ b/backend/include/adminive/amis.hpp @@ -13,191 +13,367 @@ inline std::string make_field_name(std::string_view prefix, std::string_view nam } return std::string(prefix) + "." + std::string(name); } -inline Json make_amis_form_actions(std::string submit_label = "Apply") { - return Json::array({Json{{"type", "reset"}, {"label", "Reset"}}, Json{{"type", "submit"}, {"label", std::move(submit_label)}, {"level", "primary"}}}); +template +Json make_amis_form_actions(std::string submit_label = "Apply") { + Json reset = json_object(); + json_set(reset, "type", "reset"); + json_set(reset, "label", "Reset"); + Json submit = json_object(); + json_set(submit, "type", "submit"); + json_set(submit, "label", std::move(submit_label)); + json_set(submit, "level", "primary"); + Json result = json_array(); + json_append(result, std::move(reset)); + json_append(result, std::move(submit)); + return result; } -inline Json make_amis_control(const Json& field, std::string_view permission, std::string_view prefix = {}) { - const std::string name = field.at("name").get(); +template +Json make_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 = field.at("value_type").get(); - if(value_type == "object" && field.contains("children")) { - Json body = Json::array(); - for(const auto& child : field.at("children")) { - if(child.at("visible").get()) { - body.push_back(make_amis_control(child, permission, full_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 control{{"type", "fieldset"}, {"title", field.at("label")}, {"body", std::move(body)}, {"collapsable", true}, {"collapsed", false}}; - if(field.contains("description")) { - control["description"] = field.at("description"); + 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, "collapsable", true); + json_set(control, "collapsed", false); + if(Json_Adapter::contains(field, "description")) { + json_set(control, "description", Json_Adapter::at(field, "description")); } - if(field.contains("visible_on")) { - control["visibleOn"] = field.at("visible_on"); + if(Json_Adapter::contains(field, "visible_on")) { + json_set(control, "visibleOn", Json_Adapter::at(field, "visible_on")); } return control; } - Json control{{"name", full_name}, {"label", field.at("label")}}; - if(field.contains("description")) { - control["description"] = field.at("description"); + Json control = json_object(); + json_set(control, "name", full_name); + json_set(control, "label", Json_Adapter::at(field, "label")); + if(Json_Adapter::contains(field, "description")) { + json_set(control, "description", Json_Adapter::at(field, "description")); } - if(field.contains("visible_on")) { - control["visibleOn"] = field.at("visible_on"); + if(Json_Adapter::contains(field, "visible_on")) { + json_set(control, "visibleOn", Json_Adapter::at(field, "visible_on")); } - if(!field.at(permission).get()) { - control["type"] = "static"; + if(!json_get(Json_Adapter::at(field, permission))) { + json_set(control, "type", "static"); return control; } - if(field.contains("widget")) { - control["type"] = field.at("widget"); + if(Json_Adapter::contains(field, "widget")) { + json_set(control, "type", Json_Adapter::at(field, "widget")); } else if(value_type == "boolean") { - control["type"] = "switch"; + json_set(control, "type", "switch"); } else if(value_type == "integer" || value_type == "number") { - control["type"] = "input-number"; + json_set(control, "type", "input-number"); } else if(value_type == "enum") { - control["type"] = "select"; + json_set(control, "type", "select"); } else { - control["type"] = "input-text"; + json_set(control, "type", "input-text"); } - if(control.at("type") == "input-date") { - control["valueFormat"] = "YYYY-MM-DD"; - control["displayFormat"] = "YYYY-MM-DD"; - control["clearable"] = true; - } else if(control.at("type") == "input-color") { - control["clearable"] = true; + const std::string control_type = json_get(Json_Adapter::at(control, "type")); + if(control_type == "input-date") { + json_set(control, "valueFormat", "YYYY-MM-DD"); + json_set(control, "displayFormat", "YYYY-MM-DD"); + json_set(control, "clearable", true); + } else if(control_type == "input-color") { + json_set(control, "clearable", true); } - if(field.at("required").get()) { - control["required"] = true; + if(json_get(Json_Adapter::at(field, "required"))) { + json_set(control, "required", true); } - if(field.contains("minimum")) { - control["min"] = field.at("minimum"); + if(Json_Adapter::contains(field, "minimum")) { + json_set(control, "min", Json_Adapter::at(field, "minimum")); } - if(field.contains("maximum")) { - control["max"] = field.at("maximum"); + if(Json_Adapter::contains(field, "maximum")) { + json_set(control, "max", Json_Adapter::at(field, "maximum")); } - if(field.contains("multiple_of")) { - control["step"] = field.at("multiple_of"); + if(Json_Adapter::contains(field, "multiple_of")) { + json_set(control, "step", Json_Adapter::at(field, "multiple_of")); } - if(field.contains("validation_message")) { - control["validationErrors"] = Json{{"isInt", field.at("validation_message")}}; + if(Json_Adapter::contains(field, "validation_message")) { + Json errors = json_object(); + json_set(errors, "isInt", Json_Adapter::at(field, "validation_message")); + json_set(control, "validationErrors", std::move(errors)); } - if(field.contains("options")) { - control["options"] = field.at("options"); + if(Json_Adapter::contains(field, "options")) { + json_set(control, "options", Json_Adapter::at(field, "options")); } return control; } -inline Json make_amis_form_body(const Json& descriptor, std::string_view permission) { - Json body = Json::array(); - for(const auto& field : descriptor.at("fields")) { - if(field.at("visible").get()) { - body.push_back(make_amis_control(field, permission)); +template +Json make_amis_form_body(const Json& descriptor, std::string_view permission) { + 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)); } } return body; } -inline Json make_amis_column(const Json& field) { - Json column{{"name", field.at("name")}, {"label", field.at("list_label")}}; - if(field.contains("order")) { - column["order"] = field.at("order"); +template +Json make_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")); + if(Json_Adapter::contains(field, "order")) { + json_set(column, "order", Json_Adapter::at(field, "order")); } - if(field.contains("sortable")) { - column["sortable"] = field.at("sortable"); + if(Json_Adapter::contains(field, "sortable")) { + json_set(column, "sortable", Json_Adapter::at(field, "sortable")); } - const std::string value_type = field.at("value_type"); + const std::string value_type = json_get(Json_Adapter::at(field, "value_type")); if(value_type == "boolean") { - column["type"] = "status"; - } else if(value_type == "enum" && field.contains("options")) { - column["type"] = "mapping"; - Json map = Json::object(); - for(const auto& option : field.at("options")) { - map[option.at("value").get()] = option.at("label"); + json_set(column, "type", "status"); + } else if(value_type == "enum" && Json_Adapter::contains(field, "options")) { + json_set(column, "type", "mapping"); + Json map = json_object(); + const auto& options = Json_Adapter::at(field, "options"); + for(std::size_t index = 0; index < Json_Adapter::size(options); ++index) { + const auto& option = Json_Adapter::at(options, index); + json_set(map, json_get(Json_Adapter::at(option, "value")), Json_Adapter::at(option, "label")); } - column["map"] = std::move(map); + json_set(column, "map", std::move(map)); } return column; } -inline Json make_amis_status_service(std::string status_api) { - const Json status_view{{"type", "tpl"}, {"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}
)"}}; - const Json panel{{"type", "panel"}, {"title", "Backend Status"}, {"body", status_view}}; - return Json{{"type", "service"}, {"name", "backend_status"}, {"api", Json{{"method", "get"}, {"url", std::move(status_api)}}}, {"initFetch", true}, {"interval", 2000}, {"silentPolling", true}, {"showErrorMsg", true}, {"body", panel}}; +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; } -inline Json make_amis_object_status_body(const Json& descriptor) { - Json body = Json::array(); - for(const auto& field : descriptor.at("fields")) { - const std::string name = field.at("name").get(); - const std::string label = field.at("label").get(); - const bool color = field.at("color").get(); +template +Json make_amis_object_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) { + const auto& field = Json_Adapter::at(fields, index); + const std::string name = json_get(Json_Adapter::at(field, "name")); + const std::string label = json_get(Json_Adapter::at(field, "label")); + const bool color = json_get(Json_Adapter::at(field, "color")); std::string tpl = "
" + label + "
"; - body.push_back(Json{{"type", "tpl"}, {"tpl", std::move(tpl)}}); + Json item = json_object(); + json_set(item, "type", "tpl"); + json_set(item, "tpl", std::move(tpl)); + json_append(body, std::move(item)); } - return Json{{"type", "container"}, {"className", "adminive-object-status"}, {"body", std::move(body)}}; + Json result = json_object(); + json_set(result, "type", "container"); + json_set(result, "className", "adminive-object-status"); + json_set(result, "body", std::move(body)); + return result; } -inline Json make_amis_object_status_service(const Json& descriptor, std::string status_api, std::uint64_t interval) { - return Json{{"type", "service"}, {"api", Json{{"method", "get"}, {"url", std::move(status_api)}}}, {"initFetch", true}, {"interval", interval}, {"silentPolling", true}, {"showErrorMsg", true}, {"body", make_amis_object_status_body(descriptor)}}; +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"); + json_set(api, "url", std::move(status_api)); + Json result = json_object(); + json_set(result, "type", "service"); + 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", make_amis_object_status_body(descriptor)); + return result; } -inline Json make_amis_object_status_column(const Json& descriptor, std::string status_api, std::uint64_t interval, std::string action_label) { - const Json pop_over{{"trigger", "click"}, {"position", "left-top"}, {"showIcon", false}, {"body", make_amis_object_status_service(descriptor, std::move(status_api), interval)}}; - return Json{{"type", "tpl"}, {"label", descriptor.at("label")}, {"tpl", "" + std::move(action_label) + ""}, {"popOver", pop_over}}; +template +Json make_amis_object_status_column(const Json& descriptor, std::string status_api, std::uint64_t interval, std::string action_label) { + Json pop_over = json_object(); + json_set(pop_over, "trigger", "click"); + json_set(pop_over, "position", "left-top"); + json_set(pop_over, "showIcon", false); + json_set(pop_over, "body", make_amis_object_status_service(descriptor, std::move(status_api), interval)); + Json result = json_object(); + json_set(result, "type", "tpl"); + json_set(result, "label", Json_Adapter::at(descriptor, "label")); + json_set(result, "tpl", "" + std::move(action_label) + ""); + json_set(result, "popOver", std::move(pop_over)); + return result; } -template +template Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::string submit_label = "Apply") { - const Json descriptor = to_descriptor_json(); - Json result{{"type", "form"}, {"title", descriptor.at("label")}, {"data", to_frontend_json(value)}, {"body", make_amis_form_body(descriptor, "editable")}, {"actions", make_amis_form_actions(std::move(submit_label))}, {"affixFooter", true}}; + 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, "actions", make_amis_form_actions(std::move(submit_label))); + json_set(result, "affixFooter", true); if(!submit_api.empty()) { - result["api"] = Json{{"method", "post"}, {"url", std::move(submit_api)}}; + Json api = json_object(); + json_set(api, "method", "post"); + json_set(api, "url", std::move(submit_api)); + json_set(result, "api", std::move(api)); } return result; } -template +template Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = nullptr, std::uint64_t status_interval = 2000) { - const Json descriptor = to_descriptor_json(); - const Json& list = descriptor.at("list"); - const std::string crud_id = descriptor.at("name").get() + "_crud"; - Json columns = Json::array({Json{{"name", "id"}, {"label", list.at("id_label")}, {"sortable", true}}}); + 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"; + Json columns = json_array(); + Json id_column = json_object(); + json_set(id_column, "name", "id"); + 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; - for(const auto& field : descriptor.at("fields")) { - if(field.at("readable").get() && field.at("list_visible").get() && field.at("value_type") != "object") { + 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 left.at("order").get() < right.at("order").get(); + return json_get(Json_Adapter::at(left, "order")) < json_get(Json_Adapter::at(right, "order")); }); for(const auto& field : list_fields) { - columns.push_back(make_amis_column(field)); + json_append(columns, make_amis_column(field)); } if(status_descriptor) { - columns.push_back(make_amis_object_status_column(*status_descriptor, base_api + "/${id}/status", status_interval, list.at("view_status_label").get())); + 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")))); } - const std::string create_label = list.at("create_label").get(); - const std::string edit_label = list.at("edit_label").get(); - const Json create_form{{"type", "form"}, {"api", Json{{"method", "post"}, {"url", base_api}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "creatable")}, {"actions", make_amis_form_actions(create_label)}}; - const Json edit_form{{"type", "form"}, {"api", Json{{"method", "put"}, {"url", base_api + "/${id}"}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "editable")}, {"actions", make_amis_form_actions(list.at("confirm_label").get())}}; - const Json create_button{{"type", "button"}, {"label", create_label}, {"level", "primary"}, {"actionType", "dialog"}, {"dialog", Json{{"title", create_label + " " + descriptor.at("label").get()}, {"body", create_form}}}}; - Json actions = Json::array(); - actions.push_back(Json{{"type", "button"}, {"label", edit_label}, {"level", "link"}, {"actionType", "dialog"}, {"dialog", Json{{"title", edit_label + " " + descriptor.at("label").get()}, {"body", edit_form}}}}); - actions.push_back(Json{{"type", "button"}, {"label", list.at("delete_label")}, {"level", "link"}, {"className", "text-danger"}, {"actionType", "ajax"}, {"confirmText", list.at("delete_confirm")}, {"api", Json{{"method", "delete"}, {"url", base_api + "/${id}"}}}, {"reload", crud_id}}); - columns.push_back(Json{{"type", "operation"}, {"label", list.at("actions_label")}, {"buttons", std::move(actions)}}); - const Json columns_toggler{{"type", "columns-toggler"}, {"draggable", list.at("column_reorderable")}}; - Json crud{{"type", "crud"}, {"id", crud_id}, {"api", Json{{"method", "get"}, {"url", base_api}}}, {"syncLocation", false}, {"primaryField", "id"}, {"headerToolbar", Json::array({columns_toggler, "reload", create_button})}, {"columns", std::move(columns)}}; - const std::string default_order_by = list.at("default_order_by").get(); + const std::string create_label = json_get(Json_Adapter::at(list, "create_label")); + const std::string edit_label = json_get(Json_Adapter::at(list, "edit_label")); + Json create_api = json_object(); + json_set(create_api, "method", "post"); + json_set(create_api, "url", base_api); + Json create_form = json_object(); + 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, "actions", make_amis_form_actions(create_label)); + Json edit_api = json_object(); + json_set(edit_api, "method", "put"); + json_set(edit_api, "url", base_api + "/${id}"); + Json edit_form = json_object(); + 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, "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"))); + json_set(create_dialog, "body", std::move(create_form)); + Json create_button = json_object(); + json_set(create_button, "type", "button"); + json_set(create_button, "label", create_label); + json_set(create_button, "level", "primary"); + json_set(create_button, "actionType", "dialog"); + json_set(create_button, "dialog", std::move(create_dialog)); + Json edit_dialog = json_object(); + json_set(edit_dialog, "title", edit_label + " " + json_get(Json_Adapter::at(descriptor, "label"))); + json_set(edit_dialog, "body", std::move(edit_form)); + Json edit_button = json_object(); + json_set(edit_button, "type", "button"); + json_set(edit_button, "label", edit_label); + json_set(edit_button, "level", "link"); + json_set(edit_button, "actionType", "dialog"); + json_set(edit_button, "dialog", std::move(edit_dialog)); + Json delete_api = json_object(); + json_set(delete_api, "method", "delete"); + json_set(delete_api, "url", base_api + "/${id}"); + Json delete_button = json_object(); + json_set(delete_button, "type", "button"); + json_set(delete_button, "label", Json_Adapter::at(list, "delete_label")); + json_set(delete_button, "level", "link"); + json_set(delete_button, "className", "text-danger"); + json_set(delete_button, "actionType", "ajax"); + json_set(delete_button, "confirmText", Json_Adapter::at(list, "delete_confirm")); + json_set(delete_button, "api", std::move(delete_api)); + json_set(delete_button, "reload", crud_id); + Json buttons = json_array(); + json_append(buttons, std::move(edit_button)); + json_append(buttons, std::move(delete_button)); + Json operation = json_object(); + json_set(operation, "type", "operation"); + json_set(operation, "label", Json_Adapter::at(list, "actions_label")); + json_set(operation, "buttons", std::move(buttons)); + json_append(columns, std::move(operation)); + Json toggler = json_object(); + json_set(toggler, "type", "columns-toggler"); + json_set(toggler, "draggable", Json_Adapter::at(list, "column_reorderable")); + Json toolbar = json_array(); + json_append(toolbar, std::move(toggler)); + json_append(toolbar, "reload"); + json_append(toolbar, std::move(create_button)); + Json crud_api = json_object(); + json_set(crud_api, "method", "get"); + json_set(crud_api, "url", base_api); + Json crud = json_object(); + json_set(crud, "type", "crud"); + json_set(crud, "id", crud_id); + json_set(crud, "api", std::move(crud_api)); + json_set(crud, "syncLocation", false); + json_set(crud, "primaryField", "id"); + json_set(crud, "headerToolbar", std::move(toolbar)); + json_set(crud, "columns", std::move(columns)); + const std::string default_order_by = json_get(Json_Adapter::at(list, "default_order_by")); if(!default_order_by.empty()) { - crud["defaultParams"] = Json{{"orderBy", default_order_by}, {"orderDir", list.at("default_order_dir")}}; + Json params = json_object(); + json_set(params, "orderBy", default_order_by); + json_set(params, "orderDir", Json_Adapter::at(list, "default_order_dir")); + json_set(crud, "defaultParams", std::move(params)); } - if(list.at("user_reorderable").get()) { - crud["draggable"] = true; - crud["saveOrderApi"] = Json{{"method", "post"}, {"url", base_api + "/order"}}; + if(json_get(Json_Adapter::at(list, "user_reorderable"))) { + json_set(crud, "draggable", true); + Json order_api = json_object(); + json_set(order_api, "method", "post"); + json_set(order_api, "url", base_api + "/order"); + json_set(crud, "saveOrderApi", std::move(order_api)); } - return Json{{"type", "page"}, {"title", list.at("management_label")}, {"body", std::move(crud)}}; + Json result = json_object(); + json_set(result, "type", "page"); + json_set(result, "title", Json_Adapter::at(list, "management_label")); + json_set(result, "body", std::move(crud)); + return result; } -template +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) { - Json result = to_amis_crud_schema(std::move(base_api), object_status_descriptor, object_status_interval); - Json crud = std::move(result.at("body")); - result["body"] = Json::array({make_amis_status_service(std::move(status_api)), std::move(crud)}); + 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, std::move(crud)); + json_set(result, "body", std::move(body)); return result; } } diff --git a/backend/include/adminive/descriptor.hpp b/backend/include/adminive/descriptor.hpp index 1b894e8..fd08ea1 100644 --- a/backend/include/adminive/descriptor.hpp +++ b/backend/include/adminive/descriptor.hpp @@ -1,6 +1,6 @@ #pragma once +#include "adminive/adapter.hpp" #include "adminive/concepts.hpp" -#include "magic_enum/magic_enum.hpp" #include #include #include @@ -17,6 +17,10 @@ template concept Described_Type = requires { { Type_Descriptor>::get() }; }; +template +concept Reflected_Type = requires { + { Reflection_Adapter>::field_count } -> std::convertible_to; +}; template struct Member_Pointer_Traits; template @@ -25,12 +29,40 @@ struct Member_Pointer_Traits { using member_type = Member_Type; static constexpr auto value = Member; }; +template +struct Member_Field_Accessor { + using owner_type = typename Member_Pointer_Traits::owner_type; + using member_type = typename Member_Pointer_Traits::member_type; + template + static decltype(auto) get(Object& object) noexcept { + return object.*Member; + } + template + static decltype(auto) get(const Object& object) noexcept { + return object.*Member; + } +}; +template +struct Reflected_Field_Accessor { + using owner_type = T; + using reference_type = decltype(Reflection_Adapter::template get(std::declval())); + using const_reference_type = decltype(Reflection_Adapter::template get(std::declval())); + using member_type = std::remove_cvref_t; + static_assert(std::is_lvalue_reference_v); + static_assert(std::is_lvalue_reference_v); + static decltype(auto) get(T& object) noexcept(noexcept(Reflection_Adapter::template get(object))) { + return Reflection_Adapter::template get(object); + } + static decltype(auto) get(const T& object) noexcept(noexcept(Reflection_Adapter::template get(object))) { + return Reflection_Adapter::template get(object); + } +}; inline std::string make_label(std::string_view name) { std::string result; result.reserve(name.size()); bool uppercase = true; - for(const char value : name) { - if(value == '_') { + for (const char value : name) { + if (value == '_') { result.push_back(' '); uppercase = true; continue; @@ -40,90 +72,104 @@ inline std::string make_label(std::string_view name) { } return result; } -template -class Field_Descriptor { +template +class Field_Descriptor_Base { public: - using owner_type = typename Member_Pointer_Traits::owner_type; - using member_type = typename Member_Pointer_Traits::member_type; - static constexpr auto member = Member; - explicit Field_Descriptor(std::string name) : name_(std::move(name)), label_(make_label(name_)) {} - Field_Descriptor(std::string name, std::string label) : name_(std::move(name)), label_(std::move(label)) {} - Field_Descriptor editable(bool value = true) const { - auto result = *this; + using owner_type = typename Accessor::owner_type; + using member_type = typename Accessor::member_type; + explicit Field_Descriptor_Base(std::string name) : name_(std::move(name)), label_(make_label(name_)) {} + Field_Descriptor_Base(std::string name, std::string label) : name_(std::move(name)), label_(std::move(label)) {} + Derived editable(bool value = true) const { + auto result = derived(); result.editable_ = value; return result; } - Field_Descriptor creatable(bool value = true) const { - auto result = *this; + Derived creatable(bool value = true) const { + auto result = derived(); result.creatable_ = value; return result; } - Field_Descriptor readable(bool value = true) const { - auto result = *this; + Derived readable(bool value = true) const { + auto result = derived(); result.readable_ = value; return result; } - Field_Descriptor visible(bool value = true) const { - auto result = *this; + Derived visible(bool value = true) const { + auto result = derived(); result.visible_ = value; return result; } - Field_Descriptor required(bool value = true) const { - auto result = *this; + Derived required(bool value = true) const { + auto result = derived(); result.required_ = value; return result; } - Field_Descriptor list_visible(bool value = true) const { - auto result = *this; + Derived list_visible(bool value = true) const { + auto result = derived(); result.list_visible_ = value; return result; } - Field_Descriptor label(std::string value) const { - auto result = *this; + Derived label(std::string value) const { + auto result = derived(); result.label_ = std::move(value); return result; } - Field_Descriptor description(std::string value) const { - auto result = *this; + Derived description(std::string value) const { + auto result = derived(); result.description_ = std::move(value); return result; } - Field_Descriptor widget(std::string value) const { - auto result = *this; + Derived widget(std::string value) const { + auto result = derived(); result.widget_ = std::move(value); return result; } - Field_Descriptor visible_on(std::string value) const { - auto result = *this; + Derived visible_on(std::string value) const { + auto result = derived(); result.visible_on_ = std::move(value); return result; } - Field_Descriptor list_label(std::string value) const { - auto result = *this; + Derived list_label(std::string value) const { + auto result = derived(); result.list_label_ = std::move(value); return result; } - Field_Descriptor order(int value) const { - auto result = *this; + Derived order(int value) const { + auto result = derived(); result.order_ = value; return result; } - Field_Descriptor sortable(bool value = true) const { - auto result = *this; + Derived sortable(bool value = true) const { + auto result = derived(); result.sortable_ = value; return result; } template - requires std::is_enum_v && std::same_as - Field_Descriptor enum_label(std::string label) const { - const auto name = magic_enum::enum_name(Value); - if(name.empty()) { - throw std::invalid_argument("enum value is outside the magic_enum reflection range"); + requires Enum_Type> + Derived enum_label(std::string label) const { + const auto name = enum_name(Value); + if (name.empty()) { + throw std::invalid_argument("enum adapter returned an empty value name"); } - auto result = *this; + auto result = derived(); result.enum_labels_.insert_or_assign(std::string(name), std::move(label)); return result; } + Derived enum_label(std::string value_name, std::string label) const { + auto result = derived(); + result.enum_labels_.insert_or_assign(std::move(value_name), std::move(label)); + return result; + } + template + requires std::convertible_to*, owner_type*> + decltype(auto) get(Object& object) const noexcept(noexcept(Accessor::get(object))) { + return Accessor::get(object); + } + template + requires std::convertible_to*, const owner_type*> + decltype(auto) get(const Object& object) const noexcept(noexcept(Accessor::get(object))) { + return Accessor::get(object); + } const std::string& name() const noexcept { return name_; } @@ -169,6 +215,10 @@ public: bool is_list_visible() const noexcept { return list_visible_; } +protected: + const Derived& derived() const noexcept { + return static_cast(*this); + } private: std::string name_; std::string label_; @@ -186,10 +236,26 @@ private: int order_{-1}; bool sortable_{}; }; +template +class Field_Descriptor : public Field_Descriptor_Base, Member_Field_Accessor> { +public: + using Base = Field_Descriptor_Base, Member_Field_Accessor>; + using Base::Base; + static constexpr auto member = Member; +}; +template +class Reflected_Field_Descriptor : public Field_Descriptor_Base, Reflected_Field_Accessor> { +public: + using Base = Field_Descriptor_Base, Reflected_Field_Accessor>; + using Base::Base; + static constexpr std::size_t index = Index; +}; template struct Is_Field_Descriptor : std::false_type {}; template struct Is_Field_Descriptor> : std::true_type {}; +template +struct Is_Field_Descriptor> : std::true_type {}; template concept Field_Descriptor_Type = Is_Field_Descriptor>::value; template @@ -200,6 +266,16 @@ template auto field(std::string name, std::string label) { return Field_Descriptor(std::move(name), std::move(label)); } +template +auto reflected_field() { + static_assert(Index < static_cast(Reflection_Adapter::field_count)); + return Reflected_Field_Descriptor(std::string(Reflection_Adapter::template name())); +} +template +auto reflected_field(std::string label) { + static_assert(Index < static_cast(Reflection_Adapter::field_count)); + return Reflected_Field_Descriptor(std::string(Reflection_Adapter::template name()), std::move(label)); +} struct No_Object_Validator { template void operator()(const T&) const noexcept {} @@ -212,7 +288,7 @@ struct Object_List_Options { std::string actions_label{"Actions"}; std::string confirm_label{"Confirm Changes"}; std::string delete_confirm{"Delete this record?"}; - std::string view_status_label{"View status ▾"}; + std::string view_status_label{"View status"}; std::string management_label; std::string default_order_by; std::string default_order_dir{"asc"}; @@ -319,15 +395,42 @@ private: }; template auto object(std::string name, Fields... fields) { - static_assert((std::same_as && ...)); + static_assert((std::convertible_to && ...)); const auto label = make_label(name); return Object_Descriptor(std::move(name), label, No_Object_Validator{}, std::tuple(std::move(fields)...)); } template auto object(std::string name, std::string label, Fields... fields) { - static_assert((std::same_as && ...)); + static_assert((std::convertible_to && ...)); return Object_Descriptor(std::move(name), std::move(label), No_Object_Validator{}, std::tuple(std::move(fields)...)); } +struct Identity_Field_Customizer { + template + auto operator()(Field value) const { + return value; + } +}; +template +auto reflected_object_with_impl(std::string name, std::string label, Customizer customizer, std::index_sequence) { + return object(std::move(name), std::move(label), customizer.template operator()(reflected_field())...); +} +template +auto reflected_object_with(std::string name, Customizer customizer) { + const auto label = make_label(name); + return reflected_object_with_impl(std::move(name), label, std::move(customizer), std::make_index_sequence(Reflection_Adapter::field_count)>{}); +} +template +auto reflected_object_with(std::string name, std::string label, Customizer customizer) { + return reflected_object_with_impl(std::move(name), std::move(label), std::move(customizer), std::make_index_sequence(Reflection_Adapter::field_count)>{}); +} +template +auto reflected_object(std::string name) { + return reflected_object_with(std::move(name), Identity_Field_Customizer{}); +} +template +auto reflected_object(std::string name, std::string label) { + return reflected_object_with(std::move(name), std::move(label), Identity_Field_Customizer{}); +} template auto describe() { return Type_Descriptor>::get(); @@ -335,3 +438,5 @@ auto describe() { } #define ADMINIVE_FIELD(Type, Member) ::adminive::field<&Type::Member>(#Member) #define ADMINIVE_FIELD_LABEL(Type, Member, Label) ::adminive::field<&Type::Member>(#Member, Label) +#define ADMINIVE_REFLECTED_FIELD(Type, Index) ::adminive::reflected_field() +#define ADMINIVE_REFLECTED_FIELD_LABEL(Type, Index, Label) ::adminive::reflected_field(Label) diff --git a/backend/include/adminive/http.hpp b/backend/include/adminive/http.hpp index 8513360..8c5917f 100644 --- a/backend/include/adminive/http.hpp +++ b/backend/include/adminive/http.hpp @@ -13,14 +13,30 @@ #include #include namespace adminive { -inline void write_http_json(httplib::Response& response, const Json& value) { - response.set_content(value.dump(2), "application/json; charset=utf-8"); +template +void write_http_json(httplib::Response& response, const Json& value) { + response.set_content(dump_json(value, 2), "application/json; charset=utf-8"); } -inline void write_http_error(httplib::Response& response, int status, const Update_Result& result) { +template +Json make_http_result(int status, std::string message, Data&& data) { + Json result = json_object(); + json_set(result, "status", status); + json_set(result, "msg", std::move(message)); + json_set(result, "data", std::forward(data)); + return result; +} +template +void write_http_error(httplib::Response& response, int status, const Update_Result& update) { response.status = status; - write_http_json(response, Json{{"status", status}, {"msg", result.message}, {"data", Json::object()}, {"errors", result.field_errors}}); + Json result = make_http_result(status, update.message, json_object()); + Json errors = json_object(); + for(const auto& [name, message] : update.field_errors) { + json_set(errors, name, message); + } + json_set(result, "errors", std::move(errors)); + write_http_json(response, result); } -template +template requires std::copy_constructible && std::assignable_from class Http_Resource { public: @@ -28,37 +44,37 @@ public: 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) {} 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, Json{{"status", 0}, {"msg", ""}, {"data", to_descriptor_json()}}); + 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, Json{{"status", 0}, {"msg", ""}, {"data", to_frontend_json(value_)}}); + 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, Json{{"status", 0}, {"msg", ""}, {"data", amis_schema()}}); + 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 = Json::parse(request.body); + const Json patch = parse_json(request.body); std::scoped_lock lock(resource_mutex()); T candidate = value_; - const auto result = apply_frontend_patch(candidate, patch); + const auto result = apply_frontend_patch(candidate, patch); if(!result.success) { - write_http_error(response, 422, result); + write_http_error(response, 422, result); return; } if(commit_) { commit_(candidate); } value_ = std::move(candidate); - write_http_json(response, Json{{"status", 0}, {"msg", result.message}, {"data", to_frontend_json(value_)}}); + 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, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); + write_http_json(response, make_http_result(400, error.what(), json_object())); } }); } @@ -72,7 +88,7 @@ private: std::mutex* shared_mutex_{}; mutable std::mutex mutex_; }; -template +template requires std::default_initializable && std::copy_constructible && std::assignable_from class Http_Collection_Resource { public: @@ -83,16 +99,15 @@ public: } template void register_status(std::uint64_t interval = 2000) { - static_assert(std::is_member_function_pointer_v); - using Traits = Member_Function_Traits; - using Owner = typename Traits::owner_type; - using Status = std::remove_cvref_t; - static_assert(std::same_as); + 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_descriptor_ = to_status_descriptor_json(); status_interval_ = interval; status_reader_ = [](T& value) { - return to_status_json(std::invoke(Function, value)); + return to_status_json(std::invoke(Function, value)); }; } std::size_t size() const { @@ -101,29 +116,29 @@ public: } Json items_json() const { std::scoped_lock lock(mutex_); - Json items = Json::array(); + Json items = json_array(); for(const auto& entry : entries_) { - items.push_back(encode_entry(entry)); + 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_schema(path_, descriptor, status_interval_); } - return to_amis_crud_status_schema(path_, overview_status_api_, 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, Json{{"status", 0}, {"msg", ""}, {"data", to_descriptor_json()}}); + 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, Json{{"status", 0}, {"msg", ""}, {"data", amis_schema()}}); + 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, Json{{"status", 0}, {"msg", ""}, {"data", status_descriptor_}}); + 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); @@ -133,7 +148,7 @@ public: write_not_found(response); return; } - write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", status_reader_(iterator->value)}}); + write_http_json(response, make_http_result(0, "", status_reader_(iterator->value))); }); } server.Get(path_, [this](const httplib::Request& request, httplib::Response& response) { @@ -148,11 +163,14 @@ public: 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(); + Json items = json_array(); for(std::size_t index = offset; index < end; ++index) { - items.push_back(encode_entry(*ordered_entries[index])); + json_append(items, encode_entry(*ordered_entries[index])); } - write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", Json{{"items", std::move(items)}, {"total", ordered_entries.size()}}}}); + 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); @@ -162,37 +180,37 @@ public: write_not_found(response); return; } - write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", encode_entry(*iterator)}}); + 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 = Json::parse(request.body); - input.erase("id"); + Json input = parse_json(request.body); + Json_Adapter::erase(input, "id"); T value{}; - const auto result = apply_frontend_create(value, input); + const auto result = apply_frontend_create(value, input); if(!result.success) { - write_http_error(response, 422, result); + 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, Json{{"status", 0}, {"msg", "created"}, {"data", encode_entry(entries_.back())}}); + 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, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); + 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 = Json::parse(request.body); - const auto ids = read_order_ids(input.at("ids")); + 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, Json{{"status", 0}, {"msg", "reordered"}, {"data", Json::object()}}); + write_http_json(response, make_http_result(0, "reordered", json_object())); } catch(const std::exception& error) { response.status = 400; - write_http_json(response, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); + write_http_json(response, make_http_result(400, error.what(), json_object())); } }); } @@ -211,7 +229,7 @@ public: return; } entries_.erase(iterator); - write_http_json(response, Json{{"status", 0}, {"msg", "deleted"}, {"data", Json::object()}}); + write_http_json(response, make_http_result(0, "deleted", json_object())); }); } private: @@ -233,23 +251,23 @@ private: return value; } static int compare_json_values(const Json& left, const Json& right) { - if(left.is_number() && right.is_number()) { - const long double left_value = left.get(); - const long double right_value = right.get(); + 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(left.is_string() && right.is_string()) { - const auto& left_value = left.get_ref(); - const auto& right_value = right.get_ref(); + 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(left.is_boolean() && right.is_boolean()) { - const bool left_value = left.get(); - const bool right_value = right.get(); + 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 = left.dump(); - const std::string right_value = right.dump(); + 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) { @@ -273,13 +291,14 @@ private: } static std::vector read_order_ids(const Json& value) { std::vector result; - if(value.is_array()) { - for(const auto& item : value) { - result.push_back(item.is_string() ? std::stoull(item.get()) : item.get()); + 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; } - std::string text = value.get(); + const std::string text = json_get(value); std::size_t begin{}; while(begin < text.size()) { const auto end = text.find(',', begin); @@ -308,7 +327,9 @@ private: if(order_by == "id") { comparison = left->id < right->id ? -1 : left->id > right->id ? 1 : 0; } else { - comparison = compare_json_values(to_frontend_json(left->value).at(order_by), to_frontend_json(right->value).at(order_by)); + 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; }); @@ -325,13 +346,13 @@ private: }); } static Json encode_entry(const Entry& entry) { - Json result = to_frontend_json(entry.value); - result["id"] = entry.id; + 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, Json{{"status", 404}, {"msg", "record not found"}, {"data", Json::object()}}); + 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_); @@ -353,8 +374,8 @@ private: } void update(const httplib::Request& request, httplib::Response& response) { try { - Json patch = Json::parse(request.body); - patch.erase("id"); + 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); @@ -362,20 +383,20 @@ private: write_not_found(response); return; } - const auto result = apply_frontend_patch(iterator->value, patch); + const auto result = apply_frontend_patch(iterator->value, patch); if(!result.success) { - write_http_error(response, 422, result); + write_http_error(response, 422, result); return; } - write_http_json(response, Json{{"status", 0}, {"msg", result.message}, {"data", encode_entry(*iterator)}}); + 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, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); + write_http_json(response, make_http_result(400, error.what(), json_object())); } } std::string path_; std::string overview_status_api_; - Json status_descriptor_; + Json status_descriptor_{}; std::function status_reader_; std::uint64_t status_interval_{2000}; std::vector entries_; diff --git a/backend/include/adminive/json.hpp b/backend/include/adminive/json.hpp index fcc28ba..fa41671 100644 --- a/backend/include/adminive/json.hpp +++ b/backend/include/adminive/json.hpp @@ -1,20 +1,28 @@ #pragma once +#include "adminive/adapter.hpp" #include "adminive/descriptor.hpp" -#include "magic_enum/magic_enum.hpp" -#include "nlohmann/json.hpp" +#include #include #include #include #include #include namespace adminive { -using Json = nlohmann::json; struct Update_Result { bool success{}; std::string message; std::map field_errors; + template Json to_json() const { - return Json{{"success", success}, {"message", message}, {"field_errors", field_errors}}; + Json result = json_object(); + json_set(result, "success", success); + json_set(result, "message", message); + Json errors = json_object(); + for(const auto& [name, error] : field_errors) { + json_set(errors, name, error); + } + json_set(result, "field_errors", std::move(errors)); + return result; } }; class Json_Assignment_Error : public std::invalid_argument { @@ -31,281 +39,329 @@ enum class Write_Mode { create, update }; -template +template Json to_json(const T& value); -template +template Json to_frontend_json(const T& value); -template +template requires std::default_initializable Json to_descriptor_json(); -template +template requires std::default_initializable && std::copy_constructible && std::assignable_from Update_Result assign_json(T& target, const Json& value); -template +template void assign_json_value(T& target, const Json& value); -template +template Json encode_json_value(const T& value) { - using Value = std::remove_cvref_t; - if constexpr (Range_Value_Type || Validated_Value_Type) { - return encode_json_value(value.value()); - } else if constexpr (std::is_enum_v) { - const auto name = magic_enum::enum_name(value); - if (!name.empty()) { - return std::string(name); + using Storage = std::remove_cvref_t; + using Value = Adapted_Value_Type; + if constexpr(Value_Adapter_With_Encode) { + return Value_Adapter::encode(value); + } else if constexpr(!std::same_as) { + return encode_json_value(read_adapted_value(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); + if(!name.empty()) { + return json_scalar(std::string(name)); } - return static_cast>(value); - } else if constexpr (Described_Type) { - return to_json(value); - } else if constexpr (String_Key_Map_Type) { - Json result = Json::object(); - for (const auto& [key, item] : value) { - result[std::string(key)] = encode_json_value(item); + return json_scalar(static_cast>(value)); + } else if constexpr(Described_Type) { + return to_json(value); + } else if constexpr(String_Key_Map_Type) { + Json result = json_object(); + for(const auto& [key, item] : value) { + json_set(result, std::string(key), encode_json_value(item)); } return result; - } else if constexpr (Sequence_Type) { - Json result = Json::array(); - for (const auto& item : value) { - result.push_back(encode_json_value(item)); + } else if constexpr(Sequence_Type) { + Json result = json_array(); + for(const auto& item : value) { + json_append(result, encode_json_value(item)); } return result; } else { - return value; + return json_scalar(value); } } -template +template void assign_json_value(T& target, const Json& value) { - using Value = std::remove_cvref_t; - if constexpr (Range_Value_Type || Validated_Value_Type) { - using Inner = typename Value::value_type; - target = value.get(); - } else if constexpr (std::is_enum_v) { - if (value.is_string()) { - const auto parsed = magic_enum::enum_cast(value.get()); - if (!parsed) { - throw std::invalid_argument("unknown enum value"); - } - target = *parsed; - } else { - const auto parsed = magic_enum::enum_cast(value.get>()); - if (!parsed) { - throw std::invalid_argument("unknown enum value"); - } - target = *parsed; + using Storage = std::remove_cvref_t; + using Value = Adapted_Value_Type; + if constexpr(Value_Adapter_With_Decode) { + Value_Adapter::decode(target, value); + } else if constexpr(!std::same_as) { + Value parsed{}; + assign_json_value(parsed, value); + write_adapted_value(target, std::move(parsed)); + } else if constexpr(std::is_enum_v) { + static_assert(Enum_Type, "enum type requires an adminive::Enum_Adapter specialization"); + std::optional parsed; + if(Json_Adapter::is_string(value)) { + parsed = enum_cast(json_get(value)); + } else if constexpr(Enum_Adapter_With_Underlying_Cast) { + parsed = Enum_Adapter::cast(json_get>(value)); } - } else if constexpr (Described_Type) { - const auto result = assign_json(target, value); - if (!result.success) { + if(!parsed) { + throw std::invalid_argument("unknown enum value"); + } + target = *parsed; + } else if constexpr(Described_Type) { + const auto result = assign_json(target, value); + if(!result.success) { throw Json_Assignment_Error(result); } - } else if constexpr (String_Key_Map_Type) { - if (!value.is_object()) { + } else if constexpr(String_Key_Map_Type) { + if(!Json_Adapter::is_object(value)) { throw std::invalid_argument("value must be a JSON object"); } Value parsed; - for (auto iterator = value.begin(); iterator != value.end(); ++iterator) { + for(const auto& key : Json_Adapter::keys(value)) { typename Value::mapped_type item{}; - assign_json_value(item, iterator.value()); - parsed.emplace(iterator.key(), std::move(item)); + assign_json_value(item, Json_Adapter::at(value, key)); + parsed.emplace(key, std::move(item)); } target = std::move(parsed); - } else if constexpr (Sequence_Type) { - if (!value.is_array()) { + } else if constexpr(Sequence_Type) { + if(!Json_Adapter::is_array(value)) { throw std::invalid_argument("value must be a JSON array"); } Value parsed; - for (const auto& source : value) { + for(std::size_t index = 0; index < Json_Adapter::size(value); ++index) { typename Value::value_type item{}; - assign_json_value(item, source); + assign_json_value(item, Json_Adapter::at(value, index)); parsed.push_back(std::move(item)); } target = std::move(parsed); } else { - target = value.get(); + target = json_get(value); } } -template +template std::string value_type_name() { - using Value = Unwrapped_Value_Type; - if constexpr (std::is_enum_v) { + using Storage = std::remove_cvref_t; + using Value = Adapted_Value_Type; + if constexpr(Value_Adapter_With_Type_Name) { + return std::string(Value_Adapter::type_name); + } else if constexpr(std::is_enum_v) { return "enum"; - } else if constexpr (std::same_as) { + } else if constexpr(std::same_as) { return "boolean"; - } else if constexpr (std::integral) { + } else if constexpr(std::integral) { return "integer"; - } else if constexpr (std::floating_point) { + } else if constexpr(std::floating_point) { return "number"; - } else if constexpr (String_Type) { + } else if constexpr(String_Type) { return "string"; - } else if constexpr (Described_Type) { + } else if constexpr(Described_Type) { return "object"; - } else if constexpr (String_Key_Map_Type) { + } else if constexpr(String_Key_Map_Type) { return "map"; - } else if constexpr (Sequence_Type) { + } else if constexpr(Sequence_Type) { return "array"; } else { return "unknown"; } } -template +template void append_constraints(Json& result) { - using Value = std::remove_cvref_t; - if constexpr (Range_Value_Type) { - result["minimum"] = Value::minimum; - result["maximum"] = Value::maximum; - append_constraints(result); - } else if constexpr (Validated_Value_Type) { - using Validator = typename Value::validator_type; - if constexpr (Multiple_Of_Validator) { - result["multiple_of"] = Validator::multiple_of; - } - if constexpr (Validator_With_Message) { - result["validation_message"] = std::string(Validator::message); - } - append_constraints(result); + using Storage = std::remove_cvref_t; + using Value = Adapted_Value_Type; + if constexpr(Value_Adapter_With_Minimum) { + json_set(result, "minimum", Value_Adapter::minimum); + } + if constexpr(Value_Adapter_With_Maximum) { + json_set(result, "maximum", Value_Adapter::maximum); + } + if constexpr(Value_Adapter_With_Multiple_Of) { + json_set(result, "multiple_of", Value_Adapter::multiple_of); + } + if constexpr(Value_Adapter_With_Validation_Message) { + json_set(result, "validation_message", std::string(Value_Adapter::validation_message)); + } + if constexpr(!std::same_as) { + append_constraints(result); + } + if constexpr(Value_Adapter_With_Schema) { + Value_Adapter::append_schema(result); } } -template +template Json enum_options(const std::map& labels) { - using Value = Unwrapped_Value_Type; - Json result = Json::array(); + using Value = Adapted_Value_Type; + Json result = json_array(); if constexpr(std::is_enum_v) { - for(const auto value : magic_enum::enum_values()) { - const std::string name(magic_enum::enum_name(value)); + static_assert(Enum_Type, "enum type requires an adminive::Enum_Adapter specialization"); + for(const auto value : enum_values()) { + const std::string name(enum_name(value)); const auto iterator = labels.find(name); - result.push_back(Json{{"label", iterator == labels.end() ? make_label(name) : iterator->second}, {"value", name}}); + Json option = json_object(); + json_set(option, "label", iterator == labels.end() ? make_label(name) : iterator->second); + json_set(option, "value", name); + json_append(result, std::move(option)); } } return result; } -template +template requires std::default_initializable Json to_descriptor_json() { const auto descriptor = describe(); T defaults{}; - Json fields = Json::array(); + Json fields = json_array(); int default_order{}; 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{{"name", item.name()}, {"label", item.label()}, {"list_label", item.list_label()}, {"value_type", value_type_name()}, {"readable", item.is_readable()}, {"editable", item.is_editable()}, {"creatable", item.is_creatable()}, {"visible", item.is_visible()}, {"required", item.is_required()}, {"list_visible", item.is_list_visible()}, {"order", order}, {"sortable", item.is_sortable()}, {"default", encode_json_value(defaults.*Field::member)}}; - if (!item.description().empty()) { - field_json["description"] = item.description(); + Json field_json = json_object(); + json_set(field_json, "name", item.name()); + json_set(field_json, "label", item.label()); + json_set(field_json, "list_label", item.list_label()); + json_set(field_json, "value_type", value_type_name()); + json_set(field_json, "readable", item.is_readable()); + json_set(field_json, "editable", item.is_editable()); + json_set(field_json, "creatable", item.is_creatable()); + json_set(field_json, "visible", item.is_visible()); + json_set(field_json, "required", item.is_required()); + json_set(field_json, "list_visible", item.is_list_visible()); + json_set(field_json, "order", order); + json_set(field_json, "sortable", item.is_sortable()); + json_set(field_json, "default", encode_json_value(item.get(defaults))); + if(!item.description().empty()) { + json_set(field_json, "description", item.description()); } if(!item.widget().empty()) { - field_json["widget"] = item.widget(); + json_set(field_json, "widget", item.widget()); } if(!item.visible_on().empty()) { - field_json["visible_on"] = item.visible_on(); + json_set(field_json, "visible_on", item.visible_on()); } - if constexpr(Described_Type) { - field_json["children"] = to_descriptor_json().at("fields"); + if constexpr(Described_Type) { + json_set(field_json, "children", Json_Adapter::at(to_descriptor_json(), "fields")); } - append_constraints(field_json); - const auto options = enum_options(item.enum_labels()); - if (!options.empty()) { - field_json["options"] = options; + append_constraints(field_json); + auto options = enum_options(item.enum_labels()); + if(Json_Adapter::size(options) != 0) { + json_set(field_json, "options", std::move(options)); } - fields.push_back(std::move(field_json)); + json_append(fields, std::move(field_json)); }(), ...); }, descriptor.fields()); - const auto& list_options = descriptor.list_options(); - Json list{{"id_label", list_options.id_label}, {"create_label", list_options.create_label}, {"edit_label", list_options.edit_label}, {"delete_label", list_options.delete_label}, {"actions_label", list_options.actions_label}, {"confirm_label", list_options.confirm_label}, {"delete_confirm", list_options.delete_confirm}, {"view_status_label", list_options.view_status_label}, {"management_label", list_options.management_label.empty() ? descriptor.label() + " Management" : list_options.management_label}, {"default_order_by", list_options.default_order_by}, {"default_order_dir", list_options.default_order_dir}, {"user_reorderable", list_options.user_reorderable_}, {"column_reorderable", list_options.column_reorderable_}}; - return Json{{"protocol", "adminive.resource"}, {"protocol_version", 1}, {"name", descriptor.name()}, {"label", descriptor.label()}, {"value_type", "object"}, {"list", std::move(list)}, {"fields", std::move(fields)}}; + const auto& options = descriptor.list_options(); + Json list = json_object(); + json_set(list, "id_label", options.id_label); + json_set(list, "create_label", options.create_label); + json_set(list, "edit_label", options.edit_label); + json_set(list, "delete_label", options.delete_label); + json_set(list, "actions_label", options.actions_label); + json_set(list, "confirm_label", options.confirm_label); + json_set(list, "delete_confirm", options.delete_confirm); + json_set(list, "view_status_label", options.view_status_label); + json_set(list, "management_label", options.management_label.empty() ? descriptor.label() + " Management" : options.management_label); + json_set(list, "default_order_by", options.default_order_by); + json_set(list, "default_order_dir", options.default_order_dir); + json_set(list, "user_reorderable", options.user_reorderable_); + 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, "name", descriptor.name()); + json_set(result, "label", descriptor.label()); + json_set(result, "value_type", "object"); + json_set(result, "list", std::move(list)); + json_set(result, "fields", std::move(fields)); + return result; } -template +template Json to_json(const T& value) { const auto descriptor = describe(); - Json result = Json::object(); + Json result = json_object(); std::apply([&](const auto&... item) { - ([&] { - using Field = std::remove_cvref_t; - result[item.name()] = encode_json_value(value.*Field::member); - }(), ...); + (json_set(result, item.name(), encode_json_value(item.get(value))), ...); }, descriptor.fields()); return result; } -template +template Json to_frontend_json(const T& value) { const auto descriptor = describe(); - Json result = Json::object(); + Json result = json_object(); std::apply([&](const auto&... item) { ([&] { - using Field = std::remove_cvref_t; - if (item.is_readable()) { - result[item.name()] = encode_json_value(value.*Field::member); + if(item.is_readable()) { + json_set(result, item.name(), encode_json_value(item.get(value))); } }(), ...); }, descriptor.fields()); return result; } -template +template Json to_data_json(const T& value) { - return to_frontend_json(value); + return to_frontend_json(value); } template bool field_writable(const Field& field, Write_Mode mode) { - if (mode == Write_Mode::internal) { + if(mode == Write_Mode::internal) { return true; } - if (mode == Write_Mode::create) { + if(mode == Write_Mode::create) { return field.is_creatable(); } return field.is_editable(); } -template +template requires std::copy_constructible && std::assignable_from Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, bool complete) { Update_Result result; - if (!value.is_object()) { + if(!Json_Adapter::is_object(value)) { result.message = "value must be a JSON object"; return result; } T candidate = target; const auto descriptor = describe(); - for (auto iterator = value.begin(); iterator != value.end(); ++iterator) { + for(const auto& key : Json_Adapter::keys(value)) { bool known{}; std::apply([&](const auto&... item) { - known = ((iterator.key() == item.name()) || ...); + known = ((key == item.name()) || ...); }, descriptor.fields()); - if (!known) { - result.field_errors[iterator.key()] = "unknown field"; + if(!known) { + result.field_errors[key] = "unknown field"; } } std::apply([&](const auto&... item) { ([&] { - using Field = std::remove_cvref_t; - const auto iterator = value.find(item.name()); + const bool present = Json_Adapter::contains(value, item.name()); const bool writable = field_writable(item, mode); - if (complete && item.is_required() && writable && iterator == value.end()) { + if(complete && item.is_required() && writable && !present) { result.field_errors[item.name()] = "field is required"; return; } - if (iterator == value.end()) { + if(!present) { return; } - if (!writable) { + if(!writable) { result.field_errors[item.name()] = mode == Write_Mode::create ? "field is not creatable" : "field is not editable"; return; } try { - assign_json_value(candidate.*Field::member, *iterator); - } catch (const Json_Assignment_Error& error) { + 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(); - } catch (const std::exception& error) { + } catch(const std::exception& error) { result.field_errors[item.name()] = error.what(); } }(), ...); }, descriptor.fields()); - if (!result.field_errors.empty()) { + if(!result.field_errors.empty()) { result.message = "one or more fields are invalid"; return result; } try { descriptor.object_validator()(candidate); - } catch (const std::exception& error) { + } catch(const std::exception& error) { result.message = error.what(); return result; } @@ -314,42 +370,42 @@ Update_Result apply_object_json(T& target, const Json& value, Write_Mode mode, b result.message = complete ? "assigned" : "updated"; return result; } -template +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) { + auto result = apply_object_json(candidate, value, Write_Mode::internal, true); + if(result.success) { target = std::move(candidate); } return result; } -template +template requires std::copy_constructible && std::assignable_from Update_Result apply_json_patch(T& target, const Json& patch) { - return apply_object_json(target, patch, Write_Mode::internal, false); + return apply_object_json(target, patch, Write_Mode::internal, false); } -template +template requires std::copy_constructible && std::assignable_from Update_Result apply_frontend_create(T& target, const Json& value) { - return apply_object_json(target, value, Write_Mode::create, true); + return apply_object_json(target, value, Write_Mode::create, true); } -template +template requires std::copy_constructible && std::assignable_from Update_Result apply_frontend_patch(T& target, const Json& patch) { - return apply_object_json(target, patch, Write_Mode::update, false); + return apply_object_json(target, patch, Write_Mode::update, false); } -template +template requires std::copy_constructible && std::assignable_from Update_Result apply_patch(T& target, const Json& patch) { - return apply_frontend_patch(target, patch); + return apply_frontend_patch(target, patch); } -template +template requires std::default_initializable && std::copy_constructible && std::assignable_from T from_json(const Json& value) { T result{}; - auto update = assign_json(result, value); - if (!update.success) { + auto update = assign_json(result, value); + if(!update.success) { throw Json_Assignment_Error(std::move(update)); } return result; @@ -361,7 +417,7 @@ Update_Result validate(const T& value) { describe().object_validator()(value); result.success = true; result.message = "valid"; - } catch (const std::exception& error) { + } catch(const std::exception& error) { result.message = error.what(); } return result; diff --git a/backend/include/adminive/status.hpp b/backend/include/adminive/status.hpp index 360a442..a5843e2 100644 --- a/backend/include/adminive/status.hpp +++ b/backend/include/adminive/status.hpp @@ -1,6 +1,5 @@ #pragma once #include "adminive/json.hpp" -#include #include #include #include @@ -31,64 +30,53 @@ struct Status_Inner_Value> { }; template using Status_Inner_Value_Type = typename Status_Inner_Value>::type; -template +template Json encode_status_field(const T& value) { + Json result = json_object(); if constexpr(Status_Value_Type) { - return Json{{"value", encode_json_value(value.value)}, {"color", value.color}}; + json_set(result, "value", encode_json_value(value.value)); + json_set(result, "color", value.color); } else { - return Json{{"value", encode_json_value(value)}}; + json_set(result, "value", encode_json_value(value)); } + return result; } -template +template Json to_status_descriptor_json() { const auto descriptor = describe(); - Json fields = Json::array(); + Json fields = json_array(); std::apply([&](const auto&... item) { ([&] { using Field = std::remove_cvref_t; using Member = typename Field::member_type; using Value = Status_Inner_Value_Type; - Json field_json{{"name", item.name()}, {"label", item.label()}, {"value_type", value_type_name()}, {"color", Status_Value_Type}}; + Json field = json_object(); + json_set(field, "name", item.name()); + json_set(field, "label", item.label()); + json_set(field, "value_type", value_type_name()); + json_set(field, "color", Status_Value_Type); if(!item.description().empty()) { - field_json["description"] = item.description(); + json_set(field, "description", item.description()); } - fields.push_back(std::move(field_json)); + json_append(fields, std::move(field)); }(), ...); }, descriptor.fields()); - return Json{{"protocol", "adminive.status"}, {"protocol_version", 1}, {"name", descriptor.name()}, {"label", descriptor.label()}, {"value_type", "status_object"}, {"fields", std::move(fields)}}; + Json result = json_object(); + json_set(result, "protocol", "adminive.status"); + json_set(result, "protocol_version", 1); + json_set(result, "name", descriptor.name()); + json_set(result, "label", descriptor.label()); + json_set(result, "value_type", "status_object"); + json_set(result, "fields", std::move(fields)); + return result; } -template +template Json to_status_json(const T& value) { const auto descriptor = describe(); - Json result = Json::object(); + Json result = json_object(); std::apply([&](const auto&... item) { - ([&] { - using Field = std::remove_cvref_t; - result[item.name()] = encode_status_field(value.*Field::member); - }(), ...); + (json_set(result, item.name(), encode_status_field(item.get(value))), ...); }, descriptor.fields()); return result; } -template -struct Member_Function_Traits; -template -struct Member_Function_Traits { - using owner_type = Owner; - using result_type = Result; -}; -template -struct Member_Function_Traits { - using owner_type = Owner; - using result_type = Result; -}; -template -struct Member_Function_Traits { - using owner_type = Owner; - using result_type = Result; -}; -template -struct Member_Function_Traits { - using owner_type = Owner; - using result_type = Result; -}; } diff --git a/backend/include/adminive/value.hpp b/backend/include/adminive/value.hpp index f6aa076..95a6d23 100644 --- a/backend/include/adminive/value.hpp +++ b/backend/include/adminive/value.hpp @@ -1,6 +1,8 @@ #pragma once +#include "adminive/adapter.hpp" #include #include +#include #include #include namespace adminive { @@ -85,4 +87,38 @@ template struct Unwrapped_Value> : Unwrapped_Value {}; template using Unwrapped_Value_Type = typename Unwrapped_Value>::type; +template +struct Value_Adapter, Json> { + using value_type = T; + static constexpr T minimum = Minimum; + static constexpr T maximum = Maximum; + static const T& read(const Range_Value& value) noexcept { + return value.value(); + } + static void write(Range_Value& target, T value) { + target = value; + } +}; +template +struct Validator_Multiple_Of_Metadata {}; +template +struct Validator_Multiple_Of_Metadata> { + static constexpr auto multiple_of = Validator::multiple_of; +}; +template +struct Validator_Message_Metadata {}; +template +struct Validator_Message_Metadata> { + static constexpr std::string_view validation_message = Validator::message; +}; +template +struct Value_Adapter, Json> : Validator_Multiple_Of_Metadata, Validator_Message_Metadata { + using value_type = T; + static const T& read(const Validated_Value& value) noexcept { + return value.value(); + } + static void write(Validated_Value& target, T value) { + target = value; + } +}; } diff --git a/backend/src/example.hpp b/backend/src/example.hpp index 3ce9a26..8536c4f 100644 --- a/backend/src/example.hpp +++ b/backend/src/example.hpp @@ -1,11 +1,34 @@ #pragma once #include "adminive/adminive.hpp" +#include "adminive/adapters/magic_enum.hpp" +#include "adminive/adapters/nlohmann_json.hpp" +#include "adminive/http.hpp" #include #include +#include +#include +#include +#include #include #include namespace adminive::example { -std::string format_utc_time(std::chrono::system_clock::time_point value); +using Json = nlohmann::json; +template +using Http_Resource = adminive::Http_Resource; +template +using Http_Collection_Resource = adminive::Http_Collection_Resource; +inline std::string format_utc_time(std::chrono::system_clock::time_point value) { + const std::time_t raw_time = std::chrono::system_clock::to_time_t(value); + std::tm utc_time{}; +#ifdef _WIN32 + gmtime_s(&utc_time, &raw_time); +#else + gmtime_r(&raw_time, &utc_time); +#endif + std::ostringstream stream; + stream << std::put_time(&utc_time, "%Y-%m-%dT%H:%M:%SZ"); + return stream.str(); +} enum class Radio_Mode { receive, transmit, @@ -30,20 +53,6 @@ enum class Alert_Channel { email, webhook }; -enum class Device_Table_Type { - serial, - network -}; -enum class Serial_Parity { - none, - odd, - even -}; -enum class Network_Protocol { - tcp, - udp, - websocket -}; struct Server_Status { std::string service_state{"running"}; std::string server_time; @@ -86,54 +95,143 @@ struct Alert_Config { std::string highlight_color{"#d97706"}; Endpoint_Config webhook_endpoint{true, "127.0.0.1", Range_Value{9100}}; }; -struct Serial_Table_Config { - std::string group_name{"本地串口设备"}; - Range_Value default_baud_rate{115200}; - Serial_Parity default_parity{Serial_Parity::none}; - std::string accent_color{"#2563eb"}; -}; -struct Network_Table_Config { - std::string group_name{"网络终端"}; - Network_Protocol default_protocol{Network_Protocol::tcp}; - Range_Value timeout_ms{3000}; - bool tls_enabled{}; - std::string accent_color{"#16a34a"}; -}; -struct Application_Config { - Device_Table_Type default_device_type{Device_Table_Type::serial}; - Radio_Service_Config radio_service{}; - Alert_Config alerts{}; - Serial_Table_Config serial_table{}; - Network_Table_Config network_table{}; -}; struct Radio_State { struct Even_Validator { static constexpr int multiple_of = 2; static constexpr std::string_view message = "buffer_count must be even"; - void operator()(const int& value) const; + void operator()(const int& value) const { + if(value % 2 != 0) { + throw std::invalid_argument("buffer_count must be even"); + } + } }; struct Watermark_Validator { - void operator()(const Radio_State& value) const; + void operator()(const Radio_State& value) const { + if(value.low_watermark > value.high_watermark) { + throw std::invalid_argument("low_watermark must not exceed high_watermark"); + } + } }; Radio_Mode mode{Radio_Mode::receive}; Range_Value port{9999}; Validated_Value buffer_count{2}; int low_watermark{}; int high_watermark{}; - Radio_Item_Status status(); + Radio_Item_Status status() { + const auto now = std::chrono::system_clock::now(); + const auto seconds = std::chrono::duration_cast(now.time_since_epoch()).count(); + const auto state_index = (static_cast(seconds / 4) + static_cast(port.value())) % 4; + Radio_Operating_State state{}; + std::string color; + switch(state_index) { + case 0: + state = Radio_Operating_State::starting; + color = "#2563eb"; + break; + case 1: + state = Radio_Operating_State::running; + color = "#16a34a"; + break; + case 2: + state = Radio_Operating_State::degraded; + color = "#d97706"; + break; + default: + state = Radio_Operating_State::stopped; + color = "#dc2626"; + break; + } + ++status_sequence_; + return Radio_Item_Status{status_value(state, std::move(color)), status_value(status_sequence_, "#475569"), status_value(format_utc_time(now), "#475569")}; + } private: std::uint64_t status_sequence_{}; }; -struct Serial_Device_Row { - std::string port_name{"COM1"}; - Range_Value baud_rate{115200}; - Serial_Parity parity{Serial_Parity::none}; - bool enabled{true}; +} +namespace adminive { +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Server_Status; + return object("server_status", ADMINIVE_FIELD(T, service_state), ADMINIVE_FIELD(T, server_time), ADMINIVE_FIELD(T, uptime_seconds), ADMINIVE_FIELD(T, poll_sequence), ADMINIVE_FIELD(T, radio_state_count), ADMINIVE_FIELD(T, listen_port)).label("Server Status"); + } }; -struct Network_Device_Row { - std::string endpoint{"127.0.0.1:7001"}; - Network_Protocol protocol{Network_Protocol::tcp}; - Range_Value timeout_ms{3000}; - bool tls_enabled{}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Radio_Item_Status; + return object("radio_item_status", ADMINIVE_FIELD_LABEL(T, operating_state, "State"), ADMINIVE_FIELD_LABEL(T, refresh_sequence, "Refresh Sequence"), ADMINIVE_FIELD_LABEL(T, refresh_time, "Refresh Time")).label("Runtime Status"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Endpoint_Config; + return object( + "endpoint_config", + ADMINIVE_FIELD_LABEL(T, enabled, "Enable Endpoint").editable().creatable().description("Controls whether this endpoint participates in the current configuration"), + ADMINIVE_FIELD_LABEL(T, host, "Host Address").editable().creatable().required().description("String input example"), + ADMINIVE_FIELD_LABEL(T, port, "Port").editable().creatable().required().description("Integer input example") + ).label("Endpoint"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Appearance_Config; + return object( + "appearance_config", + ADMINIVE_FIELD_LABEL(T, panel_title, "Panel Title").editable().creatable().required().description("String input example"), + ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date").editable().creatable().required().widget("input-date").description("Date selector example"), + ADMINIVE_FIELD_LABEL(T, accent_color, "Accent Color").editable().creatable().required().widget("input-color").description("Color selector example") + ).label("Appearance"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Radio_Service_Config; + return object( + "radio_service_config", + ADMINIVE_FIELD_LABEL(T, profile_name, "Profile Name").editable().required().description("Editable string configuration"), + ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode").editable().required().description("Enum options are generated by magic_enum"), + ADMINIVE_FIELD_LABEL(T, worker_count, "Worker Count").editable().required().description("Integer number input"), + ADMINIVE_FIELD_LABEL(T, receive_gain, "Receive Gain").editable().required().description("Floating-point number input"), + ADMINIVE_FIELD_LABEL(T, primary_endpoint, "Primary Endpoint").editable().description("First child configuration group"), + ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint").editable().visible_on("${mode == 'duplex'}").description("Displayed only when Operating Mode is duplex"), + ADMINIVE_FIELD_LABEL(T, appearance, "Appearance").editable().description("Nested date and color controls") + ).label("Radio Service Configuration"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Alert_Config; + return object( + "alert_config", + ADMINIVE_FIELD_LABEL(T, enabled, "Enable Alerts").editable(), + ADMINIVE_FIELD_LABEL(T, rule_name, "Rule Name").editable().required(), + ADMINIVE_FIELD_LABEL(T, channel, "Delivery Channel").editable().required().description("Enum options are generated by magic_enum"), + ADMINIVE_FIELD_LABEL(T, minimum_level, "Minimum Log Level").editable().required().description("Second enum example"), + ADMINIVE_FIELD_LABEL(T, repeat_minutes, "Repeat Interval Minutes").editable().required(), + ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date").editable().required().widget("input-date"), + ADMINIVE_FIELD_LABEL(T, highlight_color, "Highlight Color").editable().required().widget("input-color"), + ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook Endpoint").editable().visible_on("${enabled && channel == 'webhook'}").description("Displayed only when alerts are enabled and channel is webhook") + ).label("Alert Configuration"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Radio_State; + return object( + "radio_state", + ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode").creatable().editable().required().list_label("Mode").order(5).sortable().description("Enum list column generated through magic_enum"), + ADMINIVE_FIELD(T, port).creatable().editable().required().description("Listening port").list_label("Listen Port").order(20).sortable(), + ADMINIVE_FIELD(T, buffer_count).creatable().editable().required().list_label("Buffers").order(10).sortable(), + ADMINIVE_FIELD(T, low_watermark).creatable().editable().order(30), + ADMINIVE_FIELD(T, high_watermark).creatable().editable().order(40) + ).label("Radio State").validator(T::Watermark_Validator{}); + } }; } diff --git a/backend/src/main.cpp b/backend/src/main.cpp index d0b05ad..ae7ac3b 100644 --- a/backend/src/main.cpp +++ b/backend/src/main.cpp @@ -1,53 +1,108 @@ -#include "server_app.hpp" +#include "example.hpp" +#include #include +#include +#include #include #include +#include #ifndef ADMINIVE_FRONTEND_DIST_DIR #define ADMINIVE_FRONTEND_DIST_DIR "frontend_dist" #endif -#ifndef ADMINIVE_CONFIG_FILE -#define ADMINIVE_CONFIG_FILE "config/adminive.json" -#endif namespace { -int read_port(int argc, char** argv) { - if(argc == 1) { - return 9999; - } - const std::string_view text(argv[1]); - int port{}; - const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), port); - if(error != std::errc{} || end != text.data() + text.size() || port < 1 || port > 65535) { - return 0; - } - return port; +using Alert_Config = adminive::example::Alert_Config; +using Radio_Item_Status = adminive::example::Radio_Item_Status; +using Radio_Mode = adminive::example::Radio_Mode; +using Radio_Service_Config = adminive::example::Radio_Service_Config; +using Radio_State = adminive::example::Radio_State; +using Json = adminive::example::Json; +using State_Resource = adminive::example::Http_Collection_Resource; +using Service_Config_Resource = adminive::example::Http_Resource; +using Alert_Config_Resource = adminive::example::Http_Resource; +Radio_State make_radio_state(Radio_Mode mode, int port, int buffer_count, int low_watermark, int high_watermark) { + Radio_State result; + result.mode = mode; + result.port = port; + result.buffer_count = buffer_count; + result.low_watermark = low_watermark; + result.high_watermark = high_watermark; + return result; } -void describe_example() { - using namespace adminive::example; - Radio_State state; - const auto status_descriptor = adminive::to_status_descriptor_json(); - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_json(state).dump(2) << '\n'; - std::cout << status_descriptor.dump(2) << '\n'; - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; +Json make_admin_schema(const State_Resource& states, const Service_Config_Resource& service_config, const Alert_Config_Resource& alert_config) { + const Json states_schema = states.amis_schema(); + Json tabs = Json::array(); + tabs.push_back(Json{{"title", "Radio State List"}, {"body", states_schema.at("body")}}); + tabs.push_back(Json{{"title", "Radio Service Configuration"}, {"body", service_config.amis_schema()}}); + tabs.push_back(Json{{"title", "Alert Configuration"}, {"body", alert_config.amis_schema()}}); + return Json{{"type", "page"}, {"title", "Adminive Complete Example"}, {"subTitle", "CRUD, enum columns, lazy status, hierarchical configuration, linkage, date and color controls are defined by the backend"}, {"body", Json{{"type", "tabs"}, {"tabs", std::move(tabs)}}}}; } } int main(int argc, char** argv) { if(argc == 2 && std::string_view(argv[1]) == "--describe") { - describe_example(); + Radio_State state; + Radio_Service_Config service_config; + Alert_Config alert_config; + const auto status_descriptor = adminive::to_status_descriptor_json(); + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_json(state).dump(2) << '\n'; + std::cout << status_descriptor.dump(2) << '\n'; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_amis_form_schema(service_config, "/admin/config/radio/data", "Confirm Changes").dump(2) << '\n'; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_amis_form_schema(alert_config, "/admin/config/alerts/data", "Confirm Changes").dump(2) << '\n'; return 0; } - const int port = read_port(argc, argv); - if(port == 0) { - std::cerr << "invalid port\n"; - return 2; + int port = 9999; + if(argc == 2) { + const std::string_view text(argv[1]); + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), port); + if(error != std::errc{} || end != text.data() + text.size() || port < 1 || port > 65535) { + std::cerr << "invalid port\n"; + return 2; + } } - try { - adminive::example::Server_App app(port, ADMINIVE_FRONTEND_DIST_DIR, ADMINIVE_CONFIG_FILE); - return app.run(); - } catch(const std::exception& error) { - std::cerr << error.what() << '\n'; - return 4; + const auto started_at = std::chrono::steady_clock::now(); + std::vector initial; + initial.push_back(make_radio_state(Radio_Mode::receive, 10001, 2, 16, 64)); + initial.push_back(make_radio_state(Radio_Mode::transmit, 10002, 4, 32, 128)); + initial.push_back(make_radio_state(Radio_Mode::duplex, 10003, 8, 64, 256)); + Radio_Service_Config radio_service_config; + Alert_Config alert_config; + httplib::Server server; + State_Resource state_resource("/admin/radio_states", std::move(initial), "/admin/status"); + 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_status<&Radio_State::status>(2000); + state_resource.bind(server); + service_config_resource.bind(server); + alert_config_resource.bind(server); + server.Get("/admin/amis", [&](const httplib::Request&, httplib::Response& response) { + adminive::write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", make_admin_schema(state_resource, service_config_resource, alert_config_resource)}}); + }); + std::atomic poll_sequence{}; + server.Get("/admin/status", [&](const httplib::Request&, httplib::Response& response) { + const auto now = std::chrono::system_clock::now(); + const auto uptime = std::chrono::duration_cast(std::chrono::steady_clock::now() - started_at).count(); + adminive::example::Server_Status status; + status.server_time = adminive::example::format_utc_time(now); + status.uptime_seconds = static_cast(uptime); + status.poll_sequence = poll_sequence.fetch_add(1, std::memory_order_relaxed) + 1; + status.radio_state_count = state_resource.size(); + status.listen_port = port; + adminive::write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", adminive::to_frontend_json(status)}}); + }); + const std::filesystem::path frontend_dist = std::filesystem::path(ADMINIVE_FRONTEND_DIST_DIR).lexically_normal(); + if(!server.set_mount_point("/", frontend_dist.string())) { + std::cerr << "frontend build directory does not exist: " << frontend_dist.string() << '\n'; + std::cerr << "run npm run build from the project root before starting the backend\n"; + return 3; } + std::cout << "Adminive server listening on http://127.0.0.1:" << port << '\n'; + std::cout << "Serving frontend from " << frontend_dist.string() << '\n'; + std::cout << std::flush; + if(!server.listen("0.0.0.0", port)) { + std::cerr << "failed to start server\n"; + return 1; + } + return 0; } diff --git a/backend/tests/core_adapter_test.cpp b/backend/tests/core_adapter_test.cpp new file mode 100644 index 0000000..ab4d283 --- /dev/null +++ b/backend/tests/core_adapter_test.cpp @@ -0,0 +1,254 @@ +#include "adminive/adminive.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace adapter_test { +struct Mini_Json; +using Mini_Object = std::map; +using Mini_Array = std::vector; +struct Mini_Json { + using Storage = std::variant; + Storage value{}; +}; +struct External_Atomic { + int value{}; +}; +enum class Mode { + primary = 10, + secondary = 20 +}; +struct Runtime_Base { + External_Atomic worker_count{4}; +}; +struct Mode_Base { + Mode mode{Mode::primary}; +}; +struct Config : Runtime_Base, Mode_Base { + std::string name{"default"}; +}; +} +namespace adminive { +template <> +struct Json_Adapter { + using Json = adapter_test::Mini_Json; + static Json object() { + return Json{adapter_test::Mini_Object{}}; + } + static Json array() { + return Json{adapter_test::Mini_Array{}}; + } + static Json parse(std::string_view) { + throw std::runtime_error("parse is not implemented by the test adapter"); + } + static std::string dump(const Json&, int) { + return {}; + } + static bool is_object(const Json& value) noexcept { + return std::holds_alternative(value.value); + } + static bool is_array(const Json& value) noexcept { + return std::holds_alternative(value.value); + } + static bool is_string(const Json& value) noexcept { + return std::holds_alternative(value.value); + } + static bool is_number(const Json& value) noexcept { + return std::holds_alternative(value.value); + } + static bool is_boolean(const Json& value) noexcept { + return std::holds_alternative(value.value); + } + static long double number(const Json& value) { + return std::get(value.value); + } + static bool contains(const Json& value, std::string_view name) { + const auto& object_value = std::get(value.value); + return object_value.contains(std::string(name)); + } + static std::size_t size(const Json& value) { + if(is_object(value)) { + return std::get(value.value).size(); + } + if(is_array(value)) { + return std::get(value.value).size(); + } + return 0; + } + static std::vector keys(const Json& value) { + std::vector result; + for(const auto& [name, item] : std::get(value.value)) { + static_cast(item); + result.push_back(name); + } + return result; + } + static Json& at(Json& value, std::string_view name) { + return std::get(value.value).at(std::string(name)); + } + static const Json& at(const Json& value, std::string_view name) { + return std::get(value.value).at(std::string(name)); + } + static Json& at(Json& value, std::size_t index) { + return std::get(value.value).at(index); + } + static const Json& at(const Json& value, std::size_t index) { + return std::get(value.value).at(index); + } + static void set(Json& value, std::string_view name, Json item) { + std::get(value.value).insert_or_assign(std::string(name), std::move(item)); + } + static void append(Json& value, Json item) { + std::get(value.value).push_back(std::move(item)); + } + static void erase(Json& value, std::string_view name) { + std::get(value.value).erase(std::string(name)); + } + template + static Json make(T&& value) { + using Value = std::remove_cvref_t; + if constexpr(std::same_as) { + return Json{value}; + } else if constexpr(std::integral || std::floating_point) { + return Json{static_cast(value)}; + } else if constexpr(std::same_as) { + return Json{std::forward(value)}; + } else if constexpr(std::same_as) { + return Json{std::string(value)}; + } else if constexpr(std::is_convertible_v) { + return Json{std::string(value)}; + } else { + static_assert(!sizeof(T), "unsupported Mini_Json scalar"); + } + } + template + static T get(const Json& value) { + if constexpr(std::same_as) { + return std::get(value.value); + } else if constexpr(std::integral || std::floating_point) { + return static_cast(std::get(value.value)); + } else if constexpr(std::same_as) { + return std::get(value.value); + } else { + static_assert(!sizeof(T), "unsupported Mini_Json conversion"); + } + } +}; +template +struct Value_Adapter { + using value_type = int; + static int read(const adapter_test::External_Atomic& value) noexcept { + return value.value; + } + static void write(adapter_test::External_Atomic& target, int value) noexcept { + target.value = value; + } +}; +template <> +struct Enum_Adapter { + static std::vector values() { + return {adapter_test::Mode::primary, adapter_test::Mode::secondary}; + } + static std::string_view name(adapter_test::Mode value) { + return value == adapter_test::Mode::primary ? "primary" : "secondary"; + } + static std::optional cast(std::string_view value) { + if(value == "primary") { + return adapter_test::Mode::primary; + } + if(value == "secondary") { + return adapter_test::Mode::secondary; + } + return std::nullopt; + } + static std::optional cast(std::underlying_type_t value) { + if(value == 10) { + return adapter_test::Mode::primary; + } + if(value == 20) { + return adapter_test::Mode::secondary; + } + return std::nullopt; + } +}; +template <> +struct Reflection_Adapter { + static constexpr std::size_t field_count = 3; + template + static decltype(auto) get(adapter_test::Config& value) noexcept { + if constexpr(Index == 0) { + return (value.worker_count); + } else if constexpr(Index == 1) { + return (value.mode); + } else { + return (value.name); + } + } + template + static decltype(auto) get(const adapter_test::Config& value) noexcept { + if constexpr(Index == 0) { + return (value.worker_count); + } else if constexpr(Index == 1) { + return (value.mode); + } else { + return (value.name); + } + } + template + static constexpr std::string_view name() noexcept { + if constexpr(Index == 0) { + return "worker_count"; + } else if constexpr(Index == 1) { + return "mode"; + } else { + return "name"; + } + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = adapter_test::Config; + return reflected_object_with("config", "配置", [](auto field) { + if constexpr(Index == 0) { + return field.label("线程数").editable().required(); + } else if constexpr(Index == 1) { + return field.label("模式").editable().template enum_label("主用").template enum_label("备用"); + } else { + return field.label("名称").editable(); + } + }); + } +}; +} +int main() { + using Json = adapter_test::Mini_Json; + using Config = adapter_test::Config; + const Json descriptor = adminive::to_descriptor_json(); + const auto& fields = adminive::Json_Adapter::at(descriptor, "fields"); + assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 0), "name")) == "worker_count")); + assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 0), "default")) == 4)); + const auto& options = adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 1), "options"); + assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(options, 0), "label")) == "主用")); + Config config; + Json patch = adminive::json_object(); + adminive::json_set(patch, "worker_count", 12); + adminive::json_set(patch, "mode", "secondary"); + adminive::json_set(patch, "name", "updated"); + const auto result = adminive::apply_frontend_patch(config, patch); + assert(result.success); + assert(config.worker_count.value == 12); + assert(config.mode == adapter_test::Mode::secondary); + assert(config.name == "updated"); + const Json encoded = adminive::to_json(config); + assert((adminive::json_get(adminive::Json_Adapter::at(encoded, "worker_count")) == 12)); + assert((adminive::json_get(adminive::Json_Adapter::at(encoded, "mode")) == "secondary")); + return 0; +} diff --git a/backend/tests/test.cpp b/backend/tests/test.cpp index 8293fe0..60dd09a 100644 --- a/backend/tests/test.cpp +++ b/backend/tests/test.cpp @@ -1,70 +1,161 @@ -#include "config_store.hpp" -#include "device_tables.hpp" -#include "example_descriptors.hpp" +#include "example.hpp" #include -#include +#include +#include int main() { - using namespace adminive::example; - const auto radio_descriptor = adminive::to_descriptor_json(); - assert(radio_descriptor.at("fields").at(0).at("name") == "mode"); - assert(radio_descriptor.at("fields").at(0).at("list_label") == "模式"); - assert(radio_descriptor.at("fields").at(0).at("options").at(0).at("label") == "接收"); - assert(radio_descriptor.at("fields").at(0).at("options").at(2).at("label") == "双工"); - assert(radio_descriptor.at("list").at("id_label") == "编号"); - assert(radio_descriptor.at("list").at("default_order_by") == "buffer_count"); - assert(radio_descriptor.at("list").at("default_order_dir") == "asc"); - const auto radio_crud = adminive::to_amis_crud_schema("/admin/radio_states"); - const auto& radio_table = radio_crud.at("body"); - assert(radio_table.at("columns").at(0).at("label") == "编号"); - assert(radio_table.at("columns").at(1).at("map").at("receive") == "接收"); - assert(radio_table.at("columns").at(2).at("name") == "buffer_count"); - assert(radio_table.at("headerToolbar").at(0).at("type") == "columns-toggler"); - assert(radio_table.at("headerToolbar").at(0).at("draggable") == true); - assert(radio_table.at("defaultParams").at("orderBy") == "buffer_count"); - assert(!radio_table.contains("draggable")); - const auto serial_descriptor = adminive::to_descriptor_json(); - assert(serial_descriptor.at("list").at("user_reorderable") == true); - assert(serial_descriptor.at("fields").at(2).at("options").at(0).at("label") == "无校验"); - const auto serial_crud = adminive::to_amis_crud_schema("/admin/device_tables/serial/items"); - assert(serial_crud.at("body").at("draggable") == true); - assert(serial_crud.at("body").at("saveOrderApi").at("url") == "/admin/device_tables/serial/items/order"); - const auto network_crud = adminive::to_amis_crud_schema("/admin/device_tables/network/items"); - assert(network_crud.at("body").at("columns").at(1).at("label") == "地址"); - assert(network_crud.at("body").at("columns").at(2).at("map").at("websocket") == "WebSocket"); - Radio_Service_Config radio_config; - const auto radio_form = adminive::to_amis_form_schema(radio_config, "/admin/config/radio/data", "确认修改"); - assert(radio_form.at("body").at(0).at("type") == "input-text"); - assert(radio_form.at("body").at(1).at("type") == "select"); - assert(radio_form.at("body").at(1).at("options").at(2).at("label") == "双工"); - assert(radio_form.at("body").at(5).at("visibleOn") == "${mode == 'duplex'}"); - assert(radio_form.at("body").at(6).at("body").at(1).at("type") == "input-date"); - assert(radio_form.at("body").at(6).at("body").at(2).at("type") == "input-color"); + using adminive::example::Alert_Config; + using adminive::example::Radio_Item_Status; + using adminive::example::Radio_Mode; + using adminive::example::Radio_Service_Config; + using adminive::example::Radio_State; + using Json = adminive::example::Json; Radio_State state; - const auto first_status = adminive::to_status_json(state.status()); - const auto second_status = adminive::to_status_json(state.status()); + const auto descriptor = adminive::to_descriptor_json(); + assert(descriptor.at("name") == "radio_state"); + assert(descriptor.at("fields").size() == 5); + assert(descriptor.at("fields").at(0).at("name") == "mode"); + assert(descriptor.at("fields").at(0).at("label") == "Operating Mode"); + assert(descriptor.at("fields").at(0).at("list_label") == "Mode"); + assert(descriptor.at("fields").at(0).at("value_type") == "enum"); + assert(descriptor.at("fields").at(0).at("options").size() == 4); + assert(descriptor.at("fields").at(0).at("options").at(0).at("value") == "receive"); + assert(descriptor.at("fields").at(0).at("options").at(2).at("value") == "duplex"); + assert(descriptor.at("fields").at(0).at("order") == 5); + assert(descriptor.at("fields").at(0).at("sortable") == true); + assert(descriptor.at("fields").at(1).at("name") == "port"); + assert(descriptor.at("fields").at(1).at("list_label") == "Listen Port"); + assert(descriptor.at("fields").at(1).at("minimum") == 1); + assert(descriptor.at("fields").at(1).at("maximum") == 65535); + assert(descriptor.at("fields").at(2).at("name") == "buffer_count"); + assert(descriptor.at("fields").at(2).at("multiple_of") == 2); + const auto initial = adminive::to_json(state); + assert(initial.at("mode") == "receive"); + assert(initial.at("port") == 9999); + Radio_State assigned; + auto result = adminive::assign_json(assigned, Json{{"mode", "transmit"}, {"port", 7000}, {"buffer_count", 6}, {"low_watermark", 10}, {"high_watermark", 20}}); + assert(result.success); + assert(assigned.mode == Radio_Mode::transmit); + assert(assigned.port.value() == 7000); + result = adminive::apply_json_patch(assigned, Json{{"mode", "duplex"}, {"port", 7200}}); + assert(result.success); + assert(assigned.mode == Radio_Mode::duplex); + assert(assigned.port.value() == 7200); + result = adminive::apply_patch(state, Json{{"buffer_count", 3}}); + assert(!result.success); + assert(state.buffer_count.value() == 2); + result = adminive::apply_patch(state, Json{{"low_watermark", 64}, {"high_watermark", 32}}); + assert(!result.success); + assert(state.low_watermark == 0); + result = adminive::apply_patch(state, Json{{"missing", 1}}); + assert(!result.success); + assert(result.field_errors.contains("missing")); + Radio_State created; + result = adminive::apply_frontend_create(created, Json{{"mode", "maintenance"}, {"port", 7300}, {"buffer_count", 10}, {"low_watermark", 12}, {"high_watermark", 24}}); + assert(result.success); + assert(created.mode == Radio_Mode::maintenance); + result = adminive::apply_frontend_create(created, Json{{"port", 7300}}); + assert(!result.success); + assert(result.field_errors.contains("mode")); + assert(result.field_errors.contains("buffer_count")); + const auto form = adminive::to_amis_form_schema(state, "/admin/radio_state", "Confirm Changes"); + assert(form.at("type") == "form"); + assert(form.at("body").size() == 5); + assert(form.at("body").at(0).at("type") == "select"); + assert(form.at("body").at(0).at("options").size() == 4); + assert(form.at("body").at(1).at("type") == "input-number"); + assert(form.at("body").at(2).at("step") == 2); + assert(form.at("actions").size() == 2); + assert(form.at("actions").at(0).at("type") == "reset"); + assert(form.at("actions").at(1).at("type") == "submit"); + assert(form.at("actions").at(1).at("label") == "Confirm Changes"); + const auto crud = adminive::to_amis_crud_schema("/admin/radio_states"); + assert(crud.at("type") == "page"); + assert(crud.at("body").at("type") == "crud"); + assert(crud.at("body").at("columns").size() == 7); + assert(crud.at("body").at("columns").at(1).at("name") == "mode"); + assert(crud.at("body").at("columns").at(1).at("type") == "mapping"); + assert(crud.at("body").at("columns").at(1).at("map").at("duplex") == "Duplex"); + assert(crud.at("body").at("columns").at(2).at("name") == "buffer_count"); + assert(crud.at("body").at("columns").at(3).at("name") == "port"); + assert(!crud.at("body").contains("interval")); + const auto& create_form = crud.at("body").at("headerToolbar").at(2).at("dialog").at("body"); + assert(create_form.at("actions").at(1).at("type") == "submit"); + assert(create_form.at("actions").at(1).at("label") == "Create"); + const auto status_descriptor = adminive::to_status_descriptor_json(); + assert(status_descriptor.at("protocol") == "adminive.status"); + assert(status_descriptor.at("fields").size() == 3); + assert(status_descriptor.at("fields").at(0).at("name") == "operating_state"); + assert(status_descriptor.at("fields").at(0).at("value_type") == "enum"); + assert(status_descriptor.at("fields").at(0).at("color") == true); + const auto first_status = adminive::to_status_json(state.status()); + const auto second_status = adminive::to_status_json(state.status()); + assert(first_status.at("operating_state").contains("color")); + 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 config_root = std::filesystem::temp_directory_path() / "adminive_config_store_test"; - const auto config_file = config_root / "adminive.json"; - std::filesystem::remove_all(config_root); - { - Config_Store store(config_file); - assert(std::filesystem::exists(config_file)); - Radio_Service_Config changed = store.data().radio_service; - changed.profile_name = "持久化配置"; - std::scoped_lock lock(store.mutex()); - store.persist_radio_service(changed); - } - { - Config_Store store(config_file); - assert(store.data().radio_service.profile_name == "持久化配置"); - Device_Table_Manager manager(store); - const auto schema = manager.amis_schema(); - assert(schema.at("data").at("device_type") == "serial"); - assert(schema.at("body").at("body").at(0).at("label") == "设备类型"); - assert(schema.at("body").at("body").at(0).at("options").size() == 2); - assert(schema.at("body").at("body").at(2).at("schemaApi").at("url") == "/admin/device_tables/schema?type=${device_type}"); - } - std::filesystem::remove_all(config_root); + const auto status_crud = adminive::to_amis_crud_status_schema("/admin/radio_states", "/admin/status", &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); + const auto& status_column = status_crud.at("body").at(1).at("columns").at(6); + assert(status_column.at("label") == "Runtime Status"); + assert(status_column.at("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"); + 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); + assert(registered_status_column.at("popOver").at("body").at("interval") == 3000); + Radio_Service_Config service_config; + const auto service_descriptor = adminive::to_descriptor_json(); + assert(service_descriptor.at("fields").size() == 7); + assert(service_descriptor.at("fields").at(0).at("value_type") == "string"); + assert(service_descriptor.at("fields").at(1).at("value_type") == "enum"); + assert(service_descriptor.at("fields").at(2).at("value_type") == "integer"); + assert(service_descriptor.at("fields").at(3).at("value_type") == "number"); + assert(service_descriptor.at("fields").at(4).at("value_type") == "object"); + assert(service_descriptor.at("fields").at(4).at("children").size() == 3); + assert(service_descriptor.at("fields").at(5).at("visible_on") == "${mode == 'duplex'}"); + assert(service_descriptor.at("fields").at(6).at("children").at(1).at("widget") == "input-date"); + assert(service_descriptor.at("fields").at(6).at("children").at(2).at("widget") == "input-color"); + const auto service_form = adminive::to_amis_form_schema(service_config, "/admin/config/radio/data", "Confirm Changes"); + assert(service_form.at("body").at(0).at("type") == "input-text"); + assert(service_form.at("body").at(1).at("type") == "select"); + assert(service_form.at("body").at(2).at("type") == "input-number"); + assert(service_form.at("body").at(3).at("type") == "input-number"); + assert(service_form.at("body").at(4).at("type") == "fieldset"); + assert(service_form.at("body").at(4).at("body").at(1).at("name") == "primary_endpoint.host"); + assert(service_form.at("body").at(5).at("visibleOn") == "${mode == 'duplex'}"); + assert(service_form.at("body").at(6).at("body").at(1).at("type") == "input-date"); + assert(service_form.at("body").at(6).at("body").at(1).at("valueFormat") == "YYYY-MM-DD"); + assert(service_form.at("body").at(6).at("body").at(2).at("type") == "input-color"); + assert(service_form.at("actions").at(1).at("type") == "submit"); + result = adminive::apply_frontend_patch(service_config, Json{{"profile_name", "Duplex Profile"}, {"mode", "duplex"}, {"worker_count", 8}, {"receive_gain", 2.5}, {"primary_endpoint", Json{{"enabled", true}, {"host", "10.0.0.1"}, {"port", 9200}}}, {"backup_endpoint", Json{{"enabled", true}, {"host", "10.0.0.2"}, {"port", 9201}}}, {"appearance", Json{{"panel_title", "Duplex Radio"}, {"effective_date", "2026-08-07"}, {"accent_color", "#16a34a"}}}}); + assert(result.success); + assert(service_config.mode == Radio_Mode::duplex); + assert(service_config.primary_endpoint.host == "10.0.0.1"); + assert(service_config.appearance.accent_color == "#16a34a"); + Alert_Config alert_config; + const auto alert_form = adminive::to_amis_form_schema(alert_config, "/admin/config/alerts/data", "Confirm Changes"); + assert(alert_form.at("body").size() == 8); + assert(alert_form.at("body").at(1).at("type") == "input-text"); + assert(alert_form.at("body").at(2).at("type") == "select"); + assert(alert_form.at("body").at(3).at("type") == "select"); + assert(alert_form.at("body").at(4).at("type") == "input-number"); + assert(alert_form.at("body").at(5).at("type") == "input-date"); + assert(alert_form.at("body").at(6).at("type") == "input-color"); + assert(alert_form.at("body").at(7).at("type") == "fieldset"); + assert(alert_form.at("body").at(7).at("visibleOn") == "${enabled && channel == 'webhook'}"); + adminive::example::Http_Resource service_resource(service_config, "/admin/config/radio"); + const auto resource_form = service_resource.amis_schema(); + assert(resource_form.at("api").at("method") == "post"); + assert(resource_form.at("api").at("url") == "/admin/config/radio/data"); + assert(resource_form.at("actions").at(1).at("label") == "Confirm Changes"); + adminive::example::Server_Status server_status; + server_status.server_time = "2026-08-06T00:00:00Z"; + server_status.poll_sequence = 7; + const auto status_json = adminive::to_frontend_json(server_status); + assert(status_json.at("service_state") == "running"); + assert(status_json.at("poll_sequence") == 7); return 0; }