From db031416a17df9cc01f0a6d32e08f7326cfc8401 Mon Sep 17 00:00:00 2001 From: Mark Paronyan Date: Wed, 7 Oct 2026 10:49:13 +0000 Subject: [PATCH 1/5] feat: add response models for endpoints returning untyped objects --- gns3server/api/routes/controller/acl.py | 6 +- gns3server/api/routes/controller/api_keys.py | 50 +- .../api/routes/controller/appliances.py | 2 + gns3server/api/routes/controller/computes.py | 2 +- .../api/routes/controller/controller.py | 7 +- gns3server/api/routes/controller/gns3vm.py | 4 +- gns3server/api/routes/controller/images.py | 7 +- gns3server/api/routes/controller/links.py | 30 +- gns3server/api/routes/controller/nodes.py | 6 +- gns3server/api/routes/controller/projects.py | 33 +- gns3server/api/routes/controller/symbols.py | 11 +- gns3server/schemas/__init__.py | 81 ++- gns3server/schemas/controller/computes.py | 8 + gns3server/schemas/controller/gns3vm.py | 21 + gns3server/schemas/controller/images.py | 32 +- gns3server/schemas/controller/links.py | 78 +++ .../schemas/controller/marker_replay.py | 114 +++++ gns3server/schemas/controller/projects.py | 12 + gns3server/schemas/controller/rbac.py | 10 + gns3server/schemas/controller/statistics.py | 131 +++++ gns3server/schemas/controller/symbols.py | 37 ++ gns3server/schemas/controller/tokens.py | 28 ++ tests/api/routes/controller/test_markers.py | 71 ++- .../routes/controller/test_response_models.py | 464 ++++++++++++++++++ 24 files changed, 1192 insertions(+), 53 deletions(-) create mode 100644 gns3server/schemas/controller/marker_replay.py create mode 100644 gns3server/schemas/controller/statistics.py create mode 100644 gns3server/schemas/controller/symbols.py create mode 100644 tests/api/routes/controller/test_response_models.py diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py index a454a1d61..49f814966 100644 --- a/gns3server/api/routes/controller/acl.py +++ b/gns3server/api/routes/controller/acl.py @@ -80,7 +80,11 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes) -@router.get("/endpoints", dependencies=[Depends(has_privilege("ACE.Audit"))]) +@router.get( + "/endpoints", + response_model=List[schemas.ACEEndpoint], + dependencies=[Depends(has_privilege("ACE.Audit"))], +) async def endpoints( users_repo: UsersRepository = Depends(get_repository(UsersRepository)), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py index 42f1d66c2..c3297e612 100644 --- a/gns3server/api/routes/controller/api_keys.py +++ b/gns3server/api/routes/controller/api_keys.py @@ -50,12 +50,12 @@ def _generate_api_key(api_key_id: UUID | None = None) -> tuple[str, str, str, UU return raw_key, key_hash, key_prefix, api_key_id -@router.post("", status_code=status.HTTP_201_CREATED) +@router.post("", response_model=schemas.ApiKeyCreated, status_code=status.HTTP_201_CREATED) async def create_api_key( api_key_data: schemas.ApiKeyCreate, current_user: schemas.User = Depends(get_current_active_user), api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), -) -> dict: +) -> schemas.ApiKeyCreated: """Create a new API key. The full key is returned only once.""" raw_key, key_hash, key_prefix, new_key_id = _generate_api_key() @@ -66,42 +66,42 @@ async def create_api_key( key_hash=key_hash, key_prefix=key_prefix, ) - return { - "api_key_id": str(db_key.api_key_id), - "api_key": raw_key, - "name": db_key.name, - "key_prefix": db_key.key_prefix, - "created_at": db_key.created_at.isoformat() if db_key.created_at else None, - } + return schemas.ApiKeyCreated( + api_key_id=db_key.api_key_id, + api_key=raw_key, + name=db_key.name, + key_prefix=db_key.key_prefix, + created_at=db_key.created_at.isoformat() if db_key.created_at else None, + ) -@router.get("") +@router.get("", response_model=list[schemas.ApiKey]) async def list_api_keys( current_user: schemas.User = Depends(get_current_active_user), api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), -) -> list[dict]: +) -> list[schemas.ApiKey]: """List all API keys for the current user.""" keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id) return [ - { - "api_key_id": str(k.api_key_id), - "name": k.name, - "key_prefix": k.key_prefix, - "created_at": k.created_at.isoformat() if k.created_at else None, - "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None, - "revoked": k.revoked, - } + schemas.ApiKey( + api_key_id=k.api_key_id, + name=k.name, + key_prefix=k.key_prefix, + created_at=k.created_at.isoformat() if k.created_at else None, + last_used_at=k.last_used_at.isoformat() if k.last_used_at else None, + revoked=k.revoked, + ) for k in keys ] -@router.post("/{api_key_id}/revoke", status_code=status.HTTP_200_OK) +@router.post("/{api_key_id}/revoke", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK) async def revoke_api_key( api_key_id: UUID, current_user: schemas.User = Depends(get_current_active_user), api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), -) -> dict: +) -> schemas.ApiKeyMessage: """Revoke an API key. It will immediately stop working, but can be restored.""" key = await api_keys_repo.get_api_key(api_key_id) @@ -111,15 +111,15 @@ async def revoke_api_key( raise HTTPException(status_code=403, detail="Cannot modify another user's API key") await api_keys_repo.revoke_api_key(api_key_id) - return {"message": f"API key '{key.name}' revoked"} + return schemas.ApiKeyMessage(message=f"API key '{key.name}' revoked") -@router.post("/{api_key_id}/restore", status_code=status.HTTP_200_OK) +@router.post("/{api_key_id}/restore", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK) async def restore_api_key( api_key_id: UUID, current_user: schemas.User = Depends(get_current_active_user), api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), -) -> dict: +) -> schemas.ApiKeyMessage: """Restore a previously revoked API key.""" key = await api_keys_repo.get_api_key(api_key_id) @@ -129,7 +129,7 @@ async def restore_api_key( raise HTTPException(status_code=403, detail="Cannot modify another user's API key") await api_keys_repo.restore_api_key(api_key_id) - return {"message": f"API key '{key.name}' restored"} + return schemas.ApiKeyMessage(message=f"API key '{key.name}' restored") @router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index ad8645554..138b16fa0 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -82,6 +82,8 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance: @router.post( "/{appliance_id}/version", + response_model=schemas.Appliance, + response_model_exclude_unset=True, status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Appliance.Allocate"))], ) diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py index a33ebc9f6..796a72063 100644 --- a/gns3server/api/routes/controller/computes.py +++ b/gns3server/api/routes/controller/computes.py @@ -212,7 +212,7 @@ async def vmware_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVMware return result -@router.post("/{compute_id}/dynamips/auto_idlepc") +@router.post("/{compute_id}/dynamips/auto_idlepc", response_model=schemas.IdlePC) async def dynamips_autoidlepc(compute_id: Union[str, UUID], auto_idle_pc: schemas.AutoIdlePC): """ Find a suitable Idle-PC value for a given IOS image. This may take a few minutes. diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py index 1b8667ec8..924319ced 100644 --- a/gns3server/api/routes/controller/controller.py +++ b/gns3server/api/routes/controller/controller.py @@ -161,7 +161,12 @@ async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULice return current_iou_license -@router.get("/statistics", dependencies=[Depends(get_current_active_user)]) +@router.get( + "/statistics", + response_model=schemas.ServerStatistics, + response_model_exclude_unset=True, + dependencies=[Depends(get_current_active_user)], +) async def statistics() -> dict: """ Return server statistics including compute resources, projects, and nodes. diff --git a/gns3server/api/routes/controller/gns3vm.py b/gns3server/api/routes/controller/gns3vm.py index e0ae90eba..21d2cdf49 100644 --- a/gns3server/api/routes/controller/gns3vm.py +++ b/gns3server/api/routes/controller/gns3vm.py @@ -30,7 +30,7 @@ from gns3server.controller import Controller router = APIRouter() -@router.get("/engines") +@router.get("/engines", response_model=List[schemas.GNS3VMEngineInfo]) async def get_engines() -> List[dict]: """ Return the list of supported engines for the GNS3VM. @@ -40,7 +40,7 @@ async def get_engines() -> List[dict]: return gns3_vm.engine_list() -@router.get("/engines/{engine}/vms") +@router.get("/engines/{engine}/vms", response_model=List[schemas.GNS3VMInfo]) async def get_vms(engine: str) -> List[dict]: """ Return all the available VMs for a specific virtualization engine. diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index f35c2417f..06a356807 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -327,7 +327,12 @@ async def prune_images( ) -@router.post("/install", status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))]) +@router.post( + "/install", + response_model=schemas.ImageInstallResult, + status_code=status.HTTP_200_OK, + dependencies=[Depends(has_privilege("Image.Allocate"))], +) async def install_images( images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index b7fa2b40d..c06ef762a 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -20,7 +20,7 @@ API routes for links. import logging import os -from typing import Any, List, Union +from typing import Any, Dict, List, Union from uuid import UUID, uuid4 import aiohttp @@ -123,7 +123,12 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche return link.asdict() -@router.get("/{link_id}/available_filters", dependencies=[Depends(has_privilege("Link.Audit"))]) +@router.get( + "/{link_id}/available_filters", + response_model=List[schemas.LinkFilterInfo], + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Link.Audit"))], +) async def get_filters(link: Link = Depends(dep_link)) -> List[dict]: """ Return all filters available for a given link. @@ -253,6 +258,7 @@ async def stop_capture(link: Link = Depends(dep_link)) -> None: @router.post( "/{link_id}/capture/wireshark/restart", + response_model=schemas.LinkWiresharkRestart, status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Link.Capture"))], ) @@ -405,7 +411,12 @@ async def web_wireshark_websocket( pass -@router.get("/{link_id}/markers", dependencies=[Depends(has_privilege("Link.Audit"))]) +@router.get( + "/{link_id}/markers", + response_model=Dict[str, schemas.Marker], + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Link.Audit"))], +) async def get_markers(link: Link = Depends(dep_link)) -> dict: """ Return all traffic-insight markers configured on this link. @@ -417,7 +428,11 @@ async def get_markers(link: Link = Depends(dep_link)) -> dict: @router.post( - "/{link_id}/markers", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Link.Modify"))] + "/{link_id}/markers", + response_model=schemas.Marker, + response_model_exclude_unset=True, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(has_privilege("Link.Modify"))], ) async def create_marker(marker_data: schemas.MarkerCreate, link: Link = Depends(dep_link)) -> dict: """ @@ -462,7 +477,12 @@ async def delete_marker(marker_name: str, link: Link = Depends(dep_link)) -> Non await link.stop_marker(marker_name) -@router.put("/{link_id}/markers/{marker_name}", dependencies=[Depends(has_privilege("Link.Modify"))]) +@router.put( + "/{link_id}/markers/{marker_name}", + response_model=schemas.Marker, + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Link.Modify"))], +) async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, link: Link = Depends(dep_link)) -> dict: """ Update a traffic-insight marker (change BPF, tag, or enabled). diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index 18134ee36..edf797191 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -447,7 +447,11 @@ async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]: return links -@router.get("/{node_id}/dynamips/auto_idlepc", dependencies=[Depends(has_privilege("Node.Audit"))]) +@router.get( + "/{node_id}/dynamips/auto_idlepc", + response_model=schemas.IdlePC, + dependencies=[Depends(has_privilege("Node.Audit"))], +) async def auto_idlepc(node: Node = Depends(dep_node)) -> dict: """ Compute an Idle-PC value for a Dynamips node diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 264dd475b..a6cc79bce 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -31,7 +31,7 @@ import gns3server.utils.zipfile_zstd as zipfile log = logging.getLogger() -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status @@ -199,7 +199,9 @@ async def delete_project( await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}") -@router.get("/{project_id}/stats", dependencies=[Depends(has_privilege("Project.Audit"))]) +@router.get( + "/{project_id}/stats", response_model=schemas.ProjectStats, dependencies=[Depends(has_privilege("Project.Audit"))] +) def get_project_stats(project: Project = Depends(dep_project)) -> dict: """ Return a project statistics. @@ -210,7 +212,12 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict: return project.stats() -@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))]) +@router.get( + "/{project_id}/markers", + response_model=Dict[str, schemas.ProjectMarker], + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Project.Audit"))], +) def get_project_markers(project: Project = Depends(dep_project)) -> dict: """ Return all traffic-insight markers across every link in the project. @@ -241,6 +248,7 @@ async def _replay_response(awaitable): @router.get( "/{project_id}/markers/tags/{tag}/replay/range", + response_model=schemas.ReplayTimeline, dependencies=[Depends(has_privilege("Project.Audit"))], ) async def replay_tag_range( @@ -281,6 +289,7 @@ async def replay_tag_range( @router.get( "/{project_id}/markers/tags/{tag}/replay/frames", + response_model=schemas.ReplayFrames, dependencies=[Depends(has_privilege("Project.Audit"))], ) async def replay_tag_frames( @@ -321,6 +330,8 @@ async def replay_tag_frames( @router.get( "/{project_id}/markers/tags/{tag}/replay/frame/detail", + response_model=schemas.ReplayFrameDetail, + response_model_exclude_unset=True, dependencies=[Depends(has_privilege("Project.Audit"))], ) async def replay_tag_frame_detail( @@ -359,7 +370,12 @@ async def replay_tag_frame_detail( # --------------------------------------------------------------------------- -@router.get("/{project_id}/marker-definitions", dependencies=[Depends(has_privilege("Project.Audit"))]) +@router.get( + "/{project_id}/marker-definitions", + response_model=Dict[str, schemas.MarkerDefinitionWithLinks], + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Project.Audit"))], +) def get_marker_definitions(project: Project = Depends(dep_project)) -> dict: """ Return all project-level marker definitions with their bound link IDs. @@ -381,6 +397,8 @@ def get_marker_definitions(project: Project = Depends(dep_project)) -> dict: @router.post( "/{project_id}/marker-definitions", + response_model=schemas.MarkerDefinition, + response_model_exclude_unset=True, status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Project.Modify"))], ) @@ -408,7 +426,12 @@ async def create_marker_definition( return project.marker_definitions.get(name, {}) -@router.put("/{project_id}/marker-definitions/{def_name}", dependencies=[Depends(has_privilege("Project.Modify"))]) +@router.put( + "/{project_id}/marker-definitions/{def_name}", + response_model=schemas.MarkerDefinition, + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Project.Modify"))], +) async def update_marker_definition( def_name: str, def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project) ) -> dict: diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py index 98d6a0c91..bcd379064 100644 --- a/gns3server/api/routes/controller/symbols.py +++ b/gns3server/api/routes/controller/symbols.py @@ -21,7 +21,7 @@ API routes for symbols. import logging import os -from typing import List +from typing import Dict, List from fastapi import APIRouter, Depends, Request, Response, status from fastapi.responses import FileResponse @@ -39,7 +39,7 @@ log = logging.getLogger(__name__) router = APIRouter() -@router.get("", dependencies=[Depends(has_privilege("Symbol.Audit"))]) +@router.get("", response_model=List[schemas.SymbolInfo], dependencies=[Depends(has_privilege("Symbol.Audit"))]) def get_symbols() -> List[dict]: """ Return all symbols. @@ -78,6 +78,7 @@ async def get_symbol(symbol_id: str, request: Request) -> Response: @router.get( "/{symbol_id:path}/dimensions", + response_model=schemas.SymbolDimensions, responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}}, dependencies=[Depends(has_privilege("Symbol.Audit"))], ) @@ -97,7 +98,11 @@ async def get_symbol_dimensions(symbol_id: str) -> dict: raise ControllerNotFoundError(f"Could not get symbol file: {e}") -@router.get("/default_symbols", dependencies=[Depends(has_privilege("Symbol.Audit"))]) +@router.get( + "/default_symbols", + response_model=Dict[str, Dict[str, str]], + dependencies=[Depends(has_privilege("Symbol.Audit"))], +) def get_default_symbols() -> dict: """ Return all default symbols. diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index a5eb8406c..4e06d8d2e 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -26,10 +26,20 @@ from .controller.computes import ( ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, + IdlePC, ) from .controller.drawings import Drawing -from .controller.gns3vm import GNS3VM -from .controller.images import Image, ImageSyncJob, ImageSyncRequest, ImageType, ImageUpload +from .controller.gns3vm import GNS3VM, GNS3VMEngineInfo, GNS3VMInfo +from .controller.images import ( + Image, + ImageInstallCreated, + ImageInstallResult, + ImageInstallSkipped, + ImageSyncJob, + ImageSyncRequest, + ImageType, + ImageUpload, +) # Controller schemas from .controller.links import ( @@ -37,10 +47,17 @@ from .controller.links import ( Link, LinkCapture, LinkCreate, + LinkFilterInfo, + LinkFilterParameter, LinkUpdate, + LinkWiresharkRestart, + Marker, MarkerCreate, + MarkerDefinition, MarkerDefinitionCreate, + MarkerDefinitionWithLinks, MarkerUpdate, + ProjectMarker, UDPPortInfo, ) from .controller.nodes import Node, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate @@ -51,6 +68,7 @@ from .controller.projects import ( ProjectCreate, ProjectDuplicate, ProjectFile, + ProjectStats, ProjectUpdate, ) from .controller.templates import Template, TemplateCreate, TemplateUpdate, TemplateUsage @@ -108,11 +126,31 @@ from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate from .controller.capabilities import Capabilities from .controller.iou_license import IOULicense +from .controller.marker_replay import ( + ReplayFrame, + ReplayFrameDetail, + ReplayFrames, + ReplayFrameSource, + ReplaySource, + ReplayTimeline, + ReplayTreeNode, +) from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList from .controller.pools import Resource, ResourceCreate, ResourcePool, ResourcePoolCreate, ResourcePoolUpdate -from .controller.rbac import ACE, ACECreate, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate +from .controller.rbac import ACE, ACECreate, ACEEndpoint, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate from .controller.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse from .controller.snapshots import Snapshot, SnapshotCreate +from .controller.statistics import ( + ComputeStatistics, + ComputeStatisticsEntry, + LinkStatistics, + NodeStatistics, + ProjectCountStatistics, + ServerStatistics, + WebWiresharkContainer, + WebWiresharkStatistics, +) +from .controller.symbols import SymbolDimensions, SymbolInfo from .controller.templates.cloud_templates import CloudTemplate, CloudTemplateUpdate from .controller.templates.docker_templates import DockerTemplate, DockerTemplateUpdate from .controller.templates.dynamips_templates import ( @@ -141,7 +179,7 @@ from .controller.templates.vmware_templates import VMwareTemplate, VMwareTemplat # Controller template schemas from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate -from .controller.tokens import ApiKeyCreate, RefreshTokenRequest, Token +from .controller.tokens import ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyMessage, RefreshTokenRequest, Token # Schemas for both controller and compute from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageFormat, QemuDiskImageUpdate @@ -155,11 +193,15 @@ __all__ = [ "UDPNIO", "VPCS", "ACECreate", + "ACEEndpoint", "ACEUpdate", "ATMSwitch", "ATMSwitchCreate", "ATMSwitchUpdate", + "ApiKey", "ApiKeyCreate", + "ApiKeyCreated", + "ApiKeyMessage", "Appliance", "ApplianceVersion", "ApplianceVersionV8", @@ -192,6 +234,8 @@ __all__ = [ "Compute", "ComputeCreate", "ComputeDockerImage", + "ComputeStatistics", + "ComputeStatisticsEntry", "ComputeUpdate", "ComputeVMwareVM", "ComputeVirtualBoxVM", @@ -223,13 +267,19 @@ __all__ = [ "FrameRelaySwitch", "FrameRelaySwitchCreate", "FrameRelaySwitchUpdate", + "GNS3VMEngineInfo", + "GNS3VMInfo", "IOUCreate", "IOULicense", "IOUStart", "IOUTemplate", "IOUTemplateUpdate", "IOUUpdate", + "IdlePC", "Image", + "ImageInstallCreated", + "ImageInstallResult", + "ImageInstallSkipped", "ImageSyncJob", "ImageSyncRequest", "ImageType", @@ -244,10 +294,17 @@ __all__ = [ "Link", "LinkCapture", "LinkCreate", + "LinkFilterInfo", + "LinkFilterParameter", + "LinkStatistics", "LinkUpdate", + "LinkWiresharkRestart", "LoggedInUserUpdate", + "Marker", "MarkerCreate", + "MarkerDefinition", "MarkerDefinitionCreate", + "MarkerDefinitionWithLinks", "MarkerRebuild", "MarkerToggle", "MarkerUpdate", @@ -260,15 +317,19 @@ __all__ = [ "NodeCreate", "NodeDuplicate", "NodeFile", + "NodeStatistics", "NodeUpdate", "OpenAIMessage", "OpenAIToolCall", "Privilege", "Project", "ProjectCompression", + "ProjectCountStatistics", "ProjectCreate", "ProjectDuplicate", "ProjectFile", + "ProjectMarker", + "ProjectStats", "ProjectUpdate", "Qemu", "QemuCreate", @@ -280,6 +341,13 @@ __all__ = [ "QemuUpdate", "RefreshTokenRequest", "RenameSession", + "ReplayFrame", + "ReplayFrameDetail", + "ReplayFrameSource", + "ReplayFrames", + "ReplaySource", + "ReplayTimeline", + "ReplayTreeNode", "Resource", "ResourceCreate", "ResourcePool", @@ -289,11 +357,14 @@ __all__ = [ "RoleCreate", "RoleUpdate", "ServerConfig", + "ServerStatistics", "SettingsResponse", "SettingsUpdate", "SettingsUpdateResponse", "Snapshot", "SnapshotCreate", + "SymbolDimensions", + "SymbolInfo", "Template", "TemplateCreate", "TemplateUpdate", @@ -321,4 +392,6 @@ __all__ = [ "VirtualBoxTemplate", "VirtualBoxTemplateUpdate", "VirtualBoxUpdate", + "WebWiresharkContainer", + "WebWiresharkStatistics", ] diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py index 3db2656cc..0d5c9f9c6 100644 --- a/gns3server/schemas/controller/computes.py +++ b/gns3server/schemas/controller/computes.py @@ -161,3 +161,11 @@ class AutoIdlePC(BaseModel): model_config = ConfigDict( json_schema_extra={"example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}} ) + + +class IdlePC(BaseModel): + """ + Result of an Idle-PC computation. + """ + + idlepc: Optional[str] = Field(None, description="Idle-PC value, null when none was found") diff --git a/gns3server/schemas/controller/gns3vm.py b/gns3server/schemas/controller/gns3vm.py index 71a975d43..2c39a5657 100644 --- a/gns3server/schemas/controller/gns3vm.py +++ b/gns3server/schemas/controller/gns3vm.py @@ -55,3 +55,24 @@ class GNS3VM(BaseModel): vcpus: Optional[int] = Field(None, description="Number of CPUs to allocate for the GNS3 VM") ram: Optional[int] = Field(None, description="Amount of memory to allocate for the GNS3 VM") port: Optional[int] = Field(None, gt=0, le=65535) + + +class GNS3VMEngineInfo(BaseModel): + """ + A virtualization engine supported by the GNS3 VM. + """ + + engine_id: str = Field(..., description="Engine identifier") + name: str = Field(..., description="Engine display name") + description: str = Field(..., description="Engine description (may contain HTML)") + support_when_exit: bool = Field(..., description="The engine supports the 'when exit' action") + support_headless: bool = Field(..., description="The engine supports headless mode") + support_ram: bool = Field(..., description="The engine supports RAM and vCPUs allocation") + + +class GNS3VMInfo(BaseModel): + """ + A virtual machine available on a virtualization engine. + """ + + vmname: str = Field(..., description="Virtual machine name") diff --git a/gns3server/schemas/controller/images.py b/gns3server/schemas/controller/images.py index 92ba88775..17eb73ad3 100644 --- a/gns3server/schemas/controller/images.py +++ b/gns3server/schemas/controller/images.py @@ -16,7 +16,8 @@ from datetime import datetime from enum import Enum -from typing import Annotated, Literal, Optional +from typing import Annotated, List, Literal, Optional +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -100,3 +101,32 @@ class ImageCompatibility(BaseModel): class ImageCompatibilityCatalog(BaseModel): image_sizes: list[int] has_unknown_sizes: bool + + +class ImageInstallCreated(BaseModel): + """ + A template created from an image by the automatic installation. + """ + + template_id: UUID + name: Optional[str] = None + version: Optional[str] = None + template_type: Optional[str] = None + + +class ImageInstallSkipped(BaseModel): + """ + An image or template that the automatic installation skipped. + """ + + name: Optional[str] = None + reason: str + + +class ImageInstallResult(BaseModel): + """ + Result of the automatic template installation from images. + """ + + created: List[ImageInstallCreated] + skipped: List[ImageInstallSkipped] diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index ccb85d424..ecbdd5349 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -274,3 +274,81 @@ class MarkerDefinitionCreate(BaseModel): @classmethod def _both_to_none(cls, v): return None if v == "both" else v + + +class Marker(BaseModel): + """ + A traffic-insight marker attached to a link. + """ + + bpf: str = Field(..., description="libpcap BPF expression") + tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together") + enabled: bool = Field(True, description="Whether the marker is capturing") + color: Optional[str] = Field(None, description="Hex color used by the Web UI") + highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds") + capture_node_id: Optional[str] = Field(None, description="Node whose uBridge hosts the marker") + direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions") + data_link_type: Optional[str] = Field(None, description="pcap link-layer type of the capture file") + inherited_from: Optional[str] = Field(None, description="Project marker definition this marker was copied from") + + +class ProjectMarker(Marker): + """ + A marker in the project-wide aggregation, with the link and node it belongs to. + """ + + link_id: str + node_id: Optional[str] = None + + +class MarkerDefinition(BaseModel): + """ + A project-level marker definition inherited by every link. + """ + + bpf: str = Field(..., description="libpcap BPF expression") + tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together") + direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions") + color: Optional[str] = Field(None, description="Hex color used by the Web UI") + highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds") + data_link_type: Optional[str] = Field(None, description="pcap link-layer type of inherited markers") + paused: bool = Field(False, description="Whether the inherited markers are paused") + + +class MarkerDefinitionWithLinks(MarkerDefinition): + """ + A project-level marker definition with the links currently carrying a copy of it. + """ + + link_ids: List[str] = Field(..., description="Links carrying an inherited copy of the definition") + + +class LinkWiresharkRestart(BaseModel): + """ + Result of restarting the Wireshark window of a link capture. + """ + + status: str + + +class LinkFilterParameter(BaseModel): + """ + A parameter of a packet filter. + """ + + name: str + type: str + minimum: Optional[int] = None + maximum: Optional[int] = None + unit: Optional[str] = None + + +class LinkFilterInfo(BaseModel): + """ + A packet filter that can be applied to a link. + """ + + type: str = Field(..., description="Filter identifier used as key in the link 'filters' property") + name: str + description: str + parameters: List[LinkFilterParameter] diff --git a/gns3server/schemas/controller/marker_replay.py b/gns3server/schemas/controller/marker_replay.py new file mode 100644 index 000000000..a07d31cd3 --- /dev/null +++ b/gns3server/schemas/controller/marker_replay.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 . + +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ReplaySource(BaseModel): + """ + A capture source (one marker pcap) of a replay tag. + """ + + node_id: str + link_id: str + marker: str + data_link_type: str + count: int = Field(..., description="Total frames in the capture, unaffected by filters") + + +class ReplayFrame(BaseModel): + """ + One frame of the merged replay timeline. + """ + + ts: str = Field(..., description="Timestamp string to send back unchanged") + len: int = Field(..., description="Captured length in bytes") + node_id: str + link_id: str + marker: str + frame_number: int = Field(..., description="1-based frame number in the source pcap") + src: Optional[str] = None + dst: Optional[str] = None + proto: Optional[str] = None + info: Optional[str] = None + bg: Optional[str] = Field(None, description="Wireshark coloring rule background") + fg: Optional[str] = Field(None, description="Wireshark coloring rule foreground") + + +class ReplayTimeline(BaseModel): + """ + Replay timeline of a tag. + """ + + tag: int + start: Optional[str] = Field(None, description="Timestamp of the first matching frame") + end: Optional[str] = Field(None, description="Timestamp of the last matching frame") + frame_count: int + sources: List[ReplaySource] + frames: List[ReplayFrame] + + +class ReplayFrames(BaseModel): + """ + Frames of a replay time window. + """ + + frames: List[ReplayFrame] + + +class ReplayFrameSource(BaseModel): + """ + The source pcap and frame a decoded frame comes from. + """ + + node_id: str + link_id: str + marker: str + frame_number: int + + +class ReplayTreeNode(BaseModel): + """ + A node of the decoded protocol tree. + + Keys sharkd adds in the future are passed through unchanged. + """ + + model_config = ConfigDict(extra="allow") + + element: Optional[str] = Field(None, description="Node type") + label: Optional[str] = Field(None, description="Display text") + name: Optional[str] = Field(None, description="Field name, e.g. 'ip.ttl'") + filter_expr: Optional[str] = Field(None, description="Display filter with the value baked in") + pos: Optional[int] = Field(None, description="Byte offset for hex highlighting") + size: Optional[int] = Field(None, description="Byte length for hex highlighting") + expert: Optional[str] = Field(None, description="Expert severity") + generated: Optional[bool] = Field(None, description="Generated by Wireshark") + children: Optional[List["ReplayTreeNode"]] = Field(None, description="Nested fields") + + +class ReplayFrameDetail(BaseModel): + """ + One decoded frame. + """ + + ts: str + source: ReplayFrameSource + field_count: int + hex: str = Field(..., description="Raw frame bytes as hex") + tree: List[ReplayTreeNode] diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py index 6354b7696..8d440e092 100644 --- a/gns3server/schemas/controller/projects.py +++ b/gns3server/schemas/controller/projects.py @@ -126,3 +126,15 @@ class ProjectCompression(str, Enum): bzip2 = "bzip2" lzma = "lzma" zstd = "zstd" + + +class ProjectStats(BaseModel): + """ + Project object counts. + """ + + nodes: int + links: int + drawings: int + snapshots: int + markers: int diff --git a/gns3server/schemas/controller/rbac.py b/gns3server/schemas/controller/rbac.py index e02a46180..60ff78e96 100644 --- a/gns3server/schemas/controller/rbac.py +++ b/gns3server/schemas/controller/rbac.py @@ -109,3 +109,13 @@ class Role(DateTimeModelMixin, RoleBase): is_builtin: bool privileges: List[Privilege] model_config = ConfigDict(from_attributes=True) + + +class ACEEndpoint(BaseModel): + """ + An endpoint that can be used in an ACL entry. + """ + + endpoint: str = Field(..., description="Endpoint path to use as the ACE path") + name: str = Field(..., description="Human readable name of the endpoint") + endpoint_type: str = Field(..., description="Kind of resource the endpoint refers to") diff --git a/gns3server/schemas/controller/statistics.py b/gns3server/schemas/controller/statistics.py new file mode 100644 index 000000000..14ec8bc6d --- /dev/null +++ b/gns3server/schemas/controller/statistics.py @@ -0,0 +1,131 @@ +# +# 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 . + +from typing import Dict, List, Optional, Union + +from pydantic import BaseModel, Field + + +class ComputeStatistics(BaseModel): + """ + Resource usage reported by a compute. + + Fields come from the compute itself, so a compute running another build + may omit some of them. + """ + + memory_total: Optional[int] = None + memory_free: Optional[int] = None + memory_used: Optional[int] = None + swap_total: Optional[int] = None + swap_free: Optional[int] = None + swap_used: Optional[int] = None + cpu_usage_percent: Optional[int] = None + cpu_count: Optional[int] = None + cpu_count_physical: Optional[int] = None + cpu_model: Optional[str] = None + memory_usage_percent: Optional[int] = None + swap_usage_percent: Optional[int] = None + disk_usage_percent: Optional[int] = None + disk_total: Optional[int] = None + disk_used: Optional[int] = None + disk_free: Optional[int] = None + load_average: Optional[List[float]] = None + load_average_percent: Optional[List[float]] = None + + +class ComputeStatisticsEntry(BaseModel): + """ + Statistics of one compute. + """ + + compute_id: str + compute_name: str + statistics: ComputeStatistics + + +class ProjectCountStatistics(BaseModel): + """ + Project counts by status. + """ + + total: int + opened: int + closed: int + + +class NodeStatistics(BaseModel): + """ + Node counts across all projects. + """ + + total: int + open_project_nodes: int + closed_project_nodes: int + by_type: Dict[str, int] = Field(..., description="Node count by node type") + by_status: Dict[str, int] = Field(..., description="Node count by status, open projects only") + + +class LinkStatistics(BaseModel): + """ + Link counts across all projects. + """ + + total: int + capturing: int + + +class WebWiresharkContainer(BaseModel): + """ + A Web Wireshark container. + """ + + project_id: str + project_name: str + container_id: str + status: str + running: bool + active_sessions: Optional[int] = None + memory_limit: Optional[str] = None + cpu_limit: Optional[str] = None + pids_limit: Optional[Union[int, str]] = Field(None, description="Process limit, or 'unlimited'") + memory: Optional[str] = Field(None, description="Current memory usage") + cpu: Optional[str] = Field(None, description="Current CPU usage") + pids: Optional[int] = Field(None, description="Current process count") + + +class WebWiresharkStatistics(BaseModel): + """ + Web Wireshark container statistics across all opened projects. + """ + + total_containers: int + running_containers: int + active_sessions: int + containers: List[WebWiresharkContainer] + + +class ServerStatistics(BaseModel): + """ + Server statistics. + """ + + uptime_seconds: int + computes: List[ComputeStatisticsEntry] + projects: ProjectCountStatistics + nodes: NodeStatistics + links: LinkStatistics + webwireshark: WebWiresharkStatistics diff --git a/gns3server/schemas/controller/symbols.py b/gns3server/schemas/controller/symbols.py new file mode 100644 index 000000000..75f9e9f31 --- /dev/null +++ b/gns3server/schemas/controller/symbols.py @@ -0,0 +1,37 @@ +# +# 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 . + +from pydantic import BaseModel + + +class SymbolInfo(BaseModel): + """ + A symbol available on the controller. + """ + + symbol_id: str + filename: str + theme: str + builtin: bool + + +class SymbolDimensions(BaseModel): + """ + Symbol dimensions in pixels. + """ + + width: int + height: int diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py index c6bde5d0e..23b22c426 100644 --- a/gns3server/schemas/controller/tokens.py +++ b/gns3server/schemas/controller/tokens.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from typing import Optional +from uuid import UUID from pydantic import BaseModel @@ -41,3 +42,30 @@ class ApiKeyCreate(BaseModel): """Schema for creating a new API key.""" name: str + + +class ApiKey(BaseModel): + """API key metadata. The secret is never returned after creation.""" + + api_key_id: UUID + name: str + key_prefix: str + created_at: Optional[str] = None + last_used_at: Optional[str] = None + revoked: bool + + +class ApiKeyCreated(BaseModel): + """A newly created API key. The full key is returned only once.""" + + api_key_id: UUID + api_key: str + name: str + key_prefix: str + created_at: Optional[str] = None + + +class ApiKeyMessage(BaseModel): + """Confirmation message returned when an API key is revoked or restored.""" + + message: str diff --git a/tests/api/routes/controller/test_markers.py b/tests/api/routes/controller/test_markers.py index b53ec8533..bc4fecf30 100644 --- a/tests/api/routes/controller/test_markers.py +++ b/tests/api/routes/controller/test_markers.py @@ -19,6 +19,8 @@ HTTP-route tests for the traffic-insight marker endpoints: per-link markers, project-level definitions, and the project-wide aggregation view. """ +from unittest.mock import DEFAULT + import pytest from fastapi import FastAPI, status from httpx import AsyncClient @@ -43,6 +45,25 @@ def _inherited(link, name="arp"): } +def _record_marker(link): + """Side effect for a mocked ``start_marker`` that stores the marker like the real one does.""" + + def side_effect(*, name, bpf, tag=None, color=None, highlight_duration=None, direction=None, **kwargs): + link._markers[name] = { + "bpf": bpf, + "tag": tag, + "enabled": True, + "color": color, + "highlight_duration": highlight_duration, + "capture_node_id": "node-id", + "direction": direction, + "data_link_type": kwargs.get("data_link_type", "DLT_EN10MB"), + } + return DEFAULT + + return side_effect + + class TestMarkerRoutes: # ----------------------------------------------------------------------- # Per-link markers @@ -53,13 +74,25 @@ class TestMarkerRoutes: link = UDPLink(project) project._links = {link.id: link} - with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock: + with asyncio_patch( + "gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link) + ) as mock: response = await client.post( app.url_path_for("create_marker", project_id=project.id, link_id=link.id), json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800}, ) assert response.status_code == status.HTTP_201_CREATED + assert response.json() == { + "bpf": "icmp", + "tag": 3, + "enabled": True, + "color": "#ff5722", + "highlight_duration": 800, + "capture_node_id": "node-id", + "direction": None, + "data_link_type": "DLT_EN10MB", + } mock.assert_called_once() _, kwargs = mock.call_args assert kwargs["bpf"] == "icmp" @@ -73,12 +106,15 @@ class TestMarkerRoutes: link = UDPLink(project) project._links = {link.id: link} - with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock: + with asyncio_patch( + "gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link) + ) as mock: response = await client.post( app.url_path_for("create_marker", project_id=project.id, link_id=link.id), json={"name": "web", "bpf": "tcp port 80"}, ) assert response.status_code == status.HTTP_201_CREATED + assert response.json()["bpf"] == "tcp port 80" _, kwargs = mock.call_args assert kwargs["name"] == "web" @@ -141,6 +177,16 @@ class TestMarkerRoutes: async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None: link = UDPLink(project) + link._markers["web"] = { + "bpf": "udp port 53", + "tag": None, + "enabled": True, + "color": None, + "highlight_duration": 1500, + "capture_node_id": "node-id", + "direction": None, + "data_link_type": "DLT_EN10MB", + } project._links = {link.id: link} with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock: @@ -149,6 +195,7 @@ class TestMarkerRoutes: json={"bpf": "udp port 53", "highlight_duration": 1500}, ) assert response.status_code == status.HTTP_200_OK + assert response.json() == link._markers["web"] _, kwargs = mock.call_args assert kwargs["bpf"] == "udp port 53" assert kwargs["highlight_duration"] == 1500 @@ -195,12 +242,27 @@ class TestMarkerRoutes: async def test_create_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock: + def store_definition(*, name, bpf, highlight_duration=None, **kwargs): + project._marker_definitions[name] = { + "bpf": bpf, + "tag": None, + "direction": None, + "color": None, + "highlight_duration": highlight_duration, + "data_link_type": "DLT_EN10MB", + "paused": False, + } + return DEFAULT + + with asyncio_patch( + "gns3server.controller.project.Project.create_marker_definition", side_effect=store_definition + ) as mock: response = await client.post( app.url_path_for("create_marker_definition", project_id=project.id), json={"name": "arp", "bpf": "arp", "highlight_duration": 1200}, ) assert response.status_code == status.HTTP_201_CREATED + assert response.json() == project._marker_definitions["arp"] _, kwargs = mock.call_args assert kwargs["name"] == "arp" assert kwargs["bpf"] == "arp" @@ -220,12 +282,15 @@ class TestMarkerRoutes: async def test_update_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + project._marker_definitions["arp"] = {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900} + with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock: response = await client.put( app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"), json={"bpf": "arp or rarp", "highlight_duration": 900}, ) assert response.status_code == status.HTTP_200_OK + assert response.json() == {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900} _, kwargs = mock.call_args assert kwargs["bpf"] == "arp or rarp" assert kwargs["highlight_duration"] == 900 diff --git a/tests/api/routes/controller/test_response_models.py b/tests/api/routes/controller/test_response_models.py new file mode 100644 index 000000000..9ea0f6dda --- /dev/null +++ b/tests/api/routes/controller/test_response_models.py @@ -0,0 +1,464 @@ +# +# 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 . + +""" +Runtime JSON shape of the endpoints that used to return untyped objects, and +the OpenAPI contract that now describes them. +""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI, status +from httpx import AsyncClient + +from gns3server.controller import Controller, marker_replay +from gns3server.controller.link import Link +from gns3server.controller.project import Project +from gns3server.controller.udp_link import UDPLink +from tests.controller.test_marker_replay import _write_pcap + +pytestmark = pytest.mark.asyncio + +TYPED_OPERATIONS = [ + ("post", "/v3/access/api-keys"), + ("get", "/v3/access/api-keys"), + ("post", "/v3/access/api-keys/{api_key_id}/revoke"), + ("post", "/v3/access/api-keys/{api_key_id}/restore"), + ("get", "/v3/statistics"), + ("get", "/v3/projects/{project_id}/stats"), + ("post", "/v3/images/install"), + ("post", "/v3/appliances/{appliance_id}/version"), + ("get", "/v3/projects/{project_id}/markers"), + ("get", "/v3/projects/{project_id}/marker-definitions"), + ("post", "/v3/projects/{project_id}/marker-definitions"), + ("put", "/v3/projects/{project_id}/marker-definitions/{def_name}"), + ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/range"), + ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frames"), + ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frame/detail"), + ("get", "/v3/projects/{project_id}/links/{link_id}/markers"), + ("post", "/v3/projects/{project_id}/links/{link_id}/markers"), + ("put", "/v3/projects/{project_id}/links/{link_id}/markers/{marker_name}"), + ("get", "/v3/symbols"), + ("get", "/v3/symbols/{symbol_id}/dimensions"), + ("get", "/v3/symbols/default_symbols"), + ("get", "/v3/projects/{project_id}/links/{link_id}/available_filters"), + ("post", "/v3/projects/{project_id}/links/{link_id}/capture/wireshark/restart"), + ("get", "/v3/projects/{project_id}/nodes/{node_id}/dynamips/auto_idlepc"), + ("post", "/v3/computes/{compute_id}/dynamips/auto_idlepc"), + ("get", "/v3/gns3vm/engines"), + ("get", "/v3/gns3vm/engines/{engine}/vms"), + ("get", "/v3/access/acl/endpoints"), +] + + +def _is_untyped(schema: dict) -> bool: + if not schema: + return True + if "$ref" in schema: + return False + if schema.get("type") == "array": + return _is_untyped(schema.get("items", {})) + if schema.get("type") == "object": + extra = schema.get("additionalProperties") + if "properties" in schema: + return False + return extra in (None, True, {}) + for key in ("anyOf", "oneOf"): + if key in schema: + return any(_is_untyped(s) for s in schema[key] if s.get("type") != "null") + return False + + +class TestOpenAPIContract: + async def test_no_untyped_success_response(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.get("/openapi.json") + assert response.status_code == status.HTTP_200_OK + paths = response.json()["paths"] + + untyped = [] + for method, path in TYPED_OPERATIONS: + operation = paths[path][method] + success = [r for code, r in operation["responses"].items() if code.startswith("2") and code != "204"] + assert success, f"{method.upper()} {path} declares no success response" + for r in success: + schema = r["content"]["application/json"]["schema"] + if _is_untyped(schema): + untyped.append(f"{method.upper()} {path}") + assert not untyped + + +class TestApiKeys: + async def _create(self, app: FastAPI, client: AsyncClient, name: str = "ci") -> dict: + + response = await client.post(app.url_path_for("create_api_key"), json={"name": name}) + assert response.status_code == status.HTTP_201_CREATED + return response.json() + + async def test_create(self, app: FastAPI, client: AsyncClient) -> None: + + body = await self._create(app, client, "created") + assert set(body) == {"api_key_id", "api_key", "name", "key_prefix", "created_at"} + assert body["name"] == "created" + assert body["api_key"].startswith(f"gns3_{body['api_key_id']}_") + assert body["key_prefix"] == body["api_key"][:8] + assert isinstance(body["created_at"], str) + uuid.UUID(body["api_key_id"]) + + async def test_list(self, app: FastAPI, client: AsyncClient) -> None: + + created = await self._create(app, client, "listed") + response = await client.get(app.url_path_for("list_api_keys")) + assert response.status_code == status.HTTP_200_OK + entry = next(k for k in response.json() if k["api_key_id"] == created["api_key_id"]) + assert set(entry) == {"api_key_id", "name", "key_prefix", "created_at", "last_used_at", "revoked"} + assert entry["name"] == "listed" + assert entry["last_used_at"] is None + assert entry["revoked"] is False + assert entry["created_at"] == created["created_at"] + + async def test_revoke_and_restore(self, app: FastAPI, client: AsyncClient) -> None: + + created = await self._create(app, client, "toggled") + key_id = created["api_key_id"] + + response = await client.post(app.url_path_for("revoke_api_key", api_key_id=key_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "API key 'toggled' revoked"} + keys = (await client.get(app.url_path_for("list_api_keys"))).json() + assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is True + + response = await client.post(app.url_path_for("restore_api_key", api_key_id=key_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "API key 'toggled' restored"} + keys = (await client.get(app.url_path_for("list_api_keys"))).json() + assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is False + + async def test_revoke_unknown_key(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.post(app.url_path_for("revoke_api_key", api_key_id=str(uuid.uuid4()))) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +COMPUTE_STATISTICS = { + "memory_total": 8000, + "memory_free": 4000, + "memory_used": 4000, + "swap_total": 1000, + "swap_free": 900, + "swap_used": 100, + "cpu_usage_percent": 12, + "cpu_count": 4, + "cpu_count_physical": None, + "cpu_model": "Test CPU", + "memory_usage_percent": 50, + "swap_usage_percent": 10, + "disk_usage_percent": 30, + "disk_total": 100000, + "disk_used": 30000, + "disk_free": 70000, + "load_average": [0.5, 0.4, 0.3], + "load_average_percent": [12.5, 10.0, 7.5], +} + + +class TestStatistics: + async def test_statistics_shape(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + webwireshark = { + "total_containers": 1, + "running_containers": 1, + "active_sessions": 0, + "containers": [ + { + "project_id": project.id, + "project_name": project.name, + "container_id": "abcdef123456", + "status": "running", + "running": True, + "active_sessions": 0, + "memory_limit": "unlimited", + "cpu_limit": "unlimited", + "pids_limit": "unlimited", + "memory": "10MiB / 1GiB", + "cpu": "0.5%", + "pids": 3, + }, + { + "project_id": project.id, + "project_name": project.name, + "container_id": "123456abcdef", + "status": "exited", + "running": False, + }, + ], + } + with patch( + "gns3server.api.routes.controller.controller.collect_webwireshark_stats", + new=AsyncMock(return_value=webwireshark), + ): + response = await client.get(app.url_path_for("statistics")) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert set(body) == {"uptime_seconds", "computes", "projects", "nodes", "links", "webwireshark"} + assert isinstance(body["uptime_seconds"], int) + assert body["projects"] == {"total": 1, "opened": 1, "closed": 0} + assert body["nodes"] == { + "total": 0, + "open_project_nodes": 0, + "closed_project_nodes": 0, + "by_type": {}, + "by_status": {}, + } + assert body["links"] == {"total": 0, "capturing": 0} + assert body["webwireshark"] == webwireshark + + async def test_statistics_compute_entry( + self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project + ) -> None: + + compute = MagicMock() + compute.id = "local" + compute.name = "local" + reply = MagicMock() + reply.json = dict(COMPUTE_STATISTICS) + compute.get = AsyncMock(return_value=reply) + controller._computes = {"local": compute} + + response = await client.get(app.url_path_for("statistics")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["computes"] == [ + {"compute_id": "local", "compute_name": "local", "statistics": COMPUTE_STATISTICS} + ] + + async def test_statistics_compute_missing_fields_stay_absent( + self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project + ) -> None: + + compute = MagicMock() + compute.id = "old" + compute.name = "old" + reply = MagicMock() + reply.json = {"memory_total": 8000, "cpu_count": 2} + compute.get = AsyncMock(return_value=reply) + controller._computes = {"old": compute} + + response = await client.get(app.url_path_for("statistics")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["computes"][0]["statistics"] == {"memory_total": 8000, "cpu_count": 2} + + async def test_project_stats(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + link._markers["a"] = {"bpf": "icmp"} + link._markers["b"] = {"bpf": "arp"} + project._links = {link.id: link} + + response = await client.get(app.url_path_for("get_project_stats", project_id=project.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"nodes": 0, "links": 1, "drawings": 0, "snapshots": 0, "markers": 2} + + +class TestSymbols: + async def test_list_shape(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.get(app.url_path_for("get_symbols")) + assert response.status_code == status.HTTP_200_OK + symbols = response.json() + assert symbols + for symbol in symbols: + assert set(symbol) == {"symbol_id", "filename", "theme", "builtin"} + assert isinstance(symbol["builtin"], bool) + + async def test_default_symbols(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.get(app.url_path_for("get_default_symbols")) + assert response.status_code == status.HTTP_200_OK + themes = response.json() + assert themes["Classic"]["firewall"] == ":/symbols/classic/firewall.svg" + assert themes == Controller.instance().symbols.default_symbols() + + async def test_dimensions(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.get( + app.url_path_for("get_symbol_dimensions", symbol_id=":/symbols/classic/firewall.svg") + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert set(body) == {"width", "height"} + assert isinstance(body["width"], int) + assert isinstance(body["height"], int) + + +class TestLinkRoutes: + async def test_wireshark_restart(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + with patch("gns3server.controller.link.Link._restart_web_wireshark", new=AsyncMock()): + response = await client.post(app.url_path_for("restart_wireshark", project_id=project.id, link_id=link.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"status": "restarted"} + + async def test_available_filters_keep_optional_parameter_keys_absent( + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: + + link = Link(project) + project._links = {link.id: link} + with patch.object(Link, "_get_filter_node", return_value=object()): + response = await client.get(app.url_path_for("get_filters", project_id=project.id, link_id=link.id)) + assert response.status_code == status.HTTP_200_OK + by_type = {f["type"]: f for f in response.json()} + assert by_type["bpf"]["parameters"] == [{"name": "Filters", "type": "text"}] + assert by_type["delay"]["parameters"][0] == { + "name": "Latency", + "minimum": 1, + "maximum": 32767, + "unit": "ms", + "type": "int", + } + + async def test_link_markers_keep_missing_keys_absent( + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: + + link = UDPLink(project) + link._markers["legacy"] = {"bpf": "icmp", "tag": 1, "enabled": True} + link._markers["global-arp"] = {"bpf": "arp", "inherited_from": "arp", "capture_node_id": "n1"} + project._links = {link.id: link} + + response = await client.get(app.url_path_for("get_markers", project_id=project.id, link_id=link.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == link._markers + + +class TestMarkerAggregation: + async def test_project_markers_and_definitions(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + link._markers["web"] = { + "bpf": "tcp port 80", + "tag": 4, + "enabled": False, + "color": "#ff5722", + "highlight_duration": 800, + "capture_node_id": "n1", + "direction": "tx", + "data_link_type": "DLT_EN10MB", + } + link._markers["global-arp"] = { + "bpf": "arp", + "tag": None, + "enabled": True, + "color": None, + "highlight_duration": None, + "capture_node_id": "n1", + "direction": None, + "data_link_type": "DLT_EN10MB", + "inherited_from": "arp", + } + project._links = {link.id: link} + project._marker_definitions = { + "arp": { + "bpf": "arp", + "tag": None, + "direction": None, + "color": None, + "highlight_duration": None, + "data_link_type": "DLT_EN10MB", + "paused": False, + }, + "old": {"bpf": "icmp", "tag": 5}, + } + + response = await client.get(app.url_path_for("get_project_markers", project_id=project.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + f"{link.id}/web": {**link._markers["web"], "link_id": link.id, "node_id": "n1"}, + f"{link.id}/global-arp": {**link._markers["global-arp"], "link_id": link.id, "node_id": "n1"}, + } + + response = await client.get(app.url_path_for("get_marker_definitions", project_id=project.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "arp": {**project._marker_definitions["arp"], "link_ids": [link.id]}, + "old": {"bpf": "icmp", "tag": 5, "link_ids": []}, + } + + +class TestReplayFrameDetail: + async def test_frame_detail_passes_unknown_tree_keys_through( + self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch + ) -> None: + + detail = { + "ts": "1693472000.000000", + "source": {"node_id": "n1", "link_id": "l1", "marker": "icmp", "frame_number": 1}, + "field_count": 3, + "hex": "0200", + "tree": [ + { + "element": "proto", + "label": "Internet Protocol", + "name": "ip", + "filter_expr": "ip", + "pos": 14, + "size": 20, + "children": [{"label": "TTL: 64", "name": "ip.ttl", "expert": "Chat", "generated": True}], + "future_key": {"a": 1}, + } + ], + } + monkeypatch.setattr(marker_replay, "decode_frame", AsyncMock(return_value=detail)) + + response = await client.get( + app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), + params={"ts": "1693472000.000000", "node_id": "n1", "link_id": "l1", "marker": "icmp"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == detail + + async def test_range_empty_timeline(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + link._markers["icmp"] = { + "bpf": "icmp", + "tag": 9, + "enabled": False, + "color": None, + "highlight_duration": None, + "capture_node_id": "n1", + "direction": None, + "data_link_type": "DLT_EN10MB", + } + project._links = {link.id: link} + _write_pcap(f"{project.markers_directory}/n1_{link.id}_icmp.pcap", []) + + response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=9)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "tag": 9, + "start": None, + "end": None, + "frame_count": 0, + "sources": [ + {"node_id": "n1", "link_id": link.id, "marker": "icmp", "data_link_type": "DLT_EN10MB", "count": 0} + ], + "frames": [], + } From 719d9cc66735bd029e8cc99df771c998c5369dd1 Mon Sep 17 00:00:00 2001 From: Mark Paronyan Date: Thu, 8 Oct 2026 20:25:20 +0300 Subject: [PATCH 2/5] refactor: ruff fixes --- gns3server/api/routes/controller/links.py | 3 +-- gns3server/schemas/__init__.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 0023c1486..c3773fe36 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -20,8 +20,7 @@ API routes for links. import logging import os -from typing import Any, Dict, List, Union -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from uuid import UUID, uuid4 import aiohttp diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 55e3917f8..b43ecf4f2 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -49,7 +49,6 @@ from .controller.links import ( LinkCreate, LinkFilterInfo, LinkFilterParameter, - LinkFilterParameter, LinkFilters, LinkFilterType, LinkIfaceInfo, @@ -302,11 +301,10 @@ __all__ = [ "LinkCreate", "LinkFilterInfo", "LinkFilterParameter", - "LinkStatistics", - "LinkFilterParameter", "LinkFilterType", "LinkFilters", "LinkIfaceInfo", + "LinkStatistics", "LinkUpdate", "LinkWiresharkRestart", "LoggedInUserUpdate", From 8fee1d4c364b1cbbf1f67ca51ba60b8b5e3f7899 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 19:31:25 +0200 Subject: [PATCH 3/5] fix: "LinkFilterParameter" already defined --- gns3server/schemas/controller/links.py | 34 +++++++++----------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 1c6791297..e84e5d5b0 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -140,6 +140,17 @@ class LinkFilterParameter(BaseModel): unit: Optional[str] = None +class LinkFilterInfo(BaseModel): + """ + A packet filter that can be applied to a link. + """ + + type: str = Field(..., description="Filter identifier used as key in the link 'filters' property") + name: str + description: str + parameters: List[LinkFilterParameter] + + class LinkFilterDefinition(BaseModel): """ Packet filter available on a link. @@ -432,26 +443,3 @@ class LinkWiresharkRestart(BaseModel): """ status: str - - -class LinkFilterParameter(BaseModel): - """ - A parameter of a packet filter. - """ - - name: str - type: str - minimum: Optional[int] = None - maximum: Optional[int] = None - unit: Optional[str] = None - - -class LinkFilterInfo(BaseModel): - """ - A packet filter that can be applied to a link. - """ - - type: str = Field(..., description="Filter identifier used as key in the link 'filters' property") - name: str - description: str - parameters: List[LinkFilterParameter] From 868973bb7c5baad264cc808a44a1130516028585 Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 9 Oct 2026 13:22:41 +0200 Subject: [PATCH 4/5] Revert "fix: "LinkFilterParameter" already defined" This reverts commit 8fee1d4c364b1cbbf1f67ca51ba60b8b5e3f7899. --- gns3server/schemas/controller/links.py | 34 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index e84e5d5b0..1c6791297 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -140,17 +140,6 @@ class LinkFilterParameter(BaseModel): unit: Optional[str] = None -class LinkFilterInfo(BaseModel): - """ - A packet filter that can be applied to a link. - """ - - type: str = Field(..., description="Filter identifier used as key in the link 'filters' property") - name: str - description: str - parameters: List[LinkFilterParameter] - - class LinkFilterDefinition(BaseModel): """ Packet filter available on a link. @@ -443,3 +432,26 @@ class LinkWiresharkRestart(BaseModel): """ status: str + + +class LinkFilterParameter(BaseModel): + """ + A parameter of a packet filter. + """ + + name: str + type: str + minimum: Optional[int] = None + maximum: Optional[int] = None + unit: Optional[str] = None + + +class LinkFilterInfo(BaseModel): + """ + A packet filter that can be applied to a link. + """ + + type: str = Field(..., description="Filter identifier used as key in the link 'filters' property") + name: str + description: str + parameters: List[LinkFilterParameter] From 4fc601e919f442aef3dc22959d6402f87a25bd33 Mon Sep 17 00:00:00 2001 From: grossmj Date: Sat, 10 Oct 2026 06:40:52 +0200 Subject: [PATCH 5/5] refactor: remove unused link filter schemas and fix test_add_v8_version_appliance --- gns3server/schemas/__init__.py | 2 +- gns3server/schemas/controller/links.py | 23 ------------------- .../api/routes/controller/test_appliances.py | 2 +- tests/api/routes/controller/test_links.py | 4 ++-- 4 files changed, 4 insertions(+), 27 deletions(-) diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 2aa6bb38b..74ddfe4af 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -213,8 +213,8 @@ __all__ = [ "ATMSwitch", "ATMSwitchCreate", "ATMSwitchUpdate", - "ApiKey", "AnchorNIO", + "ApiKey", "ApiKeyCreate", "ApiKeyCreated", "ApiKeyMessage", diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index c6b641c63..e1ac4993c 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -139,29 +139,6 @@ class LinkFilters(BaseModel): ) -class LinkFilterParameter(BaseModel): - """ - Parameter of a packet filter. - """ - - name: str - type: Literal["int", "text", "str"] - minimum: Optional[int] = None - maximum: Optional[int] = None - unit: Optional[str] = None - - -class LinkFilterDefinition(BaseModel): - """ - Packet filter available on a link. - """ - - type: LinkFilterType - name: str - description: str - parameters: List[LinkFilterParameter] - - class LinkBase(BaseModel): """ Link data. diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py index 193557e35..e2b853f5f 100644 --- a/tests/api/routes/controller/test_appliances.py +++ b/tests/api/routes/controller/test_appliances.py @@ -105,7 +105,7 @@ class TestApplianceRoutes: async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None: appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance - new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}} + new_version = {"name": "v8-99G", "images": {"hda_disk_image": "empty99G.qcow2"}} response = await client.post( app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version ) diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py index 54830923b..14e5b9699 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -648,12 +648,12 @@ class TestLinkRoutes: assert "LinkFilters" not in schemas filters_property = schemas["Link"]["properties"]["filters"] assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"} - assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == { + assert schemas["LinkFilterInfo"]["properties"]["parameters"]["items"] == { "$ref": "#/components/schemas/LinkFilterParameter" } path = "/v3/projects/{project_id}/links/{link_id}/available_filters" responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] - assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"} + assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterInfo"} async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: """