Repository: jupyterhub/jupyterhub

Issue:
JupyterHub OpenAPI spec contains errors
### Bug description

The Swagger validator reports errors in the current JupyterHub OpenAPI spec:
[![](https://validator.swagger.io/validator/?url=https%3A%2F%2Fraw.githubusercontent.com%2Fjupyterhub%2Fjupyterhub%2FHEAD%2Fdocs%2Frest-api.yml)](https://validator.swagger.io/validator/?url=https%3A%2F%2Fraw.githubusercontent.com%2Fjupyterhub%2Fjupyterhub%2FHEAD%2Fdocs%2Frest-api.yml)
https://validator.swagger.io/validator/debug?url=https%3A%2F%2Fraw.githubusercontent.com%2Fjupyterhub%2Fjupyterhub%2Fmain%2Fdocs%2Frest-api.yml

In addition the API version should be bumped to `2.0.0`, currently it still says `1.4.0`:
https://github.com/jupyterhub/jupyterhub/blob/4f6ef54b50ea577bb67f2726bc98a5ce909ebc92/docs/rest-api.yml#L4-L6


#### Expected behaviour

Schema should validate without errors

#### Actual behaviour

```json
{
  "messages": [
    "attribute paths.'/users'(get).[limit].requred is unexpected",
    "attribute paths.'/proxy'(get).[limit].requred is unexpected"
  ],
  "schemaValidationMessages": [
    {
      "level": "error",
      "domain": "validation",
      "keyword": "oneOf",
      "message": "instance failed to match exactly one schema (matched 0 out of 2)",
      "schema": {
        "loadingURI": "http://swagger.io/v2/schema.json#",
        "pointer": "/definitions/parametersList/items"
      },
      "instance": {
        "pointer": "/paths/~1proxy/get/parameters/1"
      }
    }
  ]
}
```
Note there are no errors in 1.4.0:
[![](https://validator.swagger.io/validator/?url=https%3A%2F%2Fraw.githubusercontent.com%2Fjupyterhub%2Fjupyterhub%2F1.4.0%2Fdocs%2Frest-api.yml)](https://validator.swagger.io/validator/?url=https%3A%2F%2Fraw.githubusercontent.com%2Fjupyterhub%2Fjupyterhub%2F1.4.0%2Fdocs%2Frest-api.yml)


Discussion:

Resolving patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -215,7 +215,7 @@ def setup(app):
     # build both metrics and rest-api, since RTD doesn't run make
     from subprocess import check_call as sh
 
-    sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
+    sh(['make', 'metrics', 'scopes'], cwd=docs)
 
 # -- Spell checking -------------------------------------------------------
 
diff --git a/docs/source/rbac/generate-scope-table.py b/docs/source/rbac/generate-scope-table.py
--- a/docs/source/rbac/generate-scope-table.py
+++ b/docs/source/rbac/generate-scope-table.py
@@ -5,10 +5,12 @@
 from pytablewriter import MarkdownTableWriter
 from ruamel.yaml import YAML
 
+import jupyterhub
 from jupyterhub.scopes import scope_definitions
 
 HERE = os.path.abspath(os.path.dirname(__file__))
-PARENT = Path(HERE).parent.parent.absolute()
+DOCS = Path(HERE).parent.parent.absolute()
+REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
 
 
 class ScopeTableGenerator:
@@ -98,22 +100,24 @@ def write_table(self):
 
     def write_api(self):
         """Generates the API description in markdown format and writes it into `rest-api.yml`"""
-        filename = f"{PARENT}/rest-api.yml"
+        filename = REST_API_YAML
         yaml = YAML(typ='rt')
         yaml.preserve_quotes = True
         scope_dict = {}
-        with open(filename, 'r+') as f:
+        with open(filename) as f:
             content = yaml.load(f.read())
-            f.seek(0)
-            for scope in self.scopes:
-                description = self.scopes[scope]['description']
-                doc_description = self.scopes[scope].get('doc_description', '')
-                if doc_description:
-                    description = doc_description
-                scope_dict[scope] = description
-            content['securityDefinitions']['oauth2']['scopes'] = scope_dict
+
+        content["info"]["version"] = jupyterhub.__version__
+        for scope in self.scopes:
+            description = self.scopes[scope]['description']
+            doc_description = self.scopes[scope].get('doc_description', '')
+            if doc_description:
+                description = doc_description
+            scope_dict[scope] = description
+        content['securityDefinitions']['oauth2']['scopes'] = scope_dict
+
+        with open(filename, 'w') as f:
             yaml.dump(content, f)
-            f.truncate()
 
 
 def main():
diff --git a/jupyterhub/_version.py b/jupyterhub/_version.py
--- a/jupyterhub/_version.py
+++ b/jupyterhub/_version.py
@@ -1,13 +1,14 @@
 """JupyterHub version info"""
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
-
 version_info = (
     2,
     0,
     0,
     "b3",  # release (b1, rc1, or "" for final or dev)
     # "dev",  # dev or nothing for beta/rc/stable releases
+    # when updating, make sure to update version in docs/source/_static/rest-api.yml as well
+    # `cd docs; make scopes` will do this
 )
 
 # pep 440 version: no dot before beta/rc, but before .dev


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,8 +14,28 @@ on:
 env:
   # UTF-8 content may be interpreted as ascii and causes errors without this.
   LANG: C.UTF-8
+  PYTEST_ADDOPTS: "--verbose --color=yes"
 
 jobs:
+  rest-api:
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/checkout@v2
+      - name: Validate REST API
+        uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
+        with:
+          definition-file: docs/source/_static/rest-api.yml
+
+      - uses: actions/setup-python@v2
+        with:
+          python-version: "3.9"
+      # in addition to the doc requirements
+      # the docs *tests* require pre-commit and pytest
+      - run: |
+          pip install -r docs/requirements.txt pytest pre-commit -e .
+      - run: |
+          pytest docs/
+
   # Run "pytest jupyterhub/tests" in various configurations
   pytest:
     runs-on: ubuntu-20.04
@@ -182,10 +202,8 @@ jobs:
           fi
 
       - name: Run pytest
-        # FIXME: --color=yes explicitly set because:
-        #        https://github.com/actions/runner/issues/241
         run: |
-          pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
+          pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
       - name: Run yarn jest test
         run: |
           cd jsx && yarn && yarn test
diff --git a/docs/test_docs.py b/docs/test_docs.py
new file mode 100644
--- /dev/null
+++ b/docs/test_docs.py
@@ -0,0 +1,45 @@
+import sys
+from pathlib import Path
+from subprocess import run
+
+from ruamel.yaml import YAML
+
+yaml = YAML(typ="safe")
+
+here = Path(__file__).absolute().parent
+root = here.parent
+
+
+def test_rest_api_version():
+    version_py = root.joinpath("jupyterhub", "_version.py")
+    rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
+    ns = {}
+    with version_py.open() as f:
+        exec(f.read(), {}, ns)
+    jupyterhub_version = ns["__version__"]
+
+    with rest_api_yaml.open() as f:
+        rest_api = yaml.load(f)
+        rest_api_version = rest_api["info"]["version"]
+
+    assert jupyterhub_version == rest_api_version
+
+
+def test_restapi_scopes():
+    run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
+    run(
+        ['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
+        cwd=here,
+        check=False,
+    )
+    run(
+        [
+            "git",
+            "diff",
+            "--no-pager",
+            "--exit-code",
+            str(here.joinpath("source", "_static", "rest-api.yml")),
+        ],
+        cwd=here,
+        check=True,
+    )