Repository: jupyterhub/jupyterhub

Issue:
try redoc for REST API
Updating the openapi spec in #4594, I noticed our generated spec doesn't display any of the auth info, which led me to poke around, looking for another way to render the openapi spec.

That led me to [redocly-cli](https://redocly.com/redocly-cli/), which can build a single static HTML page from openapi pretty easily:

```
npx @redocly/cli build-docs docs/source/_static/rest-api.yml
```

and it looks like this:

<img width="1224" alt="Screenshot 2024-01-08 at 12 08 23" src="https://github.com/jupyterhub/jupyterhub/assets/151929/4d93c287-d27e-4a26-a22e-c27dfabb10d6">


being a static HTML page, rather than an on-the-fly javascript render should make anchor links, etc. more reliable. Downside is that we'd be back to a javascript build step for the docs, like we had with bootprint (removed in #3665).

Apparently redoc can also [run in-page](https://redocly.com/docs/redoc/deployment/html/), which would change very little from what we have now. That's less likely to fix anchor links, but easy to check.


Discussion:
Sounds good to me! I've found it frustrating trying to link to the API when answering support posts. I [tried sphinx-openapi a few months ago](https://github.com/manics/jupyterhub/commit/5b16a52e46b0db265e7fef5ac05978abb6e718e5) but it didn't render properly.

I think we could make the choice of embedding in the page vs a standalone page once we try it based on what's clearest and most usable.


I've been using this to test changes in, #4594, and I like it so far. I don't want to add too much to that huge PR, but I'll do a standalone one for redocly after that lands.

Resolving patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -6,14 +6,20 @@
 import datetime
 import io
 import os
+import re
 import subprocess
+from pathlib import Path
+from urllib.request import urlretrieve
 
 from docutils import nodes
+from ruamel.yaml import YAML
 from sphinx.directives.other import SphinxDirective
+from sphinx.util import logging
 
 import jupyterhub
 from jupyterhub.app import JupyterHub
 
+logger = logging.getLogger(__name__)
 # -- Project information -----------------------------------------------------
 # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
 #
@@ -42,6 +48,10 @@
 # default_role let's use use `foo` instead of ``foo`` in rST
 default_role = "literal"
 
+docs = Path(__file__).parent.parent.absolute()
+docs_source = docs / "source"
+rest_api_yaml = docs_source / "_static" / "rest-api.yml"
+
 
 # -- MyST configuration ------------------------------------------------------
 # ref: https://myst-parser.readthedocs.io/en/latest/configuration.html
@@ -119,10 +129,102 @@ def run(self):
         return [par]
 
 
+class RestAPILinksDirective(SphinxDirective):
+    """Directive to populate link targets for the REST API
+
+    The resulting nodes resolve xref targets,
+    but are not actually rendered in the final result
+    which is handled by a custom template.
+    """
+
+    has_content = False
+    required_arguments = 0
+    optional_arguments = 0
+    final_argument_whitespace = False
+    option_spec = {}
+
+    def run(self):
+        targets = []
+        yaml = YAML(typ="safe")
+        with rest_api_yaml.open() as f:
+            api = yaml.load(f)
+        for path, path_spec in api["paths"].items():
+            for method, operation in path_spec.items():
+                operation_id = operation.get("operationId")
+                if not operation_id:
+                    logger.warning(f"No operation id for {method} {path}")
+                    continue
+                # 'id' is the id on the page (must match redoc anchor)
+                # 'name' is the name of the ref for use in our documents
+                target = nodes.target(
+                    ids=[f"operation/{operation_id}"],
+                    names=[f"rest-api-{operation_id}"],
+                )
+                targets.append(target)
+                self.state.document.note_explicit_target(target, target)
+
+        return targets
+
+
+templates_path = ["_templates"]
+
+
+def stage_redoc_js(app, exception):
+    """Download redoc.js to our static files"""
+    if app.builder.name != "html":
+        logger.info(f"Skipping redoc download for builder: {app.builder.name}")
+        return
+
+    out_static = Path(app.builder.outdir) / "_static"
+
+    redoc_version = "2.1.3"
+    redoc_url = (
+        f"https://cdn.redoc.ly/redoc/v{redoc_version}/bundles/redoc.standalone.js"
+    )
+    dest = out_static / "redoc.js"
+    if not dest.exists():
+        logger.info(f"Downloading {redoc_url} -> {dest}")
+        urlretrieve(redoc_url, dest)
+
+    # stage fonts for redoc from google fonts
+    fonts_css_url = "https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
+    fonts_css_file = out_static / "redoc-fonts.css"
+    fonts_dir = out_static / "fonts"
+    fonts_dir.mkdir(exist_ok=True)
+    if not fonts_css_file.exists():
+        logger.info(f"Downloading {fonts_css_url} -> {fonts_css_file}")
+        urlretrieve(fonts_css_url, fonts_css_file)
+
+    # For each font external font URL,
+    # download the font and rewrite to a local URL
+    # The downloaded TTF fonts have license info in their metadata
+    with open(fonts_css_file) as f:
+        fonts_css = f.read()
+
+    fonts_css_changed = False
+    for font_url in re.findall(r'url\((https?[^\)]+)\)', fonts_css):
+        fonts_css_changed = True
+        filename = font_url.rpartition("/")[-1]
+        dest = fonts_dir / filename
+        local_url = str(dest.relative_to(fonts_css_file.parent))
+        fonts_css = fonts_css.replace(font_url, local_url)
+        if not dest.exists():
+            logger.info(f"Downloading {font_url} -> {dest}")
+            urlretrieve(font_url, dest)
+
+    if fonts_css_changed:
+        # rewrite font css with local URLs
+        with open(fonts_css_file, "w") as f:
+            logger.info(f"Rewriting URLs in {fonts_css_file}")
+            f.write(fonts_css)
+
+
 def setup(app):
+    app.connect("build-finished", stage_redoc_js)
     app.add_css_file("custom.css")
     app.add_directive("jupyterhub-generate-config", ConfigDirective)
     app.add_directive("jupyterhub-help-all", HelpAllDirective)
+    app.add_directive("jupyterhub-rest-api-links", RestAPILinksDirective)
 
 
 # -- Read The Docs -----------------------------------------------------------
@@ -131,8 +233,7 @@ def setup(app):
 # pre-requisite steps for "make html" from here if needed.
 #
 if os.environ.get("READTHEDOCS"):
-    docs = os.path.dirname(os.path.dirname(__file__))
-    subprocess.check_call(["make", "metrics", "scopes"], cwd=docs)
+    subprocess.check_call(["make", "metrics", "scopes"], cwd=str(docs))
 
 
 # -- Spell checking ----------------------------------------------------------
@@ -187,7 +288,6 @@ def setup(app):
     "https://github.com/jupyterhub/jupyterhub/pull/",  # too many PRs in changelog
     "https://github.com/jupyterhub/jupyterhub/compare/",  # too many comparisons in changelog
     r"https?://(localhost|127.0.0.1).*",  # ignore localhost references in auto-links
-    r".*/rest-api.html#.*",  # ignore javascript-resolved internal rest-api links
     r"https://linux.die.net/.*",  # linux.die.net seems to block requests from CI with 403 sometimes
 ]
 linkcheck_anchors_ignore = [


Test patch: