From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 5BC8F1FF17E for ; Thu, 13 Nov 2025 12:59:40 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 6E77719FB3; Thu, 13 Nov 2025 13:00:32 +0100 (CET) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Date: Thu, 13 Nov 2025 13:00:16 +0100 Message-ID: <20251113120021.331639-5-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251113120021.331639-1-s.sterz@proxmox.com> References: <20251113120021.331639-1-s.sterz@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1763035198183 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.062 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_SHORT 0.001 Use of a URL Shortener for very short URL SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH proxmox v2 4/5] docgen: add docgen crate X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" currently this is limited to dumping a `Router` into a `Value` which can be serialized to JSON and interpreted by an api viewer. Signed-off-by: Shannon Sterz --- Cargo.toml | 1 + proxmox-docgen/Cargo.toml | 20 ++ proxmox-docgen/debian/changelog | 5 + proxmox-docgen/debian/control | 36 ++++ proxmox-docgen/debian/copyright | 18 ++ proxmox-docgen/debian/debcargo.toml | 7 + proxmox-docgen/src/lib.rs | 322 ++++++++++++++++++++++++++++ 7 files changed, 409 insertions(+) create mode 100644 proxmox-docgen/Cargo.toml create mode 100644 proxmox-docgen/debian/changelog create mode 100644 proxmox-docgen/debian/control create mode 100644 proxmox-docgen/debian/copyright create mode 100644 proxmox-docgen/debian/debcargo.toml create mode 100644 proxmox-docgen/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index c9d734e6..9a4cf1b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "proxmox-config-digest", "proxmox-daemon", "proxmox-dns-api", + "proxmox-docgen", "proxmox-http", "proxmox-http-error", "proxmox-human-byte", diff --git a/proxmox-docgen/Cargo.toml b/proxmox-docgen/Cargo.toml new file mode 100644 index 00000000..84a515de --- /dev/null +++ b/proxmox-docgen/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "proxmox-docgen" +description = "Library to easily create documentation from Rust sources for Proxmox projects." +version = "1.0.0" + +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +exclude.workspace = true +rust-version.workspace = true + +[dependencies] +serde_json.workspace = true + +proxmox-router.workspace = true +proxmox-schema = { workspace = true } + + diff --git a/proxmox-docgen/debian/changelog b/proxmox-docgen/debian/changelog new file mode 100644 index 00000000..79b7e602 --- /dev/null +++ b/proxmox-docgen/debian/changelog @@ -0,0 +1,5 @@ +rust-proxmox-docgen (1.0.0-1) trixie; urgency=medium + + * Initial packaging + + -- Proxmox Support Team Mon, 10 Nov 2025 10:44:26 +0200 diff --git a/proxmox-docgen/debian/control b/proxmox-docgen/debian/control new file mode 100644 index 00000000..4110e9e0 --- /dev/null +++ b/proxmox-docgen/debian/control @@ -0,0 +1,36 @@ +Source: rust-proxmox-docgen +Section: rust +Priority: optional +Build-Depends: debhelper-compat (= 13), + dh-sequence-cargo +Build-Depends-Arch: cargo:native , + rustc:native (>= 1.82) , + libstd-rust-dev , + librust-proxmox-router-3+default-dev (>= 3.2.2-~~) , + librust-proxmox-schema-5+default-dev (>= 5.0.1-~~) , + librust-serde-json-1+default-dev +Maintainer: Proxmox Support Team +Standards-Version: 4.7.2 +Vcs-Git: git://git.proxmox.com/git/proxmox.git +Vcs-Browser: https://git.proxmox.com/?p=proxmox.git +Homepage: https://proxmox.com +X-Cargo-Crate: proxmox-docgen + +Package: librust-proxmox-docgen-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-router-3+default-dev (>= 3.2.2-~~), + librust-proxmox-schema-5+default-dev (>= 5.0.1-~~), + librust-serde-json-1+default-dev +Provides: + librust-proxmox-docgen+default-dev (= ${binary:Version}), + librust-proxmox-docgen-1-dev (= ${binary:Version}), + librust-proxmox-docgen-1+default-dev (= ${binary:Version}), + librust-proxmox-docgen-1.0-dev (= ${binary:Version}), + librust-proxmox-docgen-1.0+default-dev (= ${binary:Version}), + librust-proxmox-docgen-1.0.0-dev (= ${binary:Version}), + librust-proxmox-docgen-1.0.0+default-dev (= ${binary:Version}) +Description: Easily create documentation from Rust sources for Proxmox projects - Rust source code + Source code for Debianized Rust crate "proxmox-docgen" diff --git a/proxmox-docgen/debian/copyright b/proxmox-docgen/debian/copyright new file mode 100644 index 00000000..d6e3c304 --- /dev/null +++ b/proxmox-docgen/debian/copyright @@ -0,0 +1,18 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: + * +Copyright: 2025 Proxmox Server Solutions GmbH +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + details. + . + You should have received a copy of the GNU Affero General Public License along + with this program. If not, see . diff --git a/proxmox-docgen/debian/debcargo.toml b/proxmox-docgen/debian/debcargo.toml new file mode 100644 index 00000000..b7864cdb --- /dev/null +++ b/proxmox-docgen/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +vcs_git = "git://git.proxmox.com/git/proxmox.git" +vcs_browser = "https://git.proxmox.com/?p=proxmox.git" diff --git a/proxmox-docgen/src/lib.rs b/proxmox-docgen/src/lib.rs new file mode 100644 index 00000000..b3ef1ff8 --- /dev/null +++ b/proxmox-docgen/src/lib.rs @@ -0,0 +1,322 @@ +use serde_json::{json, Value}; + +use proxmox_router::{ApiAccess, ApiHandler, ApiMethod, Permission, Router, SubRoute}; +use proxmox_schema::format::get_property_string_type_text; +use proxmox_schema::{ApiStringFormat, ObjectSchemaType, Schema}; + +pub fn generate_api_tree(router: &Router, path: &str, privileges: &[(&str, u64)]) -> Value { + let mut data = dump_api_schema(router, path, privileges); + data["expanded"] = true.into(); + data +} + +fn dump_schema(schema: &Schema) -> Value { + let mut data; + + match schema { + Schema::Null => { + data = json!({ + "type": "null", + }); + } + Schema::Boolean(boolean_schema) => { + data = json!({ + "type": "boolean", + "description": boolean_schema.description, + }); + if let Some(default) = boolean_schema.default { + data["default"] = default.into(); + } + } + Schema::String(string_schema) => { + data = json!({ + "type": "string", + "description": string_schema.description, + }); + if let Some(default) = string_schema.default { + data["default"] = default.into(); + } + if let Some(min_length) = string_schema.min_length { + data["minLength"] = min_length.into(); + } + if let Some(max_length) = string_schema.max_length { + data["maxLength"] = max_length.into(); + } + if let Some(type_text) = string_schema.type_text { + data["typetext"] = type_text.into(); + } + match string_schema.format { + None | Some(ApiStringFormat::VerifyFn(_)) => { /* do nothing */ } + Some(ApiStringFormat::Pattern(const_regex)) => { + data["pattern"] = format!("/{}/", const_regex.regex_string).into(); + } + Some(ApiStringFormat::Enum(variants)) => { + let variants: Vec = + variants.iter().map(|e| e.value.to_string()).collect(); + data["enum"] = serde_json::to_value(variants).unwrap(); + } + Some(ApiStringFormat::PropertyString(subschema)) => { + match subschema { + Schema::Object(_) | Schema::Array(_) => { + data["format"] = dump_schema(subschema); + data["typetext"] = get_property_string_type_text(subschema).into(); + } + _ => { /* do nothing - should not happen */ } + }; + } + } + } + Schema::Integer(integer_schema) => { + data = json!({ + "type": "integer", + "description": integer_schema.description, + }); + if let Some(default) = integer_schema.default { + data["default"] = default.into(); + } + if let Some(minimum) = integer_schema.minimum { + data["minimum"] = minimum.into(); + } + if let Some(maximum) = integer_schema.maximum { + data["maximum"] = maximum.into(); + } + } + Schema::Number(number_schema) => { + data = json!({ + "type": "number", + "description": number_schema.description, + }); + if let Some(default) = number_schema.default { + data["default"] = default.into(); + } + if let Some(minimum) = number_schema.minimum { + data["minimum"] = minimum.into(); + } + if let Some(maximum) = number_schema.maximum { + data["maximum"] = maximum.into(); + } + } + Schema::Object(object_schema) => { + data = dump_property_schema(object_schema); + data["type"] = "object".into(); + if let Some(default_key) = object_schema.default_key { + data["default_key"] = default_key.into(); + } + } + Schema::Array(array_schema) => { + data = json!({ + "type": "array", + "description": array_schema.description, + "items": dump_schema(array_schema.items), + }); + if let Some(min_length) = array_schema.min_length { + data["minLength"] = min_length.into(); + } + if let Some(max_length) = array_schema.min_length { + data["maxLength"] = max_length.into(); + } + } + Schema::AllOf(alloff_schema) => { + data = dump_property_schema(alloff_schema); + data["type"] = "object".into(); + } + Schema::OneOf(schema) => { + let mut type_schema = dump_schema(schema.type_schema()); + if schema.type_property_entry.1 { + type_schema["optional"] = true.into(); + } + data = json!({ + "type": "object", + "description": schema.description, + "typeProperty": schema.type_property(), + "typeSchema": type_schema, + }); + let mut variants = Vec::with_capacity(schema.list.len()); + for (title, variant) in schema.list { + let mut entry = dump_schema(variant); + entry["title"] = (*title).into(); + variants.push(entry); + } + data["oneOf"] = variants.into(); + } + }; + + data +} + +fn dump_property_schema(param: &dyn ObjectSchemaType) -> Value { + let mut properties = json!({}); + + for (prop, optional, schema) in param.properties() { + let mut property = dump_schema(schema); + if *optional { + property["optional"] = 1.into(); + } + properties[prop] = property; + } + + let data = json!({ + "description": param.description(), + "additionalProperties": param.additional_properties(), + "properties": properties, + }); + + data +} + +fn dump_api_permission(permission: &Permission, privileges: &[(&str, u64)]) -> Value { + match permission { + Permission::Superuser => json!({ "user": "root@pam" }), + Permission::User(user) => json!({ "user": user }), + Permission::Anybody => json!({ "user": "all" }), + Permission::World => json!({ "user": "world" }), + Permission::UserParam(param) => json!({ "userParam": param }), + Permission::Group(group) => json!({ "group": group }), + Permission::WithParam(param, sub_permission) => { + json!({ + "withParam": { + "name": param, + "permissions": dump_api_permission(sub_permission, privileges), + }, + }) + } + Permission::Privilege(name, value, partial) => { + let mut privs = Vec::new(); + for (name, v) in privileges { + if (value & v) != 0 { + privs.push(name.to_string()); + } + } + + json!({ + "check": { + "path": name, + "privs": privs, + "partial": partial, + } + }) + } + Permission::And(list) => { + let list: Vec = list + .iter() + .map(|p| dump_api_permission(p, privileges)) + .collect(); + json!({ "and": list }) + } + Permission::Or(list) => { + let list: Vec = list + .iter() + .map(|p| dump_api_permission(p, privileges)) + .collect(); + json!({ "or": list }) + } + } +} + +fn dump_api_method_schema( + method: &str, + api_method: &ApiMethod, + privileges: &[(&str, u64)], +) -> Value { + let mut data = json!({ + "description": api_method.parameters.description(), + }); + + data["parameters"] = dump_property_schema(&api_method.parameters); + + let mut returns = dump_schema(api_method.returns.schema); + if api_method.returns.optional { + returns["optional"] = 1.into(); + } + data["returns"] = returns; + + match api_method.access { + ApiAccess { + description: None, + permission: Permission::Superuser, + } => { + // no need to output default + } + ApiAccess { + description, + permission, + } => { + let mut permissions = dump_api_permission(permission, privileges); + if let Some(description) = description { + permissions["description"] = description.into(); + } + data["permissions"] = permissions; + } + } + + let mut method = method; + + if let ApiHandler::AsyncHttp(_) = api_method.handler { + method = if method == "POST" { "UPLOAD" } else { method }; + method = if method == "GET" { "DOWNLOAD" } else { method }; + } + + data["method"] = method.into(); + + data +} + +fn dump_api_schema(router: &Router, path: &str, privileges: &[(&str, u64)]) -> Value { + let mut data = json!({}); + + let mut info = json!({}); + if let Some(api_method) = router.get { + info["GET"] = dump_api_method_schema("GET", api_method, privileges); + } + if let Some(api_method) = router.post { + info["POST"] = dump_api_method_schema("POST", api_method, privileges); + } + if let Some(api_method) = router.put { + info["PUT"] = dump_api_method_schema("PUT", api_method, privileges); + } + if let Some(api_method) = router.delete { + info["DELETE"] = dump_api_method_schema("DELETE", api_method, privileges); + } + + data["info"] = info; + + match &router.subroute { + None => { + data["leaf"] = 1.into(); + } + Some(SubRoute::MatchAll { router, param_name }) => { + let sub_path = if path == "." { + format!("/{{{param_name}}}") + } else { + format!("{path}/{{{param_name}}}") + }; + let mut child = dump_api_schema(router, &sub_path, privileges); + child["path"] = sub_path.into(); + child["text"] = format!("{{{param_name}}}").into(); + + let children = vec![child]; + data["children"] = children.into(); + data["leaf"] = 0.into(); + } + Some(SubRoute::Map(dirmap)) => { + let mut children = Vec::new(); + + for (key, sub_router) in dirmap.iter() { + let sub_path = if path == "." { + format!("/{key}") + } else { + format!("{path}/{key}") + }; + let mut child = dump_api_schema(sub_router, &sub_path, privileges); + child["path"] = sub_path.into(); + child["text"] = key.to_string().into(); + children.push(child); + } + + data["children"] = children.into(); + data["leaf"] = 0.into(); + } + } + + data +} -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel