From: Vsevolod Stakhov Date: Tue, 18 Nov 2025 19:52:08 +0000 (+0000) Subject: [Project] Rework mixins and documentation part X-Git-Tag: 3.14.1~11^2~12 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=c65ebc68995aec954242c750a3deec5b9cf9e57a;p=thirdparty%2Frspamd.git [Project] Rework mixins and documentation part --- diff --git a/lualib/lua_redis.lua b/lualib/lua_redis.lua index 0025399e06..ddcb84c94b 100644 --- a/lualib/lua_redis.lua +++ b/lualib/lua_redis.lua @@ -18,6 +18,7 @@ local logger = require "rspamd_logger" local lutil = require "lua_util" local rspamd_util = require "rspamd_util" local T = require "lua_shape.core" +local PluginSchema = require "lua_shape.plugin_schema" local exports = {} @@ -70,53 +71,143 @@ local common_schema = T.table({ }):optional():doc({ summary = "Redis server version (6 or 7)" }), }, { open = true }) +local function extend_fields(...) + local res = {} + for i = 1, select('#', ...) do + local tbl = select(i, ...) + if tbl then + for k, v in pairs(tbl) do + res[k] = v + end + end + end + return res +end + +-- Register common schema as redis_common first +PluginSchema.register("mixins.redis_common", common_schema) + +-- Create full redis mixin schema for documentation +local redis_mixin_schema = T.one_of({ + { + name = "common_only", + schema = T.table({}, { + open = true, + mixins = { T.mixin(common_schema, { as = "redis_common" }) } + }):doc({ summary = "Redis with no explicit servers (uses localhost)" }) + }, + { + name = "read_servers", + schema = T.table({ + read_servers = T.one_of({ + T.string():doc({ summary = "Single Redis server" }), + T.array(T.string()):doc({ summary = "Multiple Redis servers" }) + }):doc({ summary = "Read servers list" }) + }, { + open = true, + mixins = { T.mixin(common_schema, { as = "redis_common" }) } + }):doc({ summary = "Redis with read-only servers" }) + }, + { + name = "write_servers", + schema = T.table({ + write_servers = T.one_of({ + T.string():doc({ summary = "Single Redis server" }), + T.array(T.string()):doc({ summary = "Multiple Redis servers" }) + }):doc({ summary = "Write servers list" }) + }, { + open = true, + mixins = { T.mixin(common_schema, { as = "redis_common" }) } + }):doc({ summary = "Redis with write-only servers" }) + }, + { + name = "rw_servers", + schema = T.table({ + read_servers = T.one_of({ + T.string():doc({ summary = "Single Redis server" }), + T.array(T.string()):doc({ summary = "Multiple Redis servers" }) + }):doc({ summary = "Read servers list" }), + write_servers = T.one_of({ + T.string():doc({ summary = "Single Redis server" }), + T.array(T.string()):doc({ summary = "Multiple Redis servers" }) + }):doc({ summary = "Write servers list" }) + }, { + open = true, + mixins = { T.mixin(common_schema, { as = "redis_common" }) } + }):doc({ summary = "Redis with separate read and write servers" }) + }, + { + name = "servers", + schema = T.table({ + servers = T.one_of({ + T.string():doc({ summary = "Single Redis server" }), + T.array(T.string()):doc({ summary = "Multiple Redis servers" }) + }):doc({ summary = "Redis servers list" }) + }, { + open = true, + mixins = { T.mixin(common_schema, { as = "redis_common" }) } + }):doc({ summary = "Redis with server list" }) + }, + { + name = "server_legacy", + schema = T.table({ + server = T.one_of({ + T.string():doc({ summary = "Single Redis server" }), + T.array(T.string()):doc({ summary = "Multiple Redis servers" }) + }):doc({ summary = "Redis server (legacy)" }) + }, { + open = true, + mixins = { T.mixin(common_schema, { as = "redis_common" }) } + }):doc({ summary = "Redis with legacy server parameter" }) + } +}):doc({ summary = "Redis connection configuration" }) + +PluginSchema.register("mixins.redis", redis_mixin_schema) + local enrich_schema = function(external) - local external_schema = T.table(external, { open = true }) + local external_fields = external or {} return T.one_of({ { name = "common_only", - schema = T.table({}, { + schema = T.table(extend_fields(external_fields), { open = true, mixins = { - T.mixin(common_schema, { as = "redis_common" }), - T.mixin(external_schema, { as = "external" }) + T.mixin(common_schema, { as = "redis" }) } }) }, { name = "read_servers", - schema = T.table({ + schema = T.table(extend_fields(external_fields, { read_servers = T.one_of({ T.string(), T.array(T.string()) - }), - }, { + }) + }), { open = true, mixins = { - T.mixin(common_schema, { as = "redis_common" }), - T.mixin(external_schema, { as = "external" }) + T.mixin(common_schema, { as = "redis" }) } }) }, { name = "write_servers", - schema = T.table({ + schema = T.table(extend_fields(external_fields, { write_servers = T.one_of({ T.string(), T.array(T.string()) - }), - }, { + }) + }), { open = true, mixins = { - T.mixin(common_schema, { as = "redis_common" }), - T.mixin(external_schema, { as = "external" }) + T.mixin(common_schema, { as = "redis" }) } }) }, { name = "rw_servers", - schema = T.table({ + schema = T.table(extend_fields(external_fields, { read_servers = T.one_of({ T.string(), T.array(T.string()) @@ -124,42 +215,39 @@ local enrich_schema = function(external) write_servers = T.one_of({ T.string(), T.array(T.string()) - }), - }, { + }) + }), { open = true, mixins = { - T.mixin(common_schema, { as = "redis_common" }), - T.mixin(external_schema, { as = "external" }) + T.mixin(common_schema, { as = "redis" }) } }) }, { name = "servers", - schema = T.table({ + schema = T.table(extend_fields(external_fields, { servers = T.one_of({ T.string(), T.array(T.string()) - }), - }, { + }) + }), { open = true, mixins = { - T.mixin(common_schema, { as = "redis_common" }), - T.mixin(external_schema, { as = "external" }) + T.mixin(common_schema, { as = "redis" }) } }) }, { name = "server_legacy", - schema = T.table({ + schema = T.table(extend_fields(external_fields, { server = T.one_of({ T.string(), T.array(T.string()) - }), - }, { + }) + }), { open = true, mixins = { - T.mixin(common_schema, { as = "redis_common" }), - T.mixin(external_schema, { as = "external" }) + T.mixin(common_schema, { as = "redis" }) } }) } diff --git a/lualib/lua_shape/registry.lua b/lualib/lua_shape/registry.lua index 88eb57c82b..39ae83d790 100644 --- a/lualib/lua_shape/registry.lua +++ b/lualib/lua_shape/registry.lua @@ -54,6 +54,14 @@ function Registry:define(id, schema) error("Schema already defined: " .. id) end + -- Set schema_id in opts if not already set + if not schema.opts then + schema.opts = {} + end + if not schema.opts.schema_id then + schema.opts.schema_id = id + end + -- Resolve mixins if this is a table schema local resolved = self:resolve_schema(schema) @@ -84,9 +92,9 @@ function Registry:resolve_schema(schema) local tag = schema.tag -- If already resolved, return from cache - local cache_key = tostring(schema) - if self.resolved_cache[cache_key] then - return self.resolved_cache[cache_key] + -- Use the schema table itself as key (works with weak tables) + if self.resolved_cache[schema] then + return self.resolved_cache[schema] end -- Handle reference nodes @@ -146,7 +154,7 @@ function Registry:resolve_schema(schema) -- Create new table schema with merged fields local resolved = shallowcopy(schema) resolved.fields = merged_fields - self.resolved_cache[cache_key] = resolved + self.resolved_cache[schema] = resolved return resolved end end @@ -157,7 +165,7 @@ function Registry:resolve_schema(schema) if resolved_item ~= schema.item_schema then local resolved = shallowcopy(schema) resolved.item_schema = resolved_item - self.resolved_cache[cache_key] = resolved + self.resolved_cache[schema] = resolved return resolved end end @@ -182,7 +190,7 @@ function Registry:resolve_schema(schema) if changed then local resolved = shallowcopy(schema) resolved.variants = resolved_variants - self.resolved_cache[cache_key] = resolved + self.resolved_cache[schema] = resolved return resolved end end @@ -193,7 +201,7 @@ function Registry:resolve_schema(schema) if resolved_inner ~= schema.inner then local resolved = shallowcopy(schema) resolved.inner = resolved_inner - self.resolved_cache[cache_key] = resolved + self.resolved_cache[schema] = resolved return resolved end end diff --git a/lualib/rspamadm/confighelp.lua b/lualib/rspamadm/confighelp.lua index 38b26b6fc9..36161c10f2 100644 --- a/lualib/rspamadm/confighelp.lua +++ b/lualib/rspamadm/confighelp.lua @@ -5,6 +5,7 @@ local known_attrs = { type = 1, required = 1, default = 1, + mixins = 1, } local argparse = require "argparse" local ansicolors = require "ansicolors" @@ -93,6 +94,14 @@ local function print_help(key, value, tabs) if value['default'] then print(string.format('%s\tDefault: %s', tabs, value['default'])) end + if value['mixins'] then + local mixin_names = {} + for _, mixin in ipairs(value['mixins']) do + table.insert(mixin_names, mixin.name or mixin.schema_id or 'unknown') + end + print(string.format('%s\tMixins: %s', tabs, table.concat(mixin_names, ', '))) + print(string.format('%s\t(Use `rspamadm confighelp ` for details)', tabs)) + end if not opts['no-examples'] and value['example'] then local nv = string.match(value['example'], '^%s*(.*[^%s])%s*$') or value.example print(string.format('%s\tExample:\n%s', tabs, nv)) diff --git a/lualib/rspamadm/confighelp_plugins.lua b/lualib/rspamadm/confighelp_plugins.lua new file mode 100644 index 0000000000..d5d822e650 --- /dev/null +++ b/lualib/rspamadm/confighelp_plugins.lua @@ -0,0 +1,147 @@ +--[[ +Copyright (c) 2025, Vsevolod Stakhov + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +]]-- + +-- Convert plugin schemas to UCL format for confighelp + +local function convert_schema(node) + if not node then return {} end + + local res = {} + if node.summary then res.data = node.summary end + if node.description and not res.data then res.data = node.description end + if node.examples and node.examples[1] then res.example = node.examples[1] end + if node.default ~= nil then res.default = node.default end + if node.optional ~= nil then res.required = not node.optional end + + if node.type == 'scalar' then + res.type = node.kind or 'string' + elseif node.type == 'table' then + res.type = 'object' + else + res.type = node.type + end + + if node.type == 'table' then + local mixins = {} + local mixin_field_names = {} + for _, group in ipairs(node.mixin_groups or {}) do + local entry = { name = group.mixin_name or group.schema_id, schema_id = group.schema_id } + table.insert(mixins, entry) + for _, mixin_field in ipairs(group.fields or {}) do + mixin_field_names[mixin_field.name] = true + end + end + if #mixins > 0 then res.mixins = mixins end + + for _, field in ipairs(node.fields or {}) do + if not mixin_field_names[field.name] then + local child = convert_schema(field.schema) + child.required = not field.optional + if field.schema and field.schema.summary and not child.data then + child.data = field.schema.summary + end + res[field.name] = child + end + end + + if node.extra_schema then + local extra_child = convert_schema(node.extra_schema) + if not extra_child.data then extra_child.data = 'Entry schema' end + res['entry'] = extra_child + end + + elseif node.type == 'array' and node.item_schema then + res.item = convert_schema(node.item_schema) + + elseif node.type == 'one_of' then + local variants = {} + local common_fields = {} + local idx = 1 + + -- Extract fields common to all variants + local first_variant_fields = nil + local all_have_common = true + for _, variant in ipairs(node.variants or {}) do + if variant.type == 'table' and variant.fields then + local field_map = {} + for _, field in ipairs(variant.fields) do + field_map[field.name] = field + end + if first_variant_fields == nil then + first_variant_fields = field_map + else + -- Check which fields are common + for fname, _ in pairs(first_variant_fields) do + if not field_map[fname] then + first_variant_fields[fname] = nil + end + end + end + else + all_have_common = false + end + end + + -- Convert common fields + if first_variant_fields and all_have_common then + for fname, field in pairs(first_variant_fields) do + local child = convert_schema(field.schema) + child.required = not field.optional + if field.schema and field.schema.summary and not child.data then + child.data = field.schema.summary + end + common_fields[fname] = true + res[fname] = child + end + end + + -- Convert variants, excluding common fields + for _, variant in ipairs(node.variants or {}) do + local vname = variant.name or ('variant_' .. tostring(idx)) + local converted_variant = convert_schema(variant) + + -- Remove common fields from variant + for fname, _ in pairs(common_fields) do + converted_variant[fname] = nil + end + + variants[vname] = converted_variant + idx = idx + 1 + end + res.options = variants + end + + return res +end + +return function() + local Registry = require 'lua_shape.registry' + local docs = require 'lua_shape.docs' + local ucl = require 'ucl' + + local reg = Registry.global() + if not reg then return nil end + + local exported = docs.for_registry(reg) + if not exported or not exported.schemas then return nil end + + local converted = {} + for id, schema in pairs(exported.schemas) do + converted[id] = convert_schema(schema) + end + + return ucl.to_format({ schemas = converted }, 'json-compact') +end diff --git a/src/rspamadm/confighelp.c b/src/rspamadm/confighelp.c index 196324d130..04c8845a16 100644 --- a/src/rspamadm/confighelp.c +++ b/src/rspamadm/confighelp.c @@ -14,6 +14,7 @@ * limitations under the License. */ #include +#include #include "config.h" #include "rspamadm.h" #include "cfg_file.h" @@ -36,6 +37,10 @@ static void rspamadm_confighelp(int argc, char **argv, static const char *rspamadm_confighelp_help(gboolean full_help, const struct rspamadm_command *cmd); +static ucl_object_t *rspamadm_confighelp_load_plugins_doc(struct rspamd_config *cfg); +static const ucl_object_t *rspamadm_confighelp_lookup_plugin_doc(ucl_object_t *plugins_doc, + const char *key); + struct rspamadm_command confighelp_command = { .name = "confighelp", .flags = 0, @@ -189,6 +194,107 @@ rspamadm_confighelp_search_word(const ucl_object_t *obj, const char *str) return res; } +static ucl_object_t * +rspamadm_confighelp_load_plugins_doc(struct rspamd_config *cfg) +{ + lua_State *L = cfg->lua_state; + struct ucl_parser *parser; + ucl_object_t *doc = NULL; + const char *json; + size_t len; + + /* Load the confighelp_plugins module */ + lua_getglobal(L, "require"); + lua_pushstring(L, "rspamadm.confighelp_plugins"); + + if (lua_pcall(L, 1, 1, 0) != LUA_OK) { + rspamd_fprintf(stderr, "cannot load confighelp_plugins module: %s\n", + lua_tostring(L, -1)); + lua_pop(L, 1); + return NULL; + } + + /* Module should return a function, call it */ + if (!lua_isfunction(L, -1)) { + rspamd_fprintf(stderr, "confighelp_plugins module should return a function\n"); + lua_pop(L, 1); + return NULL; + } + + if (lua_pcall(L, 0, 1, 0) != LUA_OK) { + rspamd_fprintf(stderr, "cannot execute confighelp_plugins function: %s\n", + lua_tostring(L, -1)); + lua_pop(L, 1); + return NULL; + } + + /* Check result */ + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + return NULL; + } + + json = lua_tolstring(L, -1, &len); + if (json == NULL) { + lua_pop(L, 1); + return NULL; + } + + /* Parse JSON result */ + parser = ucl_parser_new(0); + if (parser == NULL) { + lua_pop(L, 1); + return NULL; + } + + if (!ucl_parser_add_chunk(parser, json, len)) { + rspamd_fprintf(stderr, "cannot parse plugin registry docs: %s\n", + ucl_parser_get_error(parser)); + ucl_parser_free(parser); + lua_pop(L, 1); + return NULL; + } + + doc = ucl_parser_get_object(parser); + ucl_parser_free(parser); + lua_pop(L, 1); + + return doc; +} + +static const ucl_object_t * +rspamadm_confighelp_lookup_plugin_doc(ucl_object_t *plugins_doc, const char *key) +{ + const ucl_object_t *schemas, *elt; + + if (plugins_doc == NULL || key == NULL) { + return NULL; + } + + schemas = ucl_object_lookup(plugins_doc, "schemas"); + if (schemas == NULL) { + return NULL; + } + + elt = ucl_object_lookup(schemas, key); + if (elt == NULL) { + const char *prefixes[] = {"plugins.", "mixins."}; + gsize i; + for (i = 0; i < G_N_ELEMENTS(prefixes) && elt == NULL; i++) { + const char *pref = prefixes[i]; + size_t plen = strlen(pref); + if (strncmp(key, pref, plen) == 0) { + continue; + } + gchar *tmp = g_strdup_printf("%s%s", pref, key); + elt = ucl_object_lookup(schemas, tmp); + g_free(tmp); + } + } + + return elt; +} + __attribute__((noreturn)) static void rspamadm_confighelp(int argc, char **argv, const struct rspamadm_command *cmd) { @@ -201,6 +307,7 @@ rspamadm_confighelp(int argc, char **argv, const struct rspamadm_command *cmd) worker_t **pworker; struct module_ctx *mod_ctx; int i, ret = 0, processed_args = 0; + ucl_object_t *plugins_doc = NULL; context = g_option_context_new( "confighelp - displays help for the configuration options"); @@ -266,13 +373,26 @@ rspamadm_confighelp(int argc, char **argv, const struct rspamadm_command *cmd) argv[i]); } else { - doc_obj = ucl_object_typed_new(UCL_OBJECT); + doc_obj = NULL; elt = ucl_object_lookup_path(cfg->doc_strings, argv[i]); if (elt) { + doc_obj = ucl_object_typed_new(UCL_OBJECT); ucl_object_insert_key(doc_obj, ucl_object_ref(elt), argv[i], 0, false); } + else { + const ucl_object_t *plugin_doc = NULL; + if (plugins_doc == NULL) { + plugins_doc = rspamadm_confighelp_load_plugins_doc(cfg); + } + plugin_doc = rspamadm_confighelp_lookup_plugin_doc(plugins_doc, argv[i]); + if (plugin_doc) { + doc_obj = ucl_object_typed_new(UCL_OBJECT); + ucl_object_insert_key(doc_obj, ucl_object_ref(plugin_doc), + argv[i], 0, false); + } + } } if (doc_obj != NULL) { @@ -295,6 +415,10 @@ rspamadm_confighelp(int argc, char **argv, const struct rspamadm_command *cmd) rspamadm_confighelp_show(cfg, argc, argv, NULL, cfg->doc_strings); } + if (plugins_doc) { + ucl_object_unref(plugins_doc); + } + rspamd_config_free(cfg); exit(ret);