public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Shannon Sterz <s.sterz@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH proxmox v2 4/5] docgen: add docgen crate
Date: Thu, 13 Nov 2025 13:00:16 +0100	[thread overview]
Message-ID: <20251113120021.331639-5-s.sterz@proxmox.com> (raw)
In-Reply-To: <20251113120021.331639-1-s.sterz@proxmox.com>

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 <s.sterz@proxmox.com>
---
 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 <support@proxmox.com>  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 <!nocheck>,
+ rustc:native (>= 1.82) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-proxmox-router-3+default-dev (>= 3.2.2-~~) <!nocheck>,
+ librust-proxmox-schema-5+default-dev (>= 5.0.1-~~) <!nocheck>,
+ librust-serde-json-1+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+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 <support@proxmox.com>
+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 <https://www.gnu.org/licenses/>.
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 <support@proxmox.com>"
+
+[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<String> =
+                        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<Value> = list
+                .iter()
+                .map(|p| dump_api_permission(p, privileges))
+                .collect();
+            json!({ "and": list })
+        }
+        Permission::Or(list) => {
+            let list: Vec<Value> = 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


  parent reply	other threads:[~2025-11-13 11:59 UTC|newest]

Thread overview: 10+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-11-13 12:00 [pdm-devel] [PATCH datacenter-manager/proxmox{, -backup}/widget-toolkit v2 0/9] unstable flag and pdm api viewer Shannon Sterz
2025-11-13 12:00 ` [pdm-devel] [PATCH proxmox v2 1/5] router/api-macro: add unstable flag for ApiMethod Shannon Sterz
2025-11-13 12:00 ` [pdm-devel] [PATCH proxmox v2 2/5] pve-api-types: generate array objects Shannon Sterz
2025-11-13 12:00 ` [pdm-devel] [PATCH proxmox v2 3/5] pve-api-types: fix clippy lints Shannon Sterz
2025-11-13 12:00 ` Shannon Sterz [this message]
2025-11-13 12:00 ` [pdm-devel] [PATCH proxmox v2 5/5] docgen: add support for the new stable flag Shannon Sterz
2025-11-13 12:00 ` [pdm-devel] [PATCH widget-toolkit v2 1/1] api viewer: add support for endpoints that are marked as unstable Shannon Sterz
2025-11-13 12:00 ` [pdm-devel] [PATCH proxmox-backup v2 1/1] docgen: use proxmox-rs docgen crate Shannon Sterz
2025-11-13 12:00 ` [pdm-devel] [PATCH datacenter-manager v2 1/2] docgen: switch to " Shannon Sterz
2025-11-13 12:00 ` [pdm-devel] [PATCH datacenter-manager v2 2/2] api-viewer: add an api-viewer package Shannon Sterz

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20251113120021.331639-5-s.sterz@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal