diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py index 1fe9bcf3..71e3673f 100644 --- a/blacksheep/server/openapi/common.py +++ b/blacksheep/server/openapi/common.py @@ -299,11 +299,12 @@ def __init__( preferred_format: Format = Format.JSON, anonymous_access: bool = True, serializer: Serializer | None = None, + spec_file: str | None = None, ) -> None: self._handlers_docs: dict[Any, EndpointDocs] = {} self._controllers_docs: dict[Any, ControllerDocs] = {} self.use_docstrings: bool = True - self.include: Callable[[str, Route | None, bool]] = None + self.include: Callable[[str, Route], bool] | None = None self.json_spec_path = json_spec_path self.yaml_spec_path = yaml_spec_path self._json_docs: bytes = b"" @@ -315,6 +316,7 @@ def __init__( self.events = OpenAPIEvents(self) self.handle_optional_response_with_404 = True self._serializer = serializer + self._spec_file = spec_file or os.environ.get("APP_SPEC_FILE") def __call__( self, @@ -448,7 +450,7 @@ def _get_request_handler(self, route: Route) -> Any: # any normalization return route.handler # pragma: no cover - def get_handler_tags(self, handler: Any) -> list[str | None]: + def get_handler_tags(self, handler: Any) -> list[str] | None: docs = self.get_handler_docs(handler) if docs and docs.tags: return docs.tags @@ -563,10 +565,104 @@ def on_docs_generated(self, docs: OpenAPIRootType) -> None: def get_ui_page_title(self) -> str: return "API Docs" # pragma: no cover + def _get_spec_file_paths(self, spec_file: str) -> tuple[str, str]: + """ + Returns (json_path, yaml_path) derived from a given spec file path. + If the extension is .yaml or .yml, the JSON companion uses the same base + with .json. Otherwise the YAML companion uses the same base with .yaml. + """ + base, ext = os.path.splitext(spec_file) + if ext.lower() in (".yaml", ".yml"): + return base + ".json", spec_file + json_path = spec_file if ext == ".json" else spec_file + ".json" + return json_path, base + ".yaml" + + def _load_spec_from_file(self, spec_file: str) -> bool: + """ + Loads pre-baked OpenAPI specification bytes from disk. + Reads both the JSON and YAML variants. Returns True if both files are + found and loaded, False if either is missing (falling back to generation). + """ + json_path, yaml_path = self._get_spec_file_paths(spec_file) + if not os.path.isfile(json_path) or not os.path.isfile(yaml_path): + return False + with open(json_path, "rb") as fp: + self._json_docs = fp.read() + with open(yaml_path, "rb") as fp: + self._yaml_docs = fp.read() + return True + + def save_spec(self, destination: str) -> None: + """ + Saves the current in-memory OpenAPI specification to disk. + Both JSON and YAML variants are always written, regardless of the + extension given in *destination*. + + This is meant to be used to "bake" the spec at build/CI time (without + ``PYTHONOPTIMIZE=2``) so that it can be loaded at runtime when docstrings + are stripped. + + **Typical workflow** + + 1. Bake the spec once (e.g. in a CI step, without ``-OO``):: + + # bake_spec.py + import asyncio + from myapp import app, docs + + asyncio.run(app.start()) + docs.save_spec("./openapi.json") + # also writes ./openapi.yaml + + 2. Ship the baked files alongside the application. + + 3. At runtime (TEST / PROD) set the environment variable so that + BlackSheep loads the baked spec instead of regenerating it:: + + APP_SPEC_FILE=openapi.json + + No code change is needed between environments. Alternatively, + pass the path explicitly:: + + docs = OpenAPIHandler( + info=Info("My API", "1.0"), + spec_file="openapi.json", + ) + + If the files do not exist yet when the application starts, they are + generated and saved automatically on the first startup, then loaded + from disk on every subsequent startup. + + Args: + destination: file path with a ``.json`` or ``.yaml``/``.yml`` + extension. The companion format is written next to it + automatically. + """ + if not self._json_docs and not self._yaml_docs: + raise RuntimeError( + "The specification has not been built yet. " + "Call save_spec() only after the application has started " + "(e.g. after asyncio.run(app.start()))." + ) + json_path, yaml_path = self._get_spec_file_paths(destination) + with open(json_path, "wb") as fp: + fp.write(self._json_docs) + with open(yaml_path, "wb") as fp: + fp.write(self._yaml_docs) + async def build_docs(self, app: Application) -> None: - docs = self.generate_documentation(app) - self.on_docs_generated(docs) - serializer = self._serializer or DefaultSerializer() + spec_file = self._spec_file + if spec_file and self._load_spec_from_file(spec_file): + # Files are read from file system + ... + else: + docs = self.generate_documentation(app) + self.on_docs_generated(docs) + serializer = self._serializer or DefaultSerializer() + self._json_docs = serializer.to_json(docs).encode("utf8") + self._yaml_docs = serializer.to_yaml(docs).encode("utf8") + if spec_file: + self.save_spec(spec_file) ui_options = UIOptions( spec_url=self.get_spec_path(), page_title=self.get_ui_page_title() @@ -575,9 +671,6 @@ async def build_docs(self, app: Application) -> None: for ui_provider in self.ui_providers: ui_provider.build_ui(ui_options) - self._json_docs = serializer.to_json(docs).encode("utf8") - self._yaml_docs = serializer.to_yaml(docs).encode("utf8") - def bind_app(self, app: Application) -> None: if app.started: raise TypeError( diff --git a/blacksheep/server/openapi/docstrings.py b/blacksheep/server/openapi/docstrings.py index 940e21e7..b59c038c 100644 --- a/blacksheep/server/openapi/docstrings.py +++ b/blacksheep/server/openapi/docstrings.py @@ -19,6 +19,7 @@ """ import re +import sys import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -510,15 +511,28 @@ def parse_docstring( _handlers_docstring_info = WeakKeyDictionary() +_optimize_warning_issued = False def get_handler_docstring_info(handler) -> DocstringInfo: + global _optimize_warning_issued if handler not in _handlers_docstring_info: docs = handler.__doc__ if docs: docstring_info = parse_docstring(docs) else: + if sys.flags.optimize >= 2 and not _optimize_warning_issued: + _optimize_warning_issued = True + warnings.warn( + "Python is running with PYTHONOPTIMIZE=2 (or -OO), so docstrings " + "are not available and cannot be used to enrich OpenAPI " + "Documentation. Consider baking the OpenAPI spec to a file before " + "deploying: call docs.save_spec() after starting the application " + "once without -OO, then set APP_SPEC_FILE to the saved path so " + "that BlackSheep loads it at runtime instead of regenerating it.", + stacklevel=2, + ) docstring_info = None _handlers_docstring_info[handler] = docstring_info return _handlers_docstring_info[handler] diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py index 15d77a1f..108087c3 100644 --- a/blacksheep/server/openapi/v3.py +++ b/blacksheep/server/openapi/v3.py @@ -434,6 +434,7 @@ def __init__( security_schemes: dict[str, SecurityScheme] | None = None, servers: Sequence[Server] | None = None, serializer: Serializer | None = None, + spec_file: str | None = None, ) -> None: super().__init__( ui_path=ui_path, @@ -442,6 +443,7 @@ def __init__( preferred_format=preferred_format, anonymous_access=anonymous_access, serializer=serializer, + spec_file=spec_file, ) self.info = info self._tags = tags diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py index b83f2ee4..307023bf 100644 --- a/tests/test_openapi_v3.py +++ b/tests/test_openapi_v3.py @@ -4454,3 +4454,262 @@ async def test_filedata_schema_generation(): assert list_schema.type == ValueType.ARRAY assert list_schema.items.type == ValueType.STRING assert list_schema.items.format == ValueFormat.BINARY + + +# --------------------------------------------------------------------------- +# Tests for save_spec / spec_file (PYTHONOPTIMIZE=2 support) +# --------------------------------------------------------------------------- + + +def test_save_spec_raises_if_not_built(): + """save_spec raises RuntimeError when called before the app has started.""" + docs = OpenAPIHandler(info=Info("Test API", "0.0.1")) + with pytest.raises(RuntimeError, match="not been built yet"): + docs.save_spec("/tmp/openapi.json") + + +async def test_save_spec_writes_both_formats(tmp_path): + """save_spec always writes both a .json and a .yaml file.""" + import json as _json + + app = Application() + + @app.router.get("/hello") + async def hello(): + """Returns a greeting.""" + return "Hello" + + docs = OpenAPIHandler(info=Info("Test API", "0.0.1")) + docs.bind_app(app) + await app.start() + + destination = str(tmp_path / "openapi.json") + docs.save_spec(destination) + + json_path = tmp_path / "openapi.json" + yaml_path = tmp_path / "openapi.yaml" + + assert json_path.exists(), "JSON spec file should be written" + assert yaml_path.exists(), "YAML spec file should be written" + + # Both files should be valid and contain the /hello path + data = _json.loads(json_path.read_bytes()) + assert "/hello" in data["paths"] + + yaml_text = yaml_path.read_text() + assert "/hello" in yaml_text + + +async def test_save_spec_yaml_destination_writes_both_formats(tmp_path): + """save_spec writes both formats even when destination has .yaml extension.""" + app = Application() + + @app.router.get("/ping") + async def ping(): + return "pong" + + docs = OpenAPIHandler(info=Info("Test API", "0.0.1")) + docs.bind_app(app) + await app.start() + + destination = str(tmp_path / "openapi.yaml") + docs.save_spec(destination) + + assert (tmp_path / "openapi.yaml").exists() + assert (tmp_path / "openapi.json").exists() + + +async def test_spec_file_loads_json_on_build_docs(tmp_path): + """When spec_file is set, build_docs loads the spec from disk.""" + import json as _json + + # Step 1: build and bake the spec + app_bake = Application() + + @app_bake.router.get("/cats") + async def get_cats(): + return [] + + docs_bake = OpenAPIHandler(info=Info("Cats API", "1.0.0")) + docs_bake.bind_app(app_bake) + await app_bake.start() + docs_bake.save_spec(str(tmp_path / "openapi.json")) + + # Step 2: load the spec at "runtime" via spec_file= + app_runtime = Application() + docs_runtime = OpenAPIHandler( + info=Info("Cats API", "1.0.0"), + spec_file=str(tmp_path / "openapi.json"), + ) + docs_runtime.bind_app(app_runtime) + await app_runtime.start() + + # The loaded JSON should match what was baked + loaded = _json.loads(docs_runtime._json_docs) + assert "/cats" in loaded["paths"] + + # The YAML variant should also be populated + assert b"/cats" in docs_runtime._yaml_docs + + +async def test_spec_file_loads_yaml_on_build_docs(tmp_path): + """When spec_file points to a .yaml file, build_docs loads both formats.""" + import json as _json + + app_bake = Application() + + @app_bake.router.get("/dogs") + async def get_dogs(): + return [] + + docs_bake = OpenAPIHandler(info=Info("Dogs API", "1.0.0")) + docs_bake.bind_app(app_bake) + await app_bake.start() + docs_bake.save_spec(str(tmp_path / "openapi.yaml")) + + app_runtime = Application() + docs_runtime = OpenAPIHandler( + info=Info("Dogs API", "1.0.0"), + spec_file=str(tmp_path / "openapi.yaml"), + ) + docs_runtime.bind_app(app_runtime) + await app_runtime.start() + + loaded = _json.loads(docs_runtime._json_docs) + assert "/dogs" in loaded["paths"] + + assert b"/dogs" in docs_runtime._yaml_docs + + +def test_get_spec_file_paths_json_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi.json") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yaml" + + +def test_get_spec_file_paths_yaml_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi.yaml") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yaml" + + +def test_get_spec_file_paths_yml_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi.yml") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yml" + + +def test_get_spec_file_paths_no_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yaml" + + +async def test_app_spec_file_env_var_is_used_when_set(tmp_path, monkeypatch): + """When APP_SPEC_FILE env var is set, build_docs loads from that file.""" + import json as _json + + # Bake a spec first + app_bake = Application() + + @app_bake.router.get("/birds") + async def get_birds(): + return [] + + docs_bake = OpenAPIHandler(info=Info("Birds API", "1.0.0")) + docs_bake.bind_app(app_bake) + await app_bake.start() + docs_bake.save_spec(str(tmp_path / "openapi.json")) + + # Now simulate what a deploy environment looks like: no spec_file kwarg, + # only the env var is set. + monkeypatch.setenv("APP_SPEC_FILE", str(tmp_path / "openapi.json")) + + app_runtime = Application() + docs_runtime = OpenAPIHandler(info=Info("Birds API", "1.0.0")) + docs_runtime.bind_app(app_runtime) + await app_runtime.start() + + loaded = _json.loads(docs_runtime._json_docs) + assert "/birds" in loaded["paths"] + assert b"/birds" in docs_runtime._yaml_docs + + +async def test_app_spec_file_env_var_not_set_generates_spec(monkeypatch): + """When APP_SPEC_FILE is not set, the spec is generated normally.""" + monkeypatch.delenv("APP_SPEC_FILE", raising=False) + + app = Application() + + @app.router.get("/fish") + async def get_fish(): + return [] + + docs = OpenAPIHandler(info=Info("Fish API", "1.0.0")) + docs.bind_app(app) + await app.start() + + import json as _json + + loaded = _json.loads(docs._json_docs) + assert "/fish" in loaded["paths"] + + +async def test_spec_file_auto_saves_on_first_start(tmp_path, monkeypatch): + """When spec_file is set but files don't exist yet, the spec is generated, + saved to disk, and subsequent startups load from the saved files.""" + import json as _json + + monkeypatch.delenv("APP_SPEC_FILE", raising=False) + + spec_path = str(tmp_path / "openapi.json") + + # First startup: files don't exist yet → generate and auto-save + app_first = Application() + + @app_first.router.get("/frogs") + async def get_frogs(): + return [] + + docs_first = OpenAPIHandler(info=Info("Frogs API", "1.0.0"), spec_file=spec_path) + docs_first.bind_app(app_first) + await app_first.start() + + assert (tmp_path / "openapi.json").exists(), "JSON spec should be auto-saved" + assert (tmp_path / "openapi.yaml").exists(), "YAML spec should be auto-saved" + assert "/frogs" in _json.loads(docs_first._json_docs)["paths"] + + # Second startup: files exist → loaded from disk, generation is skipped + app_second = Application() + docs_second = OpenAPIHandler(info=Info("Frogs API", "1.0.0"), spec_file=spec_path) + docs_second.bind_app(app_second) + await app_second.start() + + assert "/frogs" in _json.loads(docs_second._json_docs)["paths"] + assert b"/frogs" in docs_second._yaml_docs + + +async def test_spec_file_env_var_auto_saves_on_first_start(tmp_path, monkeypatch): + """APP_SPEC_FILE pointing to a non-existent file causes auto-save on first start.""" + import json as _json + + spec_path = str(tmp_path / "openapi.json") + monkeypatch.setenv("APP_SPEC_FILE", spec_path) + + app = Application() + + @app.router.get("/foxes") + async def get_foxes(): + return [] + + docs = OpenAPIHandler(info=Info("Foxes API", "1.0.0")) + docs.bind_app(app) + await app.start() + + assert (tmp_path / "openapi.json").exists() + assert (tmp_path / "openapi.yaml").exists() + assert "/foxes" in _json.loads(docs._json_docs)["paths"]