diff --git a/gns3server/api/routes/compute/__init__.py b/gns3server/api/routes/compute/__init__.py index 934243922..f7815f408 100644 --- a/gns3server/api/routes/compute/__init__.py +++ b/gns3server/api/routes/compute/__init__.py @@ -31,6 +31,7 @@ from gns3server.compute.compute_error import ( from gns3server.compute.error import ImageMissingError, NodeError from gns3server.compute.ubridge.ubridge_error import UbridgeError from gns3server.controller.gns3vm.gns3_vm_error import GNS3VMError +from gns3server.version import __api_version__ from . import ( atm_switch_nodes, @@ -60,7 +61,8 @@ log = logging.getLogger(__name__) compute_api = FastAPI( title="GNS3 compute API", description="This page describes the private compute API for GNS3. PLEASE DO NOT USE DIRECTLY!", - version="3.0.0", + version=__api_version__, + servers=[{"url": "/v3/compute"}], ) compute_api.state.controller_host = None diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index e22d3d507..49e1b65cb 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException, status # Check AI Copilot availability from gns3server.agent import AI_COPILOT_AVAILABLE +from gns3server.config import Config # Conditionally import AI-dependent routes if AI_COPILOT_AVAILABLE: @@ -32,9 +33,11 @@ else: _copilot_router = APIRouter() _llm_router = APIRouter() - @_chat_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) - @_copilot_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) - @_llm_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) + @_chat_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False) + @_copilot_router.api_route( + "/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False + ) + @_llm_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False) async def ai_not_available(path: str = ""): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, @@ -67,6 +70,8 @@ from . import ( ) from .dependencies.authentication import get_current_active_user +_include_ai_in_schema = Config.instance().settings.Server.openapi_include_ai + router = APIRouter() router.include_router(controller.router, tags=["Controller"]) @@ -116,11 +121,19 @@ router.include_router( ) router.include_router( - _llm_router, prefix="/access", dependencies=[Depends(get_current_active_user)], tags=["LLM Model Configurations"] + _llm_router, + prefix="/access", + dependencies=[Depends(get_current_active_user)], + tags=["LLM Model Configurations"], + include_in_schema=_include_ai_in_schema, ) router.include_router( - _copilot_router, prefix="/copilot", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"] + _copilot_router, + prefix="/copilot", + dependencies=[Depends(get_current_active_user)], + tags=["GNS3 Copilot"], + include_in_schema=_include_ai_in_schema, ) router.include_router( @@ -128,6 +141,7 @@ router.include_router( prefix="/copilot/projects/{project_id}/chat", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"], + include_in_schema=_include_ai_in_schema, ) router.include_router(api_keys.router, dependencies=[Depends(get_current_active_user)], tags=["API Keys"]) diff --git a/gns3server/api/server.py b/gns3server/api/server.py index a0f02085a..ad7c56675 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -39,6 +39,7 @@ from gns3server.agent import MCP_AVAILABLE from gns3server.api.errors import customize_openapi_errors, error_response from gns3server.api.routes import controller, index from gns3server.api.routes.compute import compute_api +from gns3server.config import Config from gns3server.controller.controller_error import ( ComputeConflictError, ControllerBadRequestError, @@ -49,6 +50,7 @@ from gns3server.controller.controller_error import ( ControllerUnauthorizedError, ) from gns3server.core import tasks +from gns3server.version import __api_version__ if MCP_AVAILABLE: from gns3server.agent import mcp @@ -59,7 +61,7 @@ else: _mcp_router = APIRouter(prefix="/mcp", tags=["MCP"]) - @_mcp_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) + @_mcp_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False) async def mcp_not_available(path: str = ""): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, @@ -84,15 +86,9 @@ def get_application() -> FastAPI: application = ControllerAPI( lifespan=tasks.lifespan, title="GNS3 controller API", - description="This page describes the public controller API for GNS3.\n\n" - "## Notification streams\n\n" - "Notifications are available as HTTP streams of newline delimited JSON objects (`GET /v3/notifications` and " - "`GET /v3/projects/{project_id}/notifications`) and as WebSockets sending one JSON object per text frame " - "(`/v3/notifications/ws`, `/v3/projects/{project_id}/notifications/ws` and " - "`/v3/projects/{project_id}/notifications/markers/ws`). " - "OpenAPI cannot describe WebSocket routes, they carry the same `Notification` messages as the HTTP streams. " - "Marker events (`marker.match`) are only sent on the markers WebSocket.", - version="3.0.0", + description="This page describes the public controller API for GNS3", + version=__api_version__, + servers=[{"url": "/"}], docs_url=None, redoc_url=None, ) @@ -111,7 +107,12 @@ def get_application() -> FastAPI: application.mount("/v3/compute", compute_api, name="compute") # Register MCP routes (stub returns 501 if MCP dependencies are not installed) - application.include_router(_mcp_router, prefix="/v3", tags=["MCP"]) + application.include_router( + _mcp_router, + prefix="/v3", + tags=["MCP"], + include_in_schema=Config.instance().settings.Server.openapi_include_ai, + ) return application diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index f6c978911..f018dc3e2 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -155,6 +155,9 @@ class ServerSettings(BaseModel): False, description="Local server mode, set by the --local command line argument (not meant to be set by hand)" ) enable_http_auth: bool = Field(True, description="Enable compute HTTP authentication") + openapi_include_ai: bool = Field( + True, description="Include the AI Copilot, LLM and MCP routes in the generated OpenAPI specification" + ) name: str = Field( f"{socket.gethostname()} (controller)", description="Server name, default is what is returned by socket.gethostname()", diff --git a/gns3server/schemas/controller/settings.py b/gns3server/schemas/controller/settings.py index aa457dc31..600b41ba7 100644 --- a/gns3server/schemas/controller/settings.py +++ b/gns3server/schemas/controller/settings.py @@ -103,6 +103,7 @@ class ServerSettingsUpdate(BaseModel): local: Optional[bool] = None enable_http_auth: Optional[bool] = None + openapi_include_ai: Optional[bool] = None name: Optional[str] = None protocol: Optional[ServerProtocol] = None host: Optional[str] = None diff --git a/gns3server/version.py b/gns3server/version.py index 2b8d74b08..541a3722e 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -35,3 +35,5 @@ if "dev" in __version__: __version__ += "+" + r except Exception as e: print(e) + +__api_version__ = __version__.split("+", 1)[0] diff --git a/pyproject.toml b/pyproject.toml index ef7b82573..6cba871da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,6 +219,7 @@ extend-immutable-calls = [ "scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"] "tests/agent/mcp/test_handlers.py" = ["B905"] "tests/agent/mcp/test_tool_params.py" = ["C901"] +"tests/api/test_openapi_spec.py" = ["S603"] "tests/api/routes/controller/test_images.py" = ["S324", "W292"] "tests/api/routes/controller/test_links.py" = ["RUF015", "W292"] "tests/api/routes/controller/test_users.py" = ["F811"] diff --git a/tests/api/test_openapi_spec.py b/tests/api/test_openapi_spec.py new file mode 100644 index 000000000..0562680c8 --- /dev/null +++ b/tests/api/test_openapi_spec.py @@ -0,0 +1,114 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import subprocess +import sys + +import pytest +from fastapi import FastAPI + +from gns3server.agent import AI_COPILOT_AVAILABLE, MCP_AVAILABLE +from gns3server.version import __api_version__, __version__ + +pytestmark = pytest.mark.asyncio + +AI_PATH_PREFIXES = ( + "/v3/copilot", + "/v3/mcp", + "/v3/access/llm", + "/v3/access/users/{user_id}/llm-model-configs", + "/v3/access/groups/{group_id}/llm-model-configs", +) + +SPEC_SCRIPT = """ +import json +import sys + +from gns3server.config import Config + +Config.instance(files=[sys.argv[1]]) + +from gns3server.api.server import app + +print(json.dumps(app.openapi())) +""" + + +def _operation_ids(spec: dict) -> list: + return [ + operation["operationId"] + for path_item in spec["paths"].values() + for operation in path_item.values() + if isinstance(operation, dict) and "operationId" in operation + ] + + +def _build_spec(tmp_path, include_ai: bool) -> dict: + config_file = tmp_path / "gns3_server.conf" + config_file.write_text(f"[Server]\nopenapi_include_ai = {str(include_ai).lower()}\n") + result = subprocess.run( + [sys.executable, "-c", SPEC_SCRIPT, str(config_file)], + capture_output=True, + text=True, + check=True, + timeout=120, + ) + return json.loads(result.stdout.splitlines()[-1]) + + +class TestOpenAPISpec: + async def test_controller_version_matches_package(self, app: FastAPI) -> None: + + spec = app.openapi() + assert spec["info"]["version"] == __api_version__ + assert spec["info"]["version"] == __version__.split("+")[0] + assert "+" not in spec["info"]["version"] + + async def test_compute_version_matches_package(self, app: FastAPI) -> None: + + compute_app = next(route.app for route in app.routes if getattr(route, "path", None) == "/v3/compute") + assert compute_app.openapi()["info"]["version"] == __api_version__ + + async def test_servers_entry(self, app: FastAPI) -> None: + + assert app.openapi()["servers"] == [{"url": "/"}] + + async def test_no_catch_all_operations(self, app: FastAPI) -> None: + + paths = app.openapi()["paths"] + assert [path for path in paths if "{path}" in path] == [] + + async def test_operation_ids_are_unique(self, app: FastAPI) -> None: + + operation_ids = _operation_ids(app.openapi()) + assert len(operation_ids) == len(set(operation_ids)) + + @pytest.mark.skipif(AI_COPILOT_AVAILABLE or MCP_AVAILABLE, reason="AI extras are installed") + async def test_ai_routes_absent_without_extras(self, app: FastAPI) -> None: + + paths = app.openapi()["paths"] + assert [path for path in paths if path.startswith(AI_PATH_PREFIXES)] == [] + + async def test_setting_hides_ai_routes_and_keeps_the_rest(self, tmp_path) -> None: + + with_ai = _build_spec(tmp_path, include_ai=True) + without_ai = _build_spec(tmp_path, include_ai=False) + + assert [path for path in without_ai["paths"] if path.startswith(AI_PATH_PREFIXES)] == [] + non_ai_paths = {path: item for path, item in with_ai["paths"].items() if not path.startswith(AI_PATH_PREFIXES)} + assert without_ai["paths"] == non_ai_paths + assert len(_operation_ids(without_ai)) == len(set(_operation_ids(without_ai)))