Repository: Pylons/pyramid_openapi3

Issue:
support swagger ui config json
I'd like to not only pass `ui_version` but also `ui_config` which could be a json object that is rendered into the SwaggerUIBundle in some way. As an example I want to set things like the oauth2 config in https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/.

I also see there is support in swagger for a configUrl which maybe we could leverage if it's more comfortable. https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/

https://github.com/Pylons/pyramid_openapi3/blob/92797d915ac8d737a14fff9cd5542b7316d786db/pyramid_openapi3/static/index.html#L73

Discussion:
@mmerickel: I got into this deeper and created #262.

Solution patch:
diff --git a/pyramid_openapi3/__init__.py b/pyramid_openapi3/__init__.py
index aaeb3de..367b529 100644
--- a/pyramid_openapi3/__init__.py
+++ b/pyramid_openapi3/__init__.py
@@ -33,6 +33,7 @@ from string import Template
 from urllib.parse import urlparse
 
 import hupper
+import json
 import logging
 import typing as t
 
@@ -147,6 +148,11 @@ def add_explorer_view(
     route_name: str = "pyramid_openapi3.explorer",
     template: str = "static/index.html",
     ui_version: str = "5.12.0",
+    ui_config: t.Optional[dict[str, t.Any]] = None,
+    oauth_config: t.Optional[dict[str, t.Any]] = None,
+    oauth_redirect_route: t.Optional[str] = None,
+    oauth_redirect_route_name: str = "pyramid_openapi3.explorer.oauth2-redirect",
+    oauth_redirect_html: str = "static/oauth2-redirect.html",
     permission: str = NO_PERMISSION_REQUIRED,
     apiname: str = "pyramid_openapi3",
 ) -> None:
@@ -156,11 +162,28 @@ def add_explorer_view(
     :param route_name: Route name that's being added
     :param template: Dotted path to the html template that renders Swagger UI response
     :param ui_version: Swagger UI version string
+    :param ui_config:
+        A dictionary conforming to the SwaggerUI API.
+        Any settings defined here will override those defined by default.
+    :param oauth_config:
+        If defined, then SwaggerUI.initOAuth will be invoked with the supplied config.
+    :param oauth_redirect_route:
+        URL path where the redirect will be served. By default the path is constructed
+        by appending a ``/oauth2-redirect`` path component to the ``route`` parameter.
+    :param oauth_redirect_route_name:
+        Route name for the redirect route.
+    :param oauth_redirect_html:
+        Dotted path to the html that renders the oauth2-redirect HTML.
     :param permission: Permission for the explorer view
     """
 
+    if oauth_redirect_route is None:
+        oauth_redirect_route = route.rstrip("/") + "/oauth2-redirect"
+
     def register() -> None:
-        resolved_template = AssetResolver().resolve(template)
+        asset_resolver = AssetResolver()
+        resolved_template = asset_resolver.resolve(template)
+        redirect_html = asset_resolver.resolve(oauth_redirect_html)
 
         def explorer_view(request: Request) -> Response:
             settings = config.registry.settings
@@ -171,9 +194,20 @@ def add_explorer_view(
                 )
             with open(resolved_template.abspath()) as f:
                 template = Template(f.read())
+                merged_ui_config = {
+                    "url": request.route_path(settings[apiname]["spec_route_name"]),
+                    "dom_id": "#swagger-ui",
+                    "deepLinking": True,
+                    "validatorUrl": None,
+                    "layout": "StandaloneLayout",
+                    "oauth2RedirectUrl": request.route_url(oauth_redirect_route_name),
+                }
+                if ui_config:
+                    merged_ui_config.update(ui_config)
                 html = template.safe_substitute(
                     ui_version=ui_version,
-                    spec_url=request.route_path(settings[apiname]["spec_route_name"]),
+                    ui_config=json.dumps(merged_ui_config),
+                    oauth_config=json.dumps(oauth_config),
                 )
             return Response(html)
 
@@ -182,6 +216,16 @@ def add_explorer_view(
             route_name=route_name, permission=permission, view=explorer_view
         )
 
+        def redirect_view(request: Request) -> FileResponse:
+            return FileResponse(redirect_html.abspath())
+
+        config.add_route(oauth_redirect_route_name, oauth_redirect_route)
+        config.add_view(
+            route_name=oauth_redirect_route_name,
+            permission=permission,
+            view=redirect_view,
+        )
+
     config.action((f"{apiname}_add_explorer",), register, order=PHASE0_CONFIG)
 
 
diff --git a/pyramid_openapi3/static/index.html b/pyramid_openapi3/static/index.html
index 19acde9..568e9ad 100644
--- a/pyramid_openapi3/static/index.html
+++ b/pyramid_openapi3/static/index.html
@@ -69,22 +69,23 @@
 <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/${ui_version}/swagger-ui-standalone-preset.js"> </script>
 <script>
     window.onload = function() {
-        // Build a system
-        const ui = SwaggerUIBundle({
-            url: "${spec_url}",
-            dom_id: '#swagger-ui',
-            deepLinking: true,
+        const uiConfig = ${ui_config};
+        Object.assign(uiConfig, {
             presets: [
                 SwaggerUIBundle.presets.apis,
-                SwaggerUIStandalonePreset
+                SwaggerUIStandalonePreset,
             ],
             plugins: [
-                SwaggerUIBundle.plugins.DownloadUrl
+                SwaggerUIBundle.plugins.DownloadUrl,
             ],
-            validatorUrl: null,
-            layout: "StandaloneLayout"
-        })
-        window.ui = ui
+        };
+        const oauthConfig = ${oauth_config};
+        // Build a system
+        const ui = SwaggerUIBundle(uiConfig);
+        if (oauthConfig) {
+            ui.initOAuth(oauthConfig);
+        }
+        window.ui = ui;
     }
 </script>
 </body>
diff --git a/pyramid_openapi3/static/oauth2-redirect.html b/pyramid_openapi3/static/oauth2-redirect.html
new file mode 100644
index 0000000..5640917
--- /dev/null
+++ b/pyramid_openapi3/static/oauth2-redirect.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<html lang="en-US">
+<head>
+    <title>Swagger UI: OAuth2 Redirect</title>
+</head>
+<body>
+<script>
+    'use strict';
+    function run () {
+        var oauth2 = window.opener.swaggerUIRedirectOauth2;
+        var sentState = oauth2.state;
+        var redirectUrl = oauth2.redirectUrl;
+        var isValid, qp, arr;
+
+        if (/code|token|error/.test(window.location.hash)) {
+            qp = window.location.hash.substring(1).replace('?', '&');
+        } else {
+            qp = location.search.substring(1);
+        }
+
+        arr = qp.split("&");
+        arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
+        qp = qp ? JSON.parse('{' + arr.join() + '}',
+                function (key, value) {
+                    return key === "" ? value : decodeURIComponent(value);
+                }
+        ) : {};
+
+        isValid = qp.state === sentState;
+
+        if ((
+          oauth2.auth.schema.get("flow") === "accessCode" ||
+          oauth2.auth.schema.get("flow") === "authorizationCode" ||
+          oauth2.auth.schema.get("flow") === "authorization_code"
+        ) && !oauth2.auth.code) {
+            if (!isValid) {
+                oauth2.errCb({
+                    authId: oauth2.auth.name,
+                    source: "auth",
+                    level: "warning",
+                    message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
+                });
+            }
+
+            if (qp.code) {
+                delete oauth2.state;
+                oauth2.auth.code = qp.code;
+                oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
+            } else {
+                let oauthErrorMsg;
+                if (qp.error) {
+                    oauthErrorMsg = "["+qp.error+"]: " +
+                        (qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
+                        (qp.error_uri ? "More info: "+qp.error_uri : "");
+                }
+
+                oauth2.errCb({
+                    authId: oauth2.auth.name,
+                    source: "auth",
+                    level: "error",
+                    message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
+                });
+            }
+        } else {
+            oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
+        }
+        window.close();
+    }
+
+    if (document.readyState !== 'loading') {
+        run();
+    } else {
+        document.addEventListener('DOMContentLoaded', function () {
+            run();
+        });
+    }
+</script>
+</body>
+</html>
diff --git a/pyramid_openapi3/wrappers.py b/pyramid_openapi3/wrappers.py
index 3762d36..6c77211 100644
--- a/pyramid_openapi3/wrappers.py
+++ b/pyramid_openapi3/wrappers.py
@@ -41,7 +41,7 @@ class PyramidOpenAPIRequest:
             if self.request.matched_route
             else self.request.path_info
         )
-        return path_pattern
+        return self.request.script_name + path_pattern
 
     @property
     def method(self) -> str:


Test patch:
diff --git a/pyramid_openapi3/tests/test_validation.py b/pyramid_openapi3/tests/test_validation.py
index 540b625..02a9909 100644
--- a/pyramid_openapi3/tests/test_validation.py
+++ b/pyramid_openapi3/tests/test_validation.py
@@ -363,6 +363,110 @@ class TestRequestValidation(RequestValidationBase):  # noqa: D101
         self.assertEqual(start_response.status, "500 Internal Server Error")
 
 
+class TestServerRequestValidation(RequestValidationBase):  # noqa: D101
+
+    openapi_spec = (
+        b"openapi: '3.1.0'\n"
+        b"info:\n"
+        b"  version: '1.0.0'\n"
+        b"  title: Foo API\n"
+        b"servers:\n"
+        b"  - url: /prefix/v1\n"
+        b"  - url: http://example.com/prefix\n"
+        b"paths:\n"
+        b"  /foo:\n"
+        b"    get:\n"
+        b"      responses:\n"
+        b"        200:\n"
+        b"          description: A foo\n"
+        b"          content:\n"
+        b"            application/json:\n"
+        b"              schema:\n"
+        b"                type: object\n"
+        b"                properties:\n"
+        b"                  test:\n"
+        b"                    type: string\n"
+        b"        400:\n"
+        b"          description: Bad Request\n"
+    )
+
+    def test_server_validation_works_with_script_name(self) -> None:
+        """Expect to find a match for http://localhost:8080/prefix/v1/foo."""
+        self._add_view(lambda *arg: {"test": "correct"})
+        # run request through router
+        router = Router(self.config.registry)
+        environ = {
+            "wsgi.url_scheme": "http",
+            "SERVER_NAME": "localhost",
+            "SERVER_PORT": "8080",
+            "REQUEST_METHOD": "GET",
+            "SCRIPT_NAME": "/prefix/v1",
+            "PATH_INFO": "/foo",
+            "HTTP_ACCEPT": "application/json",
+        }
+        start_response = DummyStartResponse()
+        response = router(environ, start_response)
+
+        self.assertEqual(start_response.status, "200 OK")
+        self.assertEqual(json.loads(response[0]), {"test": "correct"})
+
+    def test_server_validation_works_with_script_name_and_hostname(self) -> None:
+        """Expect to find a match for http://example.com/prefix/foo."""
+        self._add_view(lambda *arg: {"test": "correct"})
+        # run request through router
+        router = Router(self.config.registry)
+        environ = {
+            "wsgi.url_scheme": "http",
+            "SERVER_NAME": "localhost",
+            "SERVER_PORT": "8080",
+            "REQUEST_METHOD": "GET",
+            "SCRIPT_NAME": "/prefix",
+            "PATH_INFO": "/foo",
+            "HTTP_HOST": "example.com",
+            "HTTP_ACCEPT": "application/json",
+        }
+        start_response = DummyStartResponse()
+        response = router(environ, start_response)
+
+        self.assertEqual(start_response.status, "200 OK")
+        self.assertEqual(json.loads(response[0]), {"test": "correct"})
+
+    def test_server_validation_fails_with_bad_hostname(self) -> None:
+        """Expect to fail for /prefix/v2/foo."""
+        self._add_view()
+        # run request through router
+        router = Router(self.config.registry)
+        environ = {
+            "wsgi.url_scheme": "http",
+            "SERVER_NAME": "localhost",
+            "SERVER_PORT": "8080",
+            "REQUEST_METHOD": "GET",
+            "SCRIPT_NAME": "/prefix/v2",
+            "PATH_INFO": "/foo",
+            "HTTP_HOST": "example.com",
+            "HTTP_ACCEPT": "application/json",
+        }
+        start_response = DummyStartResponse()
+        with self.assertLogs(level="ERROR") as cm:
+            response = router(environ, start_response)
+        self.assertEqual(start_response.status, "500 Internal Server Error")
+        self.assertEqual(
+            json.loads(response[0]),
+            [
+                {
+                    "exception": "ServerNotFound",
+                    "message": "Server not found for http://example.com/prefix/v2/foo",
+                }
+            ],
+        )
+        self.assertEqual(
+            cm.output,
+            [
+                "ERROR:pyramid_openapi3:Server not found for http://example.com/prefix/v2/foo"
+            ],
+        )
+
+
 class TestImproperAPISpecValidation(RequestValidationBase):  # noqa: D101
 
     openapi_spec = (
diff --git a/pyramid_openapi3/tests/test_views.py b/pyramid_openapi3/tests/test_views.py
index 2ced200..a22a677 100644
--- a/pyramid_openapi3/tests/test_views.py
+++ b/pyramid_openapi3/tests/test_views.py
@@ -292,7 +292,42 @@ def test_add_explorer_view() -> None:
                 document.name, route="/foo.yaml", route_name="foo_api_spec"
             )
 
-        config.pyramid_openapi3_add_explorer()
+        config.pyramid_openapi3_add_explorer(ui_config={"deepLinking": False})
+        request = config.registry.queryUtility(
+            IRouteRequest, name="pyramid_openapi3.explorer"
+        )
+        view = config.registry.adapters.registered(
+            (IViewClassifier, request, Interface), IView, name=""
+        )
+        response = view(request=DummyRequest(config=config), context=None)
+        assert b"<title>Swagger UI</title>" in response.body
+        assert b"const oauthConfig = null;" in response.body
+        assert b'"deepLinking": false' in response.body
+        assert (
+            b'"oauth2RedirectUrl": "http://example.com/docs/oauth2-redirect"'
+            in response.body
+        )
+
+
+def test_add_explorer_oauth_view() -> None:
+    """Test registration of a view serving the Swagger UI with OAuth support."""
+    with testConfig() as config:
+        config.include("pyramid_openapi3")
+
+        with tempfile.NamedTemporaryFile() as document:
+            document.write(MINIMAL_DOCUMENT)
+            document.seek(0)
+
+            config.pyramid_openapi3_spec(
+                document.name, route="/foo.yaml", route_name="foo_api_spec"
+            )
+
+        config.pyramid_openapi3_add_explorer(
+            oauth_redirect_route="/dummy/oauth2-redirect",
+            oauth_config={
+                "usePkceWithAuthorizationCodeGrant": True,
+            },
+        )
         request = config.registry.queryUtility(
             IRouteRequest, name="pyramid_openapi3.explorer"
         )
@@ -301,6 +336,21 @@ def test_add_explorer_view() -> None:
         )
         response = view(request=DummyRequest(config=config), context=None)
         assert b"<title>Swagger UI</title>" in response.body
+        assert b"ui.initOAuth(" in response.body
+        assert b"usePkceWithAuthorizationCodeGrant" in response.body
+        assert (
+            b'"oauth2RedirectUrl": "http://example.com/dummy/oauth2-redirect"'
+            in response.body
+        )
+
+        request = config.registry.queryUtility(
+            IRouteRequest, name="pyramid_openapi3.explorer.oauth2-redirect"
+        )
+        view = config.registry.adapters.registered(
+            (IViewClassifier, request, Interface), IView, name=""
+        )
+        response = view(request=DummyRequest(config=config), context=None)
+        assert b"<title>Swagger UI: OAuth2 Redirect</title>" in response.body
 
 
 def test_add_multiple_explorer_views() -> None:
@@ -344,7 +394,7 @@ def test_add_multiple_explorer_views() -> None:
         )
         response = view(request=DummyRequest(config=config), context=None)
         assert b"<title>Swagger UI</title>" in response.body
-        assert b'url: "/foo/openapi.yaml"' in response.body
+        assert b'"url": "/foo/openapi.yaml"' in response.body
 
         request = config.registry.queryUtility(IRouteRequest, name="bar_api_explorer")
         view = config.registry.adapters.registered(
@@ -352,7 +402,7 @@ def test_add_multiple_explorer_views() -> None:
         )
         response = view(request=DummyRequest(config=config), context=None)
         assert b"<title>Swagger UI</title>" in response.body
-        assert b'url: "/bar/openapi.yaml"' in response.body
+        assert b'"url": "/bar/openapi.yaml"' in response.body
 
 
 def test_add_multiple_explorer_views_using_directory() -> None:
@@ -398,7 +448,7 @@ def test_add_multiple_explorer_views_using_directory() -> None:
         )
         response = view(request=DummyRequest(config=config), context=None)
         assert b"<title>Swagger UI</title>" in response.body
-        assert b'url: "/foo.yaml"' in response.body
+        assert b'"url": "/foo.yaml"' in response.body
 
         request = config.registry.queryUtility(IRouteRequest, name="bar_api_explorer")
         view = config.registry.adapters.registered(
@@ -406,7 +456,7 @@ def test_add_multiple_explorer_views_using_directory() -> None:
         )
         response = view(request=DummyRequest(config=config), context=None)
         assert b"<title>Swagger UI</title>" in response.body
-        assert b'url: "/bar.yaml"' in response.body
+        assert b'"url": "/bar.yaml"' in response.body
 
 
 def test_explorer_view_missing_spec() -> None:
diff --git a/pyramid_openapi3/tests/test_wrappers.py b/pyramid_openapi3/tests/test_wrappers.py
index 5bae1ba..87d46b3 100644
--- a/pyramid_openapi3/tests/test_wrappers.py
+++ b/pyramid_openapi3/tests/test_wrappers.py
@@ -69,7 +69,7 @@ def test_relative_app_request() -> None:
     )
     assert openapi_request.host_url == "http://example.com"
     assert openapi_request.path == "/subpath/foo"
-    assert openapi_request.path_pattern == "/foo"
+    assert openapi_request.path_pattern == "/subpath/foo"
     assert openapi_request.method == "get"
     assert openapi_request.body == b""
     assert openapi_request.mimetype == "text/html"