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
next prev 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