Repository: truenas/middleware
Pull request #16244: NAS-133560 / 25.10 / Build static ZIP file with the API doc. Add API version switch
Author: login: themylogin
Created: 2025-04-14T10:25:31Z | Merged: 2025-04-14T20:48:47Z



Diff:
diff --git a/src/middlewared/middlewared/api/base/server/doc.py b/src/middlewared/middlewared/api/base/server/doc.py
index 5cc25edbe6f4f..f2bc237e24214 100644
--- a/src/middlewared/middlewared/api/base/server/doc.py
+++ b/src/middlewared/middlewared/api/base/server/doc.py
@@ -12,6 +12,7 @@
 
 class APIDump(BaseModel):
     version: str
+    version_title: str
     methods: list["APIDumpMethod"]
     events: list["APIDumpEvent"]
 
@@ -31,13 +32,19 @@ class APIDumpEvent(BaseModel):
 
 
 class APIDumper:
-    def __init__(self, version: str, api: API, role_manager: RoleManager):
+    def __init__(self, version: str, version_title: str, api: API, role_manager: RoleManager):
         self.version = version
+        self.version_title = version_title
         self.api = api
         self.role_manager = role_manager
 
     def dump(self):
-        return APIDump(version=self.version, methods=self._dump_methods(), events=self._dump_events())
+        return APIDump(
+            version=self.version,
+            version_title=self.version_title,
+            methods=self._dump_methods(),
+            events=self._dump_events(),
+        )
 
     def _dump_methods(self):
         result = []
diff --git a/src/middlewared/middlewared/etc_files/local/nginx/nginx.conf.mako b/src/middlewared/middlewared/etc_files/local/nginx/nginx.conf.mako
index b2ceac014a218..21f44f0a473ee 100644
--- a/src/middlewared/middlewared/etc_files/local/nginx/nginx.conf.mako
+++ b/src/middlewared/middlewared/etc_files/local/nginx/nginx.conf.mako
@@ -83,6 +83,8 @@
         disabled_ciphers = ''
 
     has_tn_connect = middleware.call_sync('tn_connect.config')['certificate'] is not None
+
+    current_api_version = middleware.api_versions[-1].version
 %>
 #
 #    TrueNAS nginx configuration file
@@ -223,6 +225,14 @@ http {
 
         location /api/docs {
             alias /usr/share/middlewared/docs;
+            add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
+            add_header Expires 0;
+        }
+
+        location /api/docs/current {
+            alias /usr/share/middlewared/docs/${current_api_version};
+            add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
+            add_header Expires 0;
         }
 
         location @index {
diff --git a/src/middlewared/middlewared/main.py b/src/middlewared/middlewared/main.py
index 96ad99327bfba..15c5ef9bc24f6 100644
--- a/src/middlewared/middlewared/main.py
+++ b/src/middlewared/middlewared/main.py
@@ -1306,10 +1306,15 @@ def dump_api(self, stream: typing.TextIO):
         self.__plugins_load()
 
         apis = self._load_apis()
+        current_api = apis.pop("current")
 
         result = {"versions": []}
         for version, api in apis.items():
-            result["versions"].append(APIDumper(version, api, self.role_manager).dump().model_dump())
+            version_title = version
+            if api.version == current_api.version:
+                version_title += " (current)"
+
+            result["versions"].append(APIDumper(version, version_title, api, self.role_manager).dump().model_dump())
 
         json.dump(result, stream)
 
diff --git a/src/middlewared_docs/docs/_static/script.js b/src/middlewared_docs/docs/_static/script.js
new file mode 100644
index 0000000000000..299ed8b1926e8
--- /dev/null
+++ b/src/middlewared_docs/docs/_static/script.js
@@ -0,0 +1,3 @@
+function navigateToVersion(version) {
+    document.location = "../" + version;
+}
diff --git a/src/middlewared_docs/docs/conf.py b/src/middlewared_docs/docs/conf.py
index e62e37a7f172f..e511fecf0b724 100644
--- a/src/middlewared_docs/docs/conf.py
+++ b/src/middlewared_docs/docs/conf.py
@@ -42,6 +42,7 @@
 ]
 html_js_files = [
     "language_data.js",
+    "script.js",
 ]
 
 html_show_sphinx = False
diff --git a/src/middlewared_docs/docs/index.rst b/src/middlewared_docs/docs/index.rst
index 3d0a356e74e7b..cb0ff57e86bee 100644
--- a/src/middlewared_docs/docs/index.rst
+++ b/src/middlewared_docs/docs/index.rst
@@ -9,3 +9,5 @@ Contents
    api_events.rst
    jobs.rst
    query_methods.rst
+
+You can also `download <download URL>`_ this documentation.
diff --git a/src/middlewared_docs/generate_docs.py b/src/middlewared_docs/generate_docs.py
index 2dcc7fdb86ae2..af66d86760a45 100644
--- a/src/middlewared_docs/generate_docs.py
+++ b/src/middlewared_docs/generate_docs.py
@@ -1,4 +1,6 @@
 # -*- coding=utf-8 -*-
+import tempfile
+
 import json
 import logging
 import os
@@ -29,7 +31,7 @@ def generate(self):
         with open(f"{self.output_dir}/conf.py") as f:
             conf = f.read()
 
-        conf = conf.replace("$VERSION", self.api.version)
+        conf = conf.replace("$VERSION", self.api.version_title)
 
         with open(f"{self.output_dir}/conf.py", "w") as f:
             f.write(conf)
@@ -185,6 +187,10 @@ def _api_event_html_process(self, soup: BeautifulSoup):
         pass
 
 
+def docs_filename(version: str):
+    return f"truenas-{version}-docs.zip"
+
+
 def main(output_dir):
     data = json.loads(
         subprocess.run(
@@ -194,12 +200,19 @@ def main(output_dir):
             text=True,
         ).stdout
     )
-    for api_dump in data["versions"]:
-        api = APIDump.model_validate(api_dump)
-
+    apis = [APIDump.model_validate(api_dump) for api_dump in data["versions"]]
+    for api in apis:
         rst_dir = f"{output_dir}/rst/{api.version}"
         DocumentationGenerator(api, rst_dir).generate()
 
+        with open(f"{rst_dir}/index.rst") as f:
+            index = f.read()
+
+        index = index.replace("<download URL>", f"<{docs_filename(api.version)}>")
+
+        with open(f"{rst_dir}/index.rst", "w") as f:
+            f.write(index)
+
         build_dir = f"{output_dir}/html/{api.version}"
         subprocess.run(
             [
@@ -216,17 +229,43 @@ def main(output_dir):
     shutil.rmtree(f"{output_dir}/rst")
     shutil.rmtree(f"{output_dir}/html")
 
-    for root, dirs, files in os.walk(output_dir):
-        for filename in files:
-            if filename.endswith(".html"):
-                with open(f"{root}/{filename}") as f:
-                    contents = f.read()
-
-                # Make sphinx show a summary of the search result (sphinxbootstrap4theme breaks this)
-                contents = contents.replace('<div class="bodywrapper">', '<div class="bodywrapper" role="main">')
-
-                with open(f"{root}/{filename}", "w") as f:
-                    f.write(contents)
+    for version in os.listdir(output_dir):
+        cwd = f"{output_dir}/{version}"
+
+        for root, dirs, files in os.walk(cwd):
+            for filename in files:
+                if filename.endswith(".html"):
+                    with open(f"{root}/{filename}") as f:
+                        contents = f.read()
+
+                    # Version switch
+                    version_switch = [
+                        (
+                            f'<option value="{api.version}" {"selected" if api.version == version else ""}>' +
+                            f'{api.version_title}' +
+                            '</option>'
+                        )
+                        for api in sorted(apis, key=lambda api: api.version, reverse=True)
+                    ]
+                    version_switch = (
+                        '<form class="form-inline">'
+                        '<select class="form-control" onchange="navigateToVersion(this.value);">' +
+                        ''.join(version_switch) +
+                        '</select>'
+                        '</form>'
+                    )
+                    navbar = '<ul class="navbar-nav mr-auto">'
+                    contents = contents.replace(navbar, version_switch + navbar)
+
+                    # Make sphinx show a summary of the search result (sphinxbootstrap4theme breaks this)
+                    contents = contents.replace('<div class="bodywrapper">', '<div class="bodywrapper" role="main">')
+
+                    with open(f"{root}/{filename}", "w") as f:
+                        f.write(contents)
+
+        with tempfile.NamedTemporaryFile() as tf:
+            shutil.make_archive(tf.name, "zip", cwd)
+            shutil.move(f"{tf.name}.zip", cwd + "/" + docs_filename(version))
 
 
 if __name__ == "__main__":


Reviews:
user: login: yocalebo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T11:29:35Z
user: login: creatorcary
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:28:27Z
user: login: themylogin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T19:37:12Z
user: login: creatorcary
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T20:20:26Z

Review comments:
diff_hunk: @@ -216,17 +227,45 @@ def main(output_dir):
     shutil.rmtree(f"{output_dir}/rst")
     shutil.rmtree(f"{output_dir}/html")
 
-    for root, dirs, files in os.walk(output_dir):
-        for filename in files:
-            if filename.endswith(".html"):
-                with open(f"{root}/{filename}") as f:
-                    contents = f.read()
-
-                # Make sphinx show a summary of the search result (sphinxbootstrap4theme breaks this)
-                contents = contents.replace('<div class="bodywrapper">', '<div class="bodywrapper" role="main">')
+    for version in os.listdir(output_dir):
+        cwd = f"{output_dir}/{version}"
+
+        for root, dirs, files in os.walk(cwd):
+            for filename in files:
+                if filename.endswith(".html"):
+                    with open(f"{root}/{filename}") as f:
+                        contents = f.read()
+
+                    # Version switch
+                    version_switch = [
+                        (
+                            f'<option value="{api.version}" {"selected" if api.version == version else ""}>' +
+                            f'{api.version_title}' +
+                            '</option>'
+                        )
+                        for api in sorted(apis, key=lambda api: api.version, reverse=True)
+                    ]
+                    version_switch = (
+                        '<form class="form-inline">'
+                        '<select class="form-control" onchange="navigateToVersion(this.value);">' +
+                        ''.join(version_switch) +
+                        '</select>'
+                        '</form>'
+                    )
+                    navbar = '<ul class="navbar-nav mr-auto">'
+                    contents = contents.replace(navbar, version_switch + navbar)
+
+                    # Make sphinx show a summary of the search result (sphinxbootstrap4theme breaks this)
+                    contents = contents.replace('<div class="bodywrapper">', '<div class="bodywrapper" role="main">')
+
+                    with open(f"{root}/{filename}", "w") as f:
+                        f.write(contents)
 
-                with open(f"{root}/{filename}", "w") as f:
-                    f.write(contents)
+        subprocess.run(
+            ["zip", "-r", docs_filename(version)] + os.listdir(cwd),
path: src/middlewared_docs/generate_docs.py
user: login: yocalebo
body: Why not `zipfile` module or `shutil.make_archive`?
created_at: 2025-04-14T11:29:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,6 +225,14 @@ http {
 
         location /api/docs {
             alias /usr/share/middlewared/docs;
+            add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
+            add_header Expires 0;
+        }
+
+        location /api/docs/current {
+            alias /usr/share/middlewared/docs/${current_api_version};
+            add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
+            add_header Expires 0;
path: src/middlewared/middlewared/etc_files/local/nginx/nginx.conf.mako
user: login: creatorcary
body: Do we need to prevent caching? It seems like the contents of docs pages wouldn't change much?
created_at: 2025-04-14T15:19:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,6 +225,14 @@ http {
 
         location /api/docs {
             alias /usr/share/middlewared/docs;
+            add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
+            add_header Expires 0;
+        }
+
+        location /api/docs/current {
+            alias /usr/share/middlewared/docs/${current_api_version};
+            add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
+            add_header Expires 0;
path: src/middlewared/middlewared/etc_files/local/nginx/nginx.conf.mako
user: login: themylogin
body: It will between TrueNAS versions, especially the /current one
created_at: 2025-04-14T19:37:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: themylogin
created_at: 2025-04-14T10:25:38Z
body: time 4:00
author_association: CONTRIBUTOR
user: login: bugclerk
created_at: 2025-04-14T10:25:55Z
body: Jira URL: https://ixsystems.atlassian.net/browse/NAS-133560
author_association: CONTRIBUTOR
user: login: themylogin
created_at: 2025-04-14T20:06:24Z
body: time 0:45
author_association: CONTRIBUTOR
user: login: bugclerk
created_at: 2025-04-14T20:49:04Z
body: This PR has been merged and conversations have been locked.
If you would like to discuss more about this issue please use our forums or raise a Jira ticket.

author_association: CONTRIBUTOR