Skip to content
Merged
1 change: 1 addition & 0 deletions medcat-service/docs/setup/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The following environment variables are available for tailoring the MedCAT Servi
- `APP_ENABLE_METRICS` - Enable prometheus metrics collection served on the path /metrics
- `APP_ENABLE_DEMO_UI` - Enable the demo user interface to try models. (Default: `False`)
- `APP_DEMO_UI_PATH` - Customise the path of the demo UI. (Default: `/`)
- `APP_USE_CDN` - Load Swagger UI and ReDoc assets from a CDN. (Default: `True`) Set to `False` to serve docs from bundled static files instead. This allows the docs UI to work for offline browsers.

### Shared Memory (`DOCKER_SHM_SIZE`)

Expand Down
10 changes: 10 additions & 0 deletions medcat-service/medcat_service/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ class Settings(BaseSettings):
enable_demo_ui: bool = Field(default=False, description="Enable the demo app", alias="APP_ENABLE_DEMO_UI")
demo_ui_path: str = Field(default="", description="Path to the demo app", alias="APP_DEMO_UI_PATH")

use_cdn_for_docs: bool = Field(
default=True,
description=(
"Use a CDN for Swagger UI and ReDoc (default). "
"Set to false to serve docs from bundled static files. "
"This allows the docs UI to work for offline browsers."
),
alias="APP_USE_CDN",
)

# Model paths
model_cdb_path: str | None = Field("/cat/models/medmen/cdb.dat", alias="APP_MODEL_CDB_PATH")
model_vocab_path: str | None = Field("/cat/models/medmen/vocab.dat", alias="APP_MODEL_VOCAB_PATH")
Expand Down
5 changes: 5 additions & 0 deletions medcat-service/medcat_service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from medcat_service.dependencies import get_settings
from medcat_service.log_config import log_config
from medcat_service.routers import admin, health, process
from medcat_service.routers.swagger import configure_docs
from medcat_service.types import HealthCheckFailedException

settings = get_settings()
Expand All @@ -30,12 +31,16 @@
"identifier": "Apache-2.0",
},
root_path=settings.app_root_path,
docs_url="/docs" if settings.use_cdn_for_docs else None,
redoc_url="/redoc" if settings.use_cdn_for_docs else None,
)

app.include_router(admin.router)
app.include_router(health.router)
app.include_router(process.router)

configure_docs(app, settings)


def configure_observability(settings: Settings, app: FastAPI):
if settings.observability.enable_metrics:
Expand Down
58 changes: 58 additions & 0 deletions medcat-service/medcat_service/routers/swagger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from pathlib import Path

from fastapi import FastAPI
from fastapi.openapi.docs import (
get_redoc_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
from fastapi.staticfiles import StaticFiles

from medcat_service.config import Settings

STATIC_DIR = Path(__file__).resolve().parent.parent.parent / "static"


def configure_docs(app: FastAPI, settings: Settings) -> None:
"""
Support self-hosting javascript and css for docs instead of using the CDN.

This allows the docs page to work offline or in an air-gapped environment.

https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/#self-hosting-javascript-and-css-for-docs

If the flag is true, then it should just have the default FastAPI behaviour.
"""
if settings.use_cdn_for_docs:
return

app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
root_path = app.root_path.rstrip("/")
oauth2_redirect_url = app.swagger_ui_oauth2_redirect_url
if oauth2_redirect_url:
oauth2_redirect_url = root_path + oauth2_redirect_url
return get_swagger_ui_html(
openapi_url=root_path + app.openapi_url,
title=app.title + " - Swagger UI",
oauth2_redirect_url=oauth2_redirect_url,
swagger_js_url=f"{root_path}/static/swagger-ui-bundle.js",
swagger_css_url=f"{root_path}/static/swagger-ui.css",
)

if app.swagger_ui_oauth2_redirect_url:

@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
async def swagger_ui_redirect():
return get_swagger_ui_oauth2_redirect_html()

@app.get("/redoc", include_in_schema=False)
async def redoc_html():
root_path = app.root_path.rstrip("/")
return get_redoc_html(
openapi_url=root_path + app.openapi_url,
title=app.title + " - ReDoc",
redoc_js_url=f"{root_path}/static/redoc.standalone.js",
)
89 changes: 89 additions & 0 deletions medcat-service/medcat_service/test/test_swagger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import os
import sys
import unittest
from unittest.mock import patch

from fastapi.testclient import TestClient

CDN_SWAGGER_JS = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"
CDN_SWAGGER_CSS = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"
CDN_REDOC_JS = "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"


class TestSwaggerDocs(unittest.TestCase):
def _reload_app(self):
"""
Reload the FastAPI app after env changes
"""
# Clear cached imports so settings are re-evaluated
for mod in list(sys.modules):
if mod.startswith("medcat_service"):
sys.modules.pop(mod)
from medcat_service.main import app

return app

def tearDown(self):
self._reload_app()

@patch.dict(os.environ, {"APP_USE_CDN": "true"}, clear=False)
def test_cdn_mode_serves_docs_from_cdn(self):
app = self._reload_app()
client = TestClient(app)

docs = client.get("/docs")
self.assertEqual(docs.status_code, 200)
self.assertIn(CDN_SWAGGER_JS, docs.text)
self.assertIn(CDN_SWAGGER_CSS, docs.text)
self.assertNotIn("/static/swagger-ui-bundle.js", docs.text)

redoc = client.get("/redoc")
self.assertEqual(redoc.status_code, 200)
self.assertIn(CDN_REDOC_JS, redoc.text)
self.assertNotIn("/static/redoc.standalone.js", redoc.text)

static = client.get("/static/swagger-ui-bundle.js")
self.assertEqual(static.status_code, 404)

@patch.dict(os.environ, {"APP_USE_CDN": "false"}, clear=False)
def test_self_hosted_mode_serves_docs_from_static(self):
app = self._reload_app()
client = TestClient(app)

docs = client.get("/docs")
self.assertEqual(docs.status_code, 200)
self.assertIn("/static/swagger-ui-bundle.js", docs.text)
self.assertIn("/static/swagger-ui.css", docs.text)
self.assertNotIn(CDN_SWAGGER_JS, docs.text)

redoc = client.get("/redoc")
self.assertEqual(redoc.status_code, 200)
self.assertIn("/static/redoc.standalone.js", redoc.text)
self.assertNotIn(CDN_REDOC_JS, redoc.text)

static = client.get("/static/swagger-ui-bundle.js")
self.assertEqual(static.status_code, 200)
self.assertTrue(static.text.startswith("/*!"))

@patch.dict(
os.environ,
{"APP_USE_CDN": "false", "APP_ROOT_PATH": "/medcat-service"},
clear=False,
)
def test_self_hosted_mode_prefixes_urls_with_root_path(self):
app = self._reload_app()
client = TestClient(app)

docs = client.get("/docs")
self.assertEqual(docs.status_code, 200)
self.assertIn("/medcat-service/static/swagger-ui-bundle.js", docs.text)
self.assertIn("/medcat-service/openapi.json", docs.text)

redoc = client.get("/redoc")
self.assertEqual(redoc.status_code, 200)
self.assertIn("/medcat-service/static/redoc.standalone.js", redoc.text)
self.assertIn("/medcat-service/openapi.json", redoc.text)


if __name__ == "__main__":
unittest.main()
1,838 changes: 1,838 additions & 0 deletions medcat-service/static/redoc.standalone.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions medcat-service/static/swagger-ui-bundle.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions medcat-service/static/swagger-ui.css

Large diffs are not rendered by default.

Loading