diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 1de8ae9..22a455a 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -111,7 +111,7 @@ jobs: --env ENTITIES_SERVICE_X509_CERTIFICATE_FILE \ --env ENTITIES_SERVICE_CA_FILE \ --env PORT=${ENTITIES_SERVICE_PORT} \ - --env RUN_TIME=40 \ + --env RUN_TIME=60 \ --env STOP_TIME=3 \ --name "entities-service" \ --network "host" \ @@ -124,7 +124,7 @@ jobs: - name: Run tests run: | { - pytest -vv --live-backend --cov-report= + pytest -vv --live-backend --cov-report= --color=yes } || { echo "Failed! Here's the Docker logs for the service:" && docker logs entities-service && @@ -189,7 +189,7 @@ jobs: pip install -U -e .[testing] - name: Run pytest - run: pytest -vv --cov-report=xml + run: pytest -vv --cov-report=xml --color=yes - name: Upload coverage if: github.repository_owner == 'SINTEF' diff --git a/docker-compose.yml b/docker-compose.yml index b65f439..4ff1812 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3" +name: "entities-service" services: entities_service: diff --git a/entities_service/cli/commands/__init__.py b/entities_service/cli/commands/__init__.py index 3f9a5b7..5db9efc 100644 --- a/entities_service/cli/commands/__init__.py +++ b/entities_service/cli/commands/__init__.py @@ -15,11 +15,20 @@ from typer import Typer -SUB_TYPER_APPS = ("config",) +SUB_TYPER_APPS = ("config", "list") +NO_ARGS_IS_HELP_COMMANDS = ("upload", "validate") +ALIASED_COMMANDS: dict[str, str] = {} def get_commands() -> Generator[tuple[Callable, dict[str, Any]], None, None]: - """Return all CLI commands, along with typer.command() kwargs.""" + """Return all CLI commands, along with typer.command() kwargs. + + It is important the command module name matches the command function name. + + To have a command with an alias, add the alias to the ALIASED_COMMANDS dict. + To have a command that does not require arguments to show the help message, add + the command name to the NO_ARGS_IS_HELP_COMMANDS tuple. + """ this_dir = Path(__file__).parent.resolve() for path in this_dir.glob("*.py"): @@ -37,16 +46,21 @@ def get_commands() -> Generator[tuple[Callable, dict[str, Any]], None, None]: "name." ) - command_kwargs = {} - if path.stem in ("upload", "validate"): + command_kwargs: dict[str, Any] = {} + if path.stem in NO_ARGS_IS_HELP_COMMANDS: command_kwargs["no_args_is_help"] = True + if path.stem in ALIASED_COMMANDS: + command_kwargs["name"] = ALIASED_COMMANDS[path.stem] yield getattr(module, path.stem), command_kwargs def get_subtyper_apps() -> Generator[tuple[Typer, dict[str, Any]], None, None]: """Return all CLI Typer apps, which are a group of sub-command groups, along with - typer.add_typer() kwargs.""" + typer.add_typer() kwargs. + + This is done according to the SUB_TYPER_APPS tuple. + """ this_dir = Path(__file__).parent.resolve() for path in this_dir.glob("*.py"): diff --git a/entities_service/cli/commands/list.py b/entities_service/cli/commands/list.py new file mode 100644 index 0000000..7e95b82 --- /dev/null +++ b/entities_service/cli/commands/list.py @@ -0,0 +1,340 @@ +"""entities-service list command.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Annotated + +try: + import httpx + import typer + from rich import box + from rich.table import Table +except ImportError as exc: # pragma: no cover + from entities_service.cli._utils.generics import EXC_MSG_INSTALL_PACKAGE + + raise ImportError(EXC_MSG_INSTALL_PACKAGE) from exc + +from pydantic import ValidationError +from pydantic.networks import AnyUrl + +from entities_service.cli._utils.generics import ( + ERROR_CONSOLE, + get_namespace_name_version, + print, +) +from entities_service.cli._utils.types import OptionalListStr +from entities_service.models import GENERIC_NAMESPACE_URI_REGEX +from entities_service.service.config import CONFIG + +if TYPE_CHECKING: # pragma: no cover + from typing import Any + + +APP = typer.Typer( + name=__file__.rsplit("/", 1)[-1].replace(".py", ""), + help="List resources.", + no_args_is_help=True, + invoke_without_command=True, + rich_markup_mode="rich", +) + + +@APP.command() +def namespaces( + # Hidden options - used only when calling the function directly + return_info: Annotated[ + bool, + typer.Option( + hidden=True, + help=( + "Avoid printing the namespaces and instead return them as a Python " + "list. Useful when calling this function from another function." + ), + ), + ] = False, +) -> list[str] | None: + """List namespaces from the entities service.""" + with httpx.Client(base_url=str(CONFIG.base_url)) as client: + try: + response = client.get("/_api/namespaces") + except httpx.HTTPError as exc: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: Could not list namespaces. HTTP " + f"exception: {exc}" + ) + raise typer.Exit(1) from exc + + # Decode response + try: + namespaces: dict[str, Any] | list[str] = response.json() + except json.JSONDecodeError as exc: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Could not list namespaces. JSON decode " + f"error: {exc}" + ) + raise typer.Exit(1) from exc + + # Unsuccessful response (!= 200 OK) + if not response.is_success: + # First, it may be that there are no namespaces + if ( + response.status_code == 500 + and isinstance(namespaces, dict) + and namespaces.get("detail") == "No namespaces found in the backend." + ): + print( + "[bold yellow]Warning[/bold yellow]: No namespaces found. There are no " + f"entities hosted. Ensure {CONFIG.base_url} is the desired service to " + "target." + ) + namespaces = [] + + # Or it may be an error + else: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Could not list namespaces. HTTP status " + f"code: {response.status_code}. Error response: " + ) + ERROR_CONSOLE.print_json(data=namespaces) + raise typer.Exit(1) + + # Bad response format + if not isinstance(namespaces, list): + # Expect a list of namespaces + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Could not list namespaces. Invalid response: " + f"{namespaces}" + ) + raise typer.Exit(1) + + if return_info: + return namespaces + + if not namespaces: + raise typer.Exit() + + # Print namespaces + table = Table( + box=box.HORIZONTALS, + show_edge=False, + highlight=True, + ) + + table.add_column("Namespaces:", no_wrap=True) + + for namespace in sorted(namespaces): + table.add_row(namespace) + + print("", table, "") + + return None + + +@APP.command() +def entities( + namespace: Annotated[ + OptionalListStr, + typer.Argument( + help=( + "Namespace(s) to list entities from. Defaults to the core namespace. " + "If the namespace is a URL, the specific namespace will be extracted." + ), + show_default=False, + ), + ] = None, + all_namespaces: Annotated[ + bool, + typer.Option( + "--all", + "-a", + help="List entities from all namespaces.", + ), + ] = False, +) -> None: + """List entities from the entities service.""" + valid_namespaces: list[str] = namespaces(return_info=True) + + if all_namespaces: + namespace = valid_namespaces + + if namespace is None: + namespace = [str(CONFIG.base_url).rstrip("/")] if valid_namespaces else [] + + try: + target_namespaces = [_parse_namespace(ns) for ns in namespace] + except ValueError as exc: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Cannot parse one or more namespaces. " + f"Error message: {exc}" + ) + raise typer.Exit(1) from exc + + if not all(ns in valid_namespaces for ns in target_namespaces): + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: Invalid namespace(s) given: " + f"{[ns for ns in target_namespaces if ns not in valid_namespaces]}" + f"\nValid namespaces: {sorted(valid_namespaces)}" + ) + raise typer.Exit(1) + + # Get all specific namespaces from target namespaces (including "core", if present) + # `specific_namespaces` will consist of specific namespaces (str) + # and/or the "core" namespace (None) + specific_namespaces = [_get_specific_namespace(ns) for ns in target_namespaces] + + with httpx.Client(base_url=str(CONFIG.base_url)) as client: + try: + response = client.get( + "/_api/entities", + params={ + "namespace": [ + ns if ns is not None else "" for ns in specific_namespaces + ] + }, + ) + except httpx.HTTPError as exc: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Could not list entities. HTTP exception: " + f"{exc}" + ) + raise typer.Exit(1) from exc + + # Decode response + try: + entities: dict[str, Any] | list[dict[str, Any]] = response.json() + except json.JSONDecodeError as exc: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Could not list entities. JSON decode " + f"error: {exc}" + ) + raise typer.Exit(1) from exc + + # Unsuccessful response (!= 200 OK) + if not response.is_success: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Could not list entities. HTTP status code: " + f"{response.status_code}. Error response: " + ) + ERROR_CONSOLE.print_json(data=entities) + raise typer.Exit(1) + + # Bad response format + if not isinstance(entities, list): + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Could not list entities. Invalid response: " + f"{entities}" + ) + raise typer.Exit(1) + + if not entities: + print(f"No entities found in namespace(s) {namespace}") + raise typer.Exit() + + # Print entities + table = Table( + box=box.HORIZONTALS, + show_edge=False, + highlight=True, + ) + + # Sort the entities in the following order: + # 1. Namespace (only relevant if multiple namespaces are given) + # 2. Name + # 3. Version (reversed) + + if len(target_namespaces) > 1: + table.add_column("Namespace", no_wrap=False) + table.add_column("Name", no_wrap=True) + table.add_column("Version", no_wrap=True) + + last_namespace, last_name = "", "" + for entity in sorted( + entities, key=lambda entity: get_namespace_name_version(entity) + ): + entity_namespace, entity_name, entity_version = get_namespace_name_version( + entity + ) + + if entity_namespace != last_namespace: + # Add line in table + table.add_section() + + if len(target_namespaces) > 1: + # Include namespace + table.add_row( + entity_namespace if entity_namespace != last_namespace else "", + ( + entity_name + if entity_name != last_name or entity_namespace != last_namespace + else "" + ), + entity_version, + ) + else: + table.add_row( + entity_name if entity_name != last_name else "", + entity_version, + ) + + last_namespace, last_name = entity_namespace, entity_name + + core_namespace = str(CONFIG.base_url).rstrip("/") + single_namespace = "" + if len(target_namespaces) == 1 and entity_namespace != core_namespace: + single_namespace = f"Specific namespace: {core_namespace}/{entity_namespace}\n" + + print(f"\nBase namespace: {core_namespace}\n{single_namespace}", table, "") + + +def _parse_namespace(namespace: str | None, allow_external: bool = True) -> str: + """Parse a (specific) namespace, returning a full namespace.""" + # If a full URI (including version and name) is passed, + # extract and return the namespace + if ( + namespace is not None + and (match := GENERIC_NAMESPACE_URI_REGEX.match(namespace)) is not None + ): + return match.group("namespace") + + core_namespace = str(CONFIG.base_url).rstrip("/") + + if namespace is None or ( + isinstance(namespace, str) and namespace.strip() in ("/", "") + ): + return core_namespace + + if namespace.startswith(core_namespace): + return namespace.rstrip("/") + + try: + AnyUrl(namespace) + except (ValueError, TypeError, ValidationError): + # Expect the namespace to be a specific namespace + return f"{core_namespace}/{namespace.lstrip('/')}" + + # The namespace is a URL, but not within the core namespace + if allow_external: + return namespace.rstrip("/") + + raise ValueError( + f"{namespace} is not within the core namespace {core_namespace} and external " + "namespaces are not allowed (set 'allow_external=True')." + ) + + +def _get_specific_namespace(namespace: str) -> str | None: + """Retrieve the specific namespace (if any) from a full namespace. + + Note, if the namespace is a fully qualified URL it is expected to already be within + the core namespace as given by the `base_url` configuration setting. + + If the namespace is the core namespace, return `None`. + """ + if namespace.startswith(str(CONFIG.base_url).rstrip("/")): + namespace = namespace[len(str(CONFIG.base_url).rstrip("/")) :] + + if namespace.strip() in ("/", ""): + return None + + return namespace.strip("/") diff --git a/entities_service/cli/main.py b/entities_service/cli/main.py index 359e81d..6963a63 100644 --- a/entities_service/cli/main.py +++ b/entities_service/cli/main.py @@ -25,6 +25,7 @@ for typer_app, typer_app_kwargs in get_subtyper_apps(): APP.add_typer(typer_app, **typer_app_kwargs) + # Add all "leaf"-commands for command, commands_kwargs in get_commands(): APP.command(**commands_kwargs)(command) diff --git a/entities_service/models/__init__.py b/entities_service/models/__init__.py index 1dae863..1aef1ce 100644 --- a/entities_service/models/__init__.py +++ b/entities_service/models/__init__.py @@ -9,9 +9,11 @@ from .dlite_soft5 import DLiteSOFT5Entity from .dlite_soft7 import DLiteSOFT7Entity from .soft import ( + GENERIC_NAMESPACE_URI_REGEX, NO_GROUPS_SEMVER_REGEX, SEMVER_REGEX, URI_REGEX, + EntityNamespaceType, EntityNameType, EntityVersionType, ) @@ -26,16 +28,18 @@ __all__ = ( "Entity", + "EntityNamespaceType", + "EntityNameType", "EntityType", - "soft_entity", + "EntityVersionType", + "GENERIC_NAMESPACE_URI_REGEX", + "get_updated_version", "get_uri", "get_version", - "get_updated_version", - "URI_REGEX", - "SEMVER_REGEX", "NO_GROUPS_SEMVER_REGEX", - "EntityNameType", - "EntityVersionType", + "SEMVER_REGEX", + "soft_entity", + "URI_REGEX", ) diff --git a/entities_service/models/soft.py b/entities_service/models/soft.py index 13f91dc..0ff9d15 100644 --- a/entities_service/models/soft.py +++ b/entities_service/models/soft.py @@ -52,7 +52,17 @@ rf"^(?P{re.escape(str(CONFIG.base_url).rstrip('/'))}(?:/(?P.+))?)" rf"/(?P{NO_GROUPS_SEMVER_REGEX})/(?P[^/#?]+)$" ) -"""Regular expression to parse a SOFT entity URI.""" +"""Regular expression to parse a SOFT entity URI as URL.""" + +GENERIC_NAMESPACE_URI_REGEX = re.compile( + r"^(?Phttps?://[^/]+(?::[0-9]+)?(?:/.+)?)" + rf"/(?P{NO_GROUPS_SEMVER_REGEX})/(?P[^/#?]+)$" +) +"""Regular expression to parse a generic namespace SOFT entity URI as URL. + +It is not possible to derive the specific namespace from this URI. +The whole namespace may be considered the specific namespace. +""" def _disallowed_namespace_characters(value: str) -> str: diff --git a/entities_service/service/backend/__init__.py b/entities_service/service/backend/__init__.py index 65edfa3..9d7e41e 100644 --- a/entities_service/service/backend/__init__.py +++ b/entities_service/service/backend/__init__.py @@ -120,3 +120,12 @@ def get_backend( backend_settings.update(settings) return backend_class(settings=backend_settings) + + +def get_dbs(backend: Backend | Backends | str | None = None) -> list[str]: + """Get the dbs (namespaces) from a given backend.""" + if isinstance(backend, (Backends, str)) or backend is None: + return get_backend(backend).get_dbs() + + # Expect backend to be a backend instance + return backend.get_dbs() diff --git a/entities_service/service/backend/backend.py b/entities_service/service/backend/backend.py index 5cc361c..61b2634 100644 --- a/entities_service/service/backend/backend.py +++ b/entities_service/service/backend/backend.py @@ -62,6 +62,8 @@ def __init__( self._settings = settings or self._settings_model() self._is_closed: bool = False + self._initialize() + # Exceptions @property @abstractmethod @@ -80,6 +82,12 @@ def __del__(self) -> None: if not self._is_closed: self.close() + def __iter__(self) -> Iterator[dict[str, Any]]: + return iter(self.search()) + + def __len__(self) -> int: + return self.count() + # Container protocol methods def __contains__(self, item: Any) -> bool: if isinstance(item, dict): @@ -104,17 +112,9 @@ def __contains__(self, item: Any) -> bool: return False - @abstractmethod - def __iter__(self) -> Iterator[dict[str, Any]]: # pragma: no cover - raise NotImplementedError - - @abstractmethod - def __len__(self) -> int: # pragma: no cover - raise NotImplementedError - # Backend methods (initialization) @abstractmethod - def initialize(self) -> None: # pragma: no cover + def _initialize(self) -> None: # pragma: no cover """Initialize the backend.""" raise NotImplementedError @@ -149,7 +149,7 @@ def delete(self, entity_identity: AnyHttpUrl | str) -> None: # pragma: no cover # Backend methods (search) @abstractmethod - def search(self, query: Any) -> Iterator[dict[str, Any]]: # pragma: no cover + def search(self, query: Any = None) -> Iterator[dict[str, Any]]: # pragma: no cover """Search for entities.""" raise NotImplementedError @@ -170,3 +170,12 @@ def close(self) -> None: raise BackendError("Backend is already closed") self._is_closed = True + + # Backend methods (other) + @abstractmethod + def get_dbs(self) -> list[str]: # pragma: no cover + """Get the backend databases. + + This is related (but not necessarily equivalent) to the specific namespaces. + """ + raise NotImplementedError diff --git a/entities_service/service/backend/mongodb.py b/entities_service/service/backend/mongodb.py index 0d8d3bd..219c96d 100644 --- a/entities_service/service/backend/mongodb.py +++ b/entities_service/service/backend/mongodb.py @@ -267,12 +267,18 @@ class MongoDBBackend(Backend): _settings_model: type[MongoDBSettings] = MongoDBSettings _settings: MongoDBSettings - def __init__( - self, - settings: MongoDBSettings | dict[str, Any] | None = None, - ) -> None: - super().__init__(settings) + # Exceptions + @property + def write_access_exception(self) -> tuple: + return MongoDBBackendWriteAccessError + # Standard magic methods + def __str__(self) -> str: + return f"{self.__class__.__name__}: uri={self._settings.mongo_uri}" + + # Backend methods (initialization) + def _initialize(self) -> None: + """Initialize the MongoDB backend.""" # Set up the MongoDB collection try: self._collection = get_client( @@ -291,26 +297,12 @@ def __init__( except ValueError as exc: raise MongoDBBackendError(str(exc)) from exc - self.initialize() - - def __str__(self) -> str: - return f"{self.__class__.__name__}: uri={self._settings.mongo_uri}" - - # Exceptions - @property - def write_access_exception(self) -> tuple: - return MongoDBBackendWriteAccessError - - def __iter__(self) -> Iterator[dict[str, Any]]: - return iter(self._collection.find({}, projection={"_id": False})) - - def __len__(self) -> int: - return self._collection.count_documents({}) - - def initialize(self) -> None: - """Initialize the MongoDB backend.""" - if self._settings.auth_level == "read": - # Not enough rights to create an index + # Create a unique DB index for the URI + if ( + self._settings.auth_level == "read" + or self._settings.mongo_driver == "mongomock" + ): + # Not enough rights to create an index or using mongomock return # Check index exists @@ -337,6 +329,7 @@ def initialize(self) -> None: ["uri", "namespace", "version", "name"], unique=True, name="URI" ) + # Backend methods (CRUD) def create( self, entities: Sequence[Entity | dict[str, Any]] ) -> list[dict[str, Any]] | dict[str, Any] | None: @@ -377,7 +370,8 @@ def delete(self, entity_identity: AnyHttpUrl | str) -> None: filter = self._single_uri_query(str(entity_identity)) self._collection.delete_one(filter) - def search(self, query: Any) -> Iterator[dict[str, Any]]: + # Backend methods (search) + def search(self, query: Any = None) -> Iterator[dict[str, Any]]: """Search for entities.""" query = query or {} @@ -395,6 +389,7 @@ def count(self, query: Any = None) -> int: return self._collection.count_documents(query) + # Backend methods (close) def close(self) -> None: """We never close the MongoDB connection once its created.""" if self._settings.mongo_driver == "mongomock": @@ -402,6 +397,13 @@ def close(self) -> None: super().close() + # Backend methods (other) + def get_dbs(self) -> list[str]: + """Get the collections in the MongoDB.""" + return self._collection.database.list_collection_names( + filter={"name": {"$regex": r"^(?!system\.)"}} + ) + # MongoDBBackend specific methods def _single_uri_query(self, uri: str) -> dict[str, Any]: """Build a query for a single URI.""" diff --git a/entities_service/service/routers/admin.py b/entities_service/service/routers/admin.py index a75edbc..c8cac10 100644 --- a/entities_service/service/routers/admin.py +++ b/entities_service/service/routers/admin.py @@ -1,10 +1,8 @@ """The `_admin` router and endpoints. -This router is used for both more introspective service endpoints, such as inspecting -the current and all users, and for endpoints requiring administrative rights, such as -creating entities. +This router is used for creating entities. -The endpoints in this router are not documented in the OpenAPI schema. +Endpoints in this router are not documented in the OpenAPI schema. """ from __future__ import annotations @@ -30,6 +28,7 @@ ROUTER = APIRouter( prefix="/_admin", + tags=["Admin"], include_in_schema=CONFIG.debug, dependencies=[Depends(verify_token)], ) diff --git a/entities_service/service/routers/api.py b/entities_service/service/routers/api.py new file mode 100644 index 0000000..ee6e8d3 --- /dev/null +++ b/entities_service/service/routers/api.py @@ -0,0 +1,198 @@ +"""The `_api` router and endpoints. + +This router is used for more introspective service endpoints. +""" + +from __future__ import annotations + +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, HTTPException, Query +from pydantic import AnyHttpUrl, ValidationError + +from entities_service.models import URI_REGEX, Entity, EntityNamespaceType +from entities_service.service.backend import get_backend, get_dbs +from entities_service.service.config import CONFIG +from entities_service.service.utils import _get_entities + +LOGGER = logging.getLogger(__name__) + +ROUTER = APIRouter(prefix="/_api", tags=["API"]) + + +@ROUTER.get( + "/entities", + response_model=list[Entity], + response_model_by_alias=True, + response_model_exclude_unset=True, +) +async def list_entities( + namespaces: Annotated[ + list[str], + Query( + alias="namespace", + description=( + "A namespace wherein to list all entities. Can be supplied multiple " + "times - entities will be returned as an aggregated, flat list." + ), + ), + ] = [] # noqa: B006 +) -> list[dict[str, Any]]: + """List all entities in the given namespace(s).""" + # Format namespaces + parsed_namespaces: set[str | None] = set() + bad_namespaces: list[AnyHttpUrl | str] = [] + + LOGGER.debug("Namespaces: %r", namespaces) + + for namespace in namespaces: + # Validate namespace and retrieve the specific namespace (if any) + + is_url = True + try: + AnyHttpUrl(namespace) + except (ValueError, TypeError, ValidationError): + # Not a URL + is_url = False + + if is_url: + # Ensure the namespace is within the base URL domain + if not namespace.startswith(str(CONFIG.base_url).rstrip("/")): + LOGGER.error( + "Namespace %r does not start with the base URL %s.", + namespace, + CONFIG.base_url, + ) + bad_namespaces.append(namespace) + continue + + # Extract the specific namespace from the URL + + # Handle the case of the 'namespace' being a URI (as a URL) + if (match := URI_REGEX.match(str(namespace))) is not None: + LOGGER.debug("Namespace %r is a URI (as a URL).", namespace) + + # Replace the namespace with the specific namespace + # This will be `None` if the namespace is the "core" namespace + specific_namespace = match.group("specific_namespace") + + else: + LOGGER.debug("Namespace %r is a 'regular' full namespace.", namespace) + + specific_namespace = namespace[len(str(CONFIG.base_url).rstrip("/")) :] + if specific_namespace.strip() in ("", "/"): + specific_namespace = None + else: + specific_namespace = specific_namespace.lstrip("/") + + parsed_namespaces.add(specific_namespace) + + elif namespace.strip() in ("", "/"): + parsed_namespaces.add(None) + else: + # Add namespace as is + parsed_namespaces.add(namespace) + + if bad_namespaces: + # Raise an error if there are any bad namespaces + raise HTTPException( + status_code=400, + detail=( + f"Invalid namespace{'s' if len(bad_namespaces) > 1 else ''}: " + f"{', '.join(map(str, bad_namespaces))}." + ), + ) + + if not parsed_namespaces: + # Use the default namespace if none are given + parsed_namespaces.add(None) + + LOGGER.debug("Parsed namespaces: %r", parsed_namespaces) + + # Retrieve entities + entities = [] + for namespace in parsed_namespaces: + # Retrieve entities from the database + entities.extend(await _get_entities(namespace)) + + return entities + + +@ROUTER.get( + "/namespaces", + response_model=list[EntityNamespaceType], + response_model_by_alias=True, + response_model_exclude_unset=True, +) +async def list_namespaces() -> list[str]: + """List all entities' namespaces. + + This endpoint will return a list of all namespaces from existing entities in the + backend. + + Currently, a specific namespace is equivalent to a database in the backend. + And the "core" namespace is equivalent to the default database in the backend. + + Furthermore, when retrieving a backend object, a specific database is specified. + If the database is left unspecified, the default database is used. + This is equivalent to setting the requested database to `None`, which will use the + default database, which is named by the `mongo_collection` configuration setting. + + Note, this may be confusing, as a MongoDB Collection is _not_ a database, but rather + something more equivalent to a "table" in a relational database. + + However, currently only MongoDB is supported as a backend. In the future, other + backends may be supported, and the configuration setting may be updated to reflect + this. Everything else around it should remain the same. + + An entity is retrieved from each database, since the specific namespace may differ + from the database name. Note, this is always true for the "core" namespace, which + is equivalent to the default database. + + If no namespaces are found in the backend, a 500 error will be raised. + """ + namespaces: list[str] = [] + + for db in get_dbs(): + backend = get_backend(CONFIG.backend, auth_level="read", db=db) + + # Ignore empty backends + if not len(backend): + continue + + # Retrieve the first entity from the database + entity = next(iter(backend)) + + if "namespace" in entity: + namespaces.append(entity["namespace"]) + LOGGER.debug( + "Found namespace %r in the backend (through 'namespace').", + entity["namespace"], + ) + continue + + if ( + "uri" not in entity or (match := URI_REGEX.match(entity["uri"])) is None + ): # pragma: no cover + # This should never actually be reached, as all entities stored in the + # backend via the service, will have either a valid namespace or valid URI. + LOGGER.error("Entity %r does not have a valid URI.", entity) + raise HTTPException( + status_code=500, + detail=f"Entity {entity} does not have a valid URI.", + ) + + LOGGER.debug( + "Found namespace %r in the backend (through 'uri').", + match.group("namespace"), + ) + namespaces.append(match.group("namespace")) + + if not namespaces: + raise HTTPException( + status_code=500, + detail="No namespaces found in the backend.", + ) + + return namespaces diff --git a/entities_service/service/utils.py b/entities_service/service/utils.py index e679b09..ac9a5bd 100644 --- a/entities_service/service/utils.py +++ b/entities_service/service/utils.py @@ -40,7 +40,7 @@ async def _get_entity(version: str, name: str, db: str | None = None) -> dict[st uri += f"/{version}/{name}" - entity = get_backend(db=db).read(uri) + entity = get_backend(CONFIG.backend, auth_level="read", db=db).read(uri) if entity is None: raise ValueError(f"Could not find entity: uri={uri}") @@ -48,3 +48,13 @@ async def _get_entity(version: str, name: str, db: str | None = None) -> dict[st await _add_dimensions(entity) return entity + + +async def _get_entities(db: str | None) -> list[dict[str, Any]]: + """Utility function for the endpoints to retrieve all endpoints from the + namespace/db-specific backend.""" + entities = list(get_backend(CONFIG.backend, auth_level="read", db=db)) + + await _add_dimensions(entities) + + return entities diff --git a/tests/cli/commands/conftest.py b/tests/cli/commands/conftest.py index a373b89..381848e 100644 --- a/tests/cli/commands/conftest.py +++ b/tests/cli/commands/conftest.py @@ -27,6 +27,20 @@ def config_app() -> Typer: return APP +@pytest.fixture(scope="session") +def list_app() -> Typer: + """Return the list APP.""" + from entities_service.cli._utils.global_settings import global_options + from entities_service.cli.commands.list import APP + + # Add global options to the APP + # This is done by the "main" APP, and should hence be done here manually to ensure + # they can be used + APP.callback()(global_options) + + return APP + + @pytest.fixture() def dotenv_file(tmp_path: Path) -> Path: """Create a path to a dotenv file in a temporary test folder.""" diff --git a/tests/cli/commands/test_list_entities.py b/tests/cli/commands/test_list_entities.py new file mode 100644 index 0000000..94e8f7e --- /dev/null +++ b/tests/cli/commands/test_list_entities.py @@ -0,0 +1,667 @@ +"""Tests for `entities-service list entities` CLI commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from typing import Literal + + from pytest_httpx import HTTPXMock + from typer import Typer + from typer.testing import CliRunner + + from entities_service.service.backend.mongodb import MongoDBBackend + + from ...conftest import GetBackendUserFixture + +pytestmark = pytest.mark.usefixtures("_mock_config_base_url") + +CLI_RESULT_FAIL_MESSAGE = "STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}" + + +def test_list_entities( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + existing_specific_namespace: str, + httpx_mock: HTTPXMock, + get_backend_user: GetBackendUserFixture, +) -> None: + """Test `entities-service list entities` CLI command. + + If no arguments and options are provided, the command should list all entities in + the core namespace. + + Note, this will fail if ever a set of test entities are named similarly, + but versioned differently. + """ + from entities_service.models import URI_REGEX, soft_entity + from entities_service.service.backend import get_backend + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.base_url).rstrip("/") + specific_namespace = f"{core_namespace}/{existing_specific_namespace}" + + backend_user = get_backend_user(auth_role="read") + core_backend: MongoDBBackend = get_backend( + auth_level="write", + settings={ + "mongo_username": backend_user["username"], + "mongo_password": backend_user["password"], + }, + db=None, + ) + core_entities = list(core_backend) + + if not live_backend: + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace, specific_namespace], + ) + + # Mock response for listing entities + # Only return entities from the core namespace, no matter the + # "current namespace" + httpx_mock.add_response( + url=f"{core_namespace}/_api/entities?namespace=", + method="GET", + json=[ + soft_entity(**entity).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + for entity in core_entities + ], + ) + + # This will ensure the right namespace is used when testing with a live backend. + # This (sort of) goes against the test, as we're testing calling `entities` without + # any extra arguments or options... But this can (and will) be tested without a live + # backend. + command = ( + f"entities {CONFIG.model_fields['base_url'].default}" + if live_backend + else "entities" + ) + + result = cli.invoke(list_app, command) + + assert result.exit_code == 0, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + f"Base namespace: {core_namespace}" in result.stdout + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + assert "Namespace" not in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + for entity in core_entities: + name, version = None, None + + if entity.get("name") and entity.get("version"): + name, version = entity["name"], entity["version"] + else: + assert entity.get("uri") + match = URI_REGEX.match(entity["uri"]) + assert match is not None + name, version = match.group("name"), match.group("version") + + if name is None or version is None: + pytest.fail( + f"Name and version could not be extracted from an entity !\n{entity}" + ) + + assert f"{name}{version}" in result.stdout.replace( + " ", "" + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + +@pytest.mark.parametrize("namespace_format", ["full", "short"]) +def test_list_entities_namespace( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + existing_specific_namespace: str, + namespace: str | None, + httpx_mock: HTTPXMock, + get_backend_user: GetBackendUserFixture, + namespace_format: Literal["full", "short"], +) -> None: + """Test `entities-service list entities` CLI command. + + With the 'NAMESPACE' argument. + + Note, this will fail if ever a set of test entities are named similarly, + but versioned differently. + """ + if live_backend and namespace_format == "short": + pytest.skip("Cannot test short namespace format with a live backend.") + + from entities_service.models import URI_REGEX, soft_entity + from entities_service.service.backend import get_backend + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + specific_namespace = f"{core_namespace}/{existing_specific_namespace}" + + backend_user = get_backend_user(auth_role="read") + backend: MongoDBBackend = get_backend( + auth_level="write", + settings={ + "mongo_username": backend_user["username"], + "mongo_password": backend_user["password"], + }, + db=namespace, + ) + backend_entities = list(backend) + + if not live_backend: + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace, specific_namespace], + ) + + # Mock response for listing entities from the core namespace + httpx_mock.add_response( + url=( + f"{core_namespace}/_api/entities" + f"?namespace={namespace if namespace else ''}" + ), + method="GET", + json=[ + soft_entity(**entity).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + for entity in backend_entities + ], + ) + + if namespace_format == "full": + # Pass in the full namespace, e.g., `http://onto-ns.com/meta/test` + result = cli.invoke( + list_app, ("entities", specific_namespace if namespace else core_namespace) + ) + else: + # Pass in the short namespace, e.g., `test` or nothing for the core namespace + result = cli.invoke(list_app, ("entities", namespace if namespace else "")) + + assert result.exit_code == 0, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + f"Base namespace: {str(CONFIG.base_url).rstrip('/')}" in result.stdout + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + if namespace: + assert ( + f"Specific namespace: {str(CONFIG.base_url).rstrip('/')}/{namespace}" + in result.stdout + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + assert "Namespace" not in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + for entity in backend_entities: + name, version = None, None + + if entity.get("name") and entity.get("version"): + name, version = entity["name"], entity["version"] + else: + assert entity.get("uri") + match = URI_REGEX.match(entity["uri"]) + assert match is not None + name, version = match.group("name"), match.group("version") + + if name is None or version is None: + pytest.fail( + f"Name and version could not be extracted from an entity !\n{entity}" + ) + + assert f"{name}{version}" in result.stdout.replace( + " ", "" + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + +def test_list_entities_all_namespaces( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + existing_specific_namespace: str, + httpx_mock: HTTPXMock, + get_backend_user: GetBackendUserFixture, +) -> None: + """Test `entities-service list entities` CLI command. + + With the '--all/-a' option. + + Note, this will fail if ever a set of test entities are named similarly, + but versioned differently. + """ + from entities_service.models import URI_REGEX, soft_entity + from entities_service.service.backend import get_backend + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + specific_namespace = f"{core_namespace}/{existing_specific_namespace}" + + backend_user = get_backend_user(auth_role="read") + core_backend: MongoDBBackend = get_backend( + auth_level="write", + settings={ + "mongo_username": backend_user["username"], + "mongo_password": backend_user["password"], + }, + db=None, + ) + core_entities = list(core_backend) + specific_backend: MongoDBBackend = get_backend( + auth_level="write", + settings={ + "mongo_username": backend_user["username"], + "mongo_password": backend_user["password"], + }, + db=existing_specific_namespace, + ) + specific_entities = list(specific_backend) + + if not live_backend: + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace, specific_namespace], + ) + + # Mock response for listing entities from the core namespace + httpx_mock.add_response( + url=( + f"{core_namespace}/_api/entities" + f"?namespace=&namespace={existing_specific_namespace}" + ), + method="GET", + json=[ + soft_entity(**entity).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + for entity in [*core_entities, *specific_entities] + ], + ) + + result = cli.invoke(list_app, ("entities", "--all")) + + assert result.exit_code == 0, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + f"Base namespace: {str(CONFIG.base_url).rstrip('/')}" in result.stdout + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + # We have multiple namespaces, so this line should not appear + assert "Specific namespace:" not in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + # We have multiple namespaces, so this table header should now appear + assert "Namespace" in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + # Ensure number of times namespaces are listed (in short form) in the output + # is exactly 1. + check_namespaces = {"/": 0, existing_specific_namespace: 0} + + for entity in [*core_entities, *specific_entities]: + short_namespace, name, version = None, None, None + + if entity.get("namespace") and entity.get("name") and entity.get("version"): + namespace, name, version = ( + entity["namespace"], + entity["name"], + entity["version"], + ) + namespace = namespace[len(core_namespace) :] + short_namespace = "/" if namespace in ("/", "") else namespace.lstrip("/") + else: + assert entity.get("uri") + match = URI_REGEX.match(entity["uri"]) + assert match is not None + short_namespace, name, version = ( + match.group("specific_namespace"), + match.group("name"), + match.group("version"), + ) + + if short_namespace is None: + short_namespace = "/" + + if name is None or version is None: + pytest.fail( + f"Name and version could not be extracted from an entity !\n{entity}" + ) + + assert f"{name}{version}" in result.stdout.replace( + " ", "" + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + if f"{short_namespace}{name}{version}" in result.stdout.replace(" ", ""): + check_namespaces[short_namespace] += 1 + + assert list(check_namespaces.values()) == [1] * len( + check_namespaces + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + + +def test_unparseable_namespace( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test `entities-service list entities` CLI command. + + With a namespace that results in a raised ValueError when calling + `_parse_namespace()`. + """ + from entities_service.service.config import CONFIG + + bad_namespace = "bad_namespace" + error_message = "Invalid namespace" + + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + + if not live_backend: + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace], + ) + + # Monkeypatch the `_parse_namespace()` function to raise a ValueError + def _raise_valueerror(namespace, allow_external=True) -> str: # noqa: ARG001 + raise ValueError(error_message) + + monkeypatch.setattr( + "entities_service.cli.commands.list._parse_namespace", _raise_valueerror + ) + + result = cli.invoke(list_app, ("entities", bad_namespace)) + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + f"Error: Cannot parse one or more namespaces. Error message: {error_message}" + in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +def test_invalid_namespace( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, + existing_specific_namespace: str, +) -> None: + """Test `entities-service list entities` CLI command. + + With an invalid namespace. + """ + from entities_service.service.config import CONFIG + + non_existing_namespace = "non_existing_namespace" + + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + specific_namespace = f"{core_namespace}/{existing_specific_namespace}" + valid_namespaces = sorted([core_namespace, specific_namespace]) + + if not live_backend: + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=valid_namespaces, + ) + + result = cli.invoke(list_app, ("entities", non_existing_namespace)) + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Invalid namespace(s) given: " + f"{[str(CONFIG.base_url).rstrip('/') + '/' + non_existing_namespace]}" + f"Valid namespaces: {valid_namespaces}" in result.stderr.replace("\n", "") + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.skip_if_live_backend("Cannot mock HTTP error with live backend") +def test_http_errors( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure the proper error message is given if an HTTP error occurs.""" + from httpx import HTTPError + + from entities_service.service.config import CONFIG + + error_message = "Generic HTTP Error" + + core_namespace = str(CONFIG.base_url).rstrip("/") + + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace], + ) + + # Mock response for the list namespaces command + httpx_mock.add_exception( + HTTPError(error_message), + url=f"{core_namespace}/_api/entities?namespace=", + ) + + result = cli.invoke(list_app, "entities") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list entities. HTTP exception: " + f"{error_message}" in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.skip_if_live_backend("Cannot mock JSON decode error with live backend") +def test_json_decode_errors( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper error message is given if a JSONDecodeError occurs.""" + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.base_url).rstrip("/") + + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace], + ) + + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{core_namespace}/_api/entities?namespace=", + status_code=200, + content=b"not json", + ) + + result = cli.invoke(list_app, "entities") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list entities. JSON decode error: " in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.skip_if_live_backend("Cannot mock invalid response with live backend") +def test_unsuccessful_response( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper error message is given if the response is not successful.""" + from entities_service.service.config import CONFIG + + error_message = "Bad response" + status_code = 400 + + core_namespace = str(CONFIG.base_url).rstrip("/") + + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace], + ) + + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{core_namespace}/_api/entities?namespace=", + status_code=status_code, + json={"detail": error_message}, + ) + + result = cli.invoke(list_app, "entities") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list entities. HTTP status code: " + f"{status_code}. Error response: " in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert error_message in result.stderr, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.skip_if_live_backend( + "Cannot mock invalid response format with live backend" +) +def test_bad_response_format( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper error message is given if the response format is not as + expected.""" + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.base_url).rstrip("/") + + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace], + ) + + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{core_namespace}/_api/entities?namespace=", + status_code=200, + json={"bad": "response format"}, # should be a list of dicts + ) + + result = cli.invoke(list_app, "entities") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list entities. Invalid response: " in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.usefixtures("_empty_backend_collection") +def test_empty_list_response( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper message is given if the list entities response is empty.""" + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + + if not live_backend: + # Mock response for listing (valid) namespaces + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace], + ) + + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{core_namespace}/_api/entities?namespace=", + status_code=200, + json=[], + ) + + result = cli.invoke(list_app, "entities") + + assert result.exit_code == 0, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "No entities found in namespace(s) " in result.stdout + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stderr, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) diff --git a/tests/cli/commands/test_list_namespaces.py b/tests/cli/commands/test_list_namespaces.py new file mode 100644 index 0000000..3f1226b --- /dev/null +++ b/tests/cli/commands/test_list_namespaces.py @@ -0,0 +1,255 @@ +"""Tests for `entities-service list namespaces` CLI commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + + from pytest_httpx import HTTPXMock + from typer import Typer + from typer.testing import CliRunner + +pytestmark = pytest.mark.usefixtures("_mock_config_base_url") + +CLI_RESULT_FAIL_MESSAGE = "STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}" + + +def test_list_namespaces( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + existing_specific_namespace: str, + httpx_mock: HTTPXMock, +) -> None: + """Test `entities-service list namespaces` CLI command.""" + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + specific_namespace = f"{core_namespace}/{existing_specific_namespace}" + + if not live_backend: + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=[core_namespace, specific_namespace], + ) + + result = cli.invoke(list_app, "namespaces") + + assert result.exit_code == 0, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert "Namespaces:" in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + assert core_namespace in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + assert specific_namespace in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +def test_list_namespaces_return_info( + live_backend: bool, + existing_specific_namespace: str, + httpx_mock: HTTPXMock, + capsys: pytest.CaptureFixture, +) -> None: + """Test `entities-service list namespaces` CLI command called as a Python function + with `return_info=True`.""" + from entities_service.cli.commands.list import namespaces + from entities_service.service.config import CONFIG + + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + specific_namespace = f"{core_namespace}/{existing_specific_namespace}" + + namespaces_info = [core_namespace, specific_namespace] + + if not live_backend: + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{core_namespace}/_api/namespaces", + method="GET", + json=namespaces_info, + ) + + result = namespaces(return_info=True) + + assert set(result) == set(namespaces_info) + + # There should be no output in this "mode" + result = capsys.readouterr() + assert not result.out + assert not result.err + + +@pytest.mark.skip_if_live_backend("Cannot mock HTTP error with live backend") +def test_http_errors( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure the proper error message is given if an HTTP error occurs.""" + from httpx import HTTPError + + from entities_service.service.config import CONFIG + + error_message = "Generic HTTP Error" + + # Mock response for the list namespaces command + httpx_mock.add_exception( + HTTPError(error_message), + url=f"{str(CONFIG.base_url).rstrip('/')}/_api/namespaces", + ) + + result = cli.invoke(list_app, "namespaces") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list namespaces. HTTP exception: " + f"{error_message}" in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.skip_if_live_backend("Cannot mock JSON decode error with live backend") +def test_json_decode_errors( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper error message is given if a JSONDecodeError occurs.""" + from entities_service.service.config import CONFIG + + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{str(CONFIG.base_url).rstrip('/')}/_api/namespaces", + status_code=200, + content=b"not json", + ) + + result = cli.invoke(list_app, "namespaces") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list namespaces. JSON decode error: " in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.skip_if_live_backend("Cannot mock invalid response with live backend") +def test_unsuccessful_response( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper error message is given if the response is not successful.""" + from entities_service.service.config import CONFIG + + error_message = "Bad response" + status_code = 400 + + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{str(CONFIG.base_url).rstrip('/')}/_api/namespaces", + status_code=status_code, + json={"detail": error_message}, + ) + + result = cli.invoke(list_app, "namespaces") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list namespaces. HTTP status code: " + f"{status_code}. Error response: " in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert error_message in result.stderr, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.skip_if_live_backend( + "Cannot mock invalid response format with live backend" +) +def test_bad_response_format( + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper error message is given if the response format is not as + expected.""" + from entities_service.service.config import CONFIG + + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{str(CONFIG.base_url).rstrip('/')}/_api/namespaces", + status_code=200, + json={"bad": "response format"}, + ) + + result = cli.invoke(list_app, "namespaces") + + assert result.exit_code == 1, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert ( + "Error: Could not list namespaces. Invalid response: " in result.stderr + ), CLI_RESULT_FAIL_MESSAGE.format(stdout=result.stdout, stderr=result.stderr) + assert not result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + +@pytest.mark.usefixtures("_empty_backend_collection") +def test_empty_list_response( + live_backend: bool, + cli: CliRunner, + list_app: Typer, + httpx_mock: HTTPXMock, +) -> None: + """Ensure a proper message is given if the list namespaces response is empty.""" + from entities_service.service.config import CONFIG + + if not live_backend: + # Mock response for the list namespaces command + httpx_mock.add_response( + url=f"{str(CONFIG.base_url).rstrip('/')}/_api/namespaces", + status_code=500, + json={"detail": "No namespaces found in the backend."}, + ) + + result = cli.invoke(list_app, "namespaces") + + assert result.exit_code == 0, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + + assert "No namespaces found." in result.stdout, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) + assert not result.stderr, CLI_RESULT_FAIL_MESSAGE.format( + stdout=result.stdout, stderr=result.stderr + ) diff --git a/tests/cli/commands/test_list_util_funcs.py b/tests/cli/commands/test_list_util_funcs.py new file mode 100644 index 0000000..896d45a --- /dev/null +++ b/tests/cli/commands/test_list_util_funcs.py @@ -0,0 +1,190 @@ +"""Tests for `entities-service list` CLI command's utility functions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from typing import Literal + +CLI_RESULT_FAIL_MESSAGE = "STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}" + + +def test_parse_namespace(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the _parse_namespace function.""" + # Patch CONFIG.base_url + from pydantic import AnyHttpUrl + + core_namespace = "http://example.com" + + monkeypatch.setattr( + "entities_service.cli.commands.list.CONFIG.base_url", AnyHttpUrl(core_namespace) + ) + + # Perform tests + from entities_service.cli.commands.list import _parse_namespace + + # Test with a valid namespace + parsed_namespace = _parse_namespace(core_namespace) + assert parsed_namespace == core_namespace + + # Test with a valid namespace with trailing slash + parsed_namespace = _parse_namespace(core_namespace + "/") + assert parsed_namespace == core_namespace + + # Test with a non-core namespace + external_namespace = "http://example.org" + assert not external_namespace.startswith(core_namespace) + parsed_namespace = _parse_namespace(external_namespace) + assert parsed_namespace == external_namespace + + # Test with a non-core specific namespace + external_namespace = "http://example.org/test/" + assert not external_namespace.startswith(core_namespace) + parsed_namespace = _parse_namespace(external_namespace) + assert parsed_namespace == external_namespace.rstrip("/") + + # Test with a non-URL namespace + specific_namespace = "test" + parsed_namespace = _parse_namespace(specific_namespace) + assert parsed_namespace == f"{core_namespace}/{specific_namespace}" + + # Test with a fully qualified specific namespace URL + specific_namespace = f"{core_namespace}/test/" + parsed_namespace = _parse_namespace(specific_namespace) + assert parsed_namespace == specific_namespace.rstrip("/") + + # Test with a uri as URL + uri = f"{core_namespace}/test/1.0/Test" + parsed_namespace = _parse_namespace(uri) + assert parsed_namespace == f"{core_namespace}/test" + + # Test core namespace is returned for None, "/", and an empty string + for namespace in (None, "/", ""): + parsed_namespace = _parse_namespace(namespace) + assert parsed_namespace == core_namespace + + +@pytest.mark.parametrize("allow_external", [True, False]) +def test_parse_namespace_valueerror( + monkeypatch: pytest.MonkeyPatch, allow_external: Literal[True, False] +) -> None: + """Test the _parse_namespace raises a ValueError according to `allow_external` + parameter.""" + # Patch CONFIG.base_url + from pydantic import AnyHttpUrl + + core_namespace = "http://example.com" + + monkeypatch.setattr( + "entities_service.cli.commands.list.CONFIG.base_url", AnyHttpUrl(core_namespace) + ) + + # Perform tests + import re + + from entities_service.cli.commands.list import _parse_namespace + + non_core_namespace = "http://example.org/test/" + assert not non_core_namespace.startswith(core_namespace) + + if allow_external: + # Test with a non-core namespace + parsed_namespace = _parse_namespace( + non_core_namespace, allow_external=allow_external + ) + assert parsed_namespace == non_core_namespace.rstrip("/") + else: + with pytest.raises( + ValueError, + match=( + rf"^{re.escape(non_core_namespace)} is not within the core namespace " + rf"{re.escape(core_namespace)} and external namespaces are not allowed " + r"\(set 'allow_external=True'\)\.$" + ), + ): + _parse_namespace(non_core_namespace, allow_external=allow_external) + + +def test_get_specific_namespace(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the _get_specific_namespace function.""" + # Patch CONFIG.base_url + from pydantic import AnyHttpUrl + + core_namespace = "http://example.com" + + monkeypatch.setattr( + "entities_service.cli.commands.list.CONFIG.base_url", AnyHttpUrl(core_namespace) + ) + + # Perform tests + from entities_service.cli.commands.list import _get_specific_namespace + + specific_namespace = "test" + + # Test with a fully qualified URL + namespace = f"{core_namespace}/{specific_namespace}" + parsed_specific_namespace = _get_specific_namespace(namespace) + assert parsed_specific_namespace == specific_namespace + + # Test with a fully qualified URL with trailing slash + namespace = f"{core_namespace}/{specific_namespace}/" + parsed_specific_namespace = _get_specific_namespace(namespace) + assert parsed_specific_namespace == specific_namespace + + # Test with a specific namespace + parsed_specific_namespace = _get_specific_namespace(specific_namespace) + assert parsed_specific_namespace == specific_namespace + + # Test with a specific namespace with trailing slash + parsed_specific_namespace = _get_specific_namespace(f"{specific_namespace}/") + assert parsed_specific_namespace == specific_namespace + + # Test with " ", "/", and an empty string + for namespace in (" ", "/", ""): + parsed_namespace = _get_specific_namespace(namespace) + assert parsed_namespace is None + + +def test_get_specific_namespace_expectations(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the expectations mentioned in the doc-string of _get_specific_namespace + holds true. + + Mainly this is here to test that if error-handling is ever introduced to this + function, this test will fail making the developer aware of the change. + """ + # Patch CONFIG.base_url + from pydantic import AnyHttpUrl + + core_namespace = "http://example.com" + + monkeypatch.setattr( + "entities_service.cli.commands.list.CONFIG.base_url", AnyHttpUrl(core_namespace) + ) + + # Perform tests + from entities_service.cli.commands.list import _get_specific_namespace + + # Test with an external namespace + # This is not supported, but also not checked. + # The external namespace should be returned as is (with any trailing or prepended + # slashes removed). + external_namespace = "http://example.org" + assert not external_namespace.startswith(core_namespace) + + namespace = f"{external_namespace}/test/" + parsed_specific_namespace = _get_specific_namespace(namespace) + assert parsed_specific_namespace == namespace.rstrip("/") + + # Test passing `None` as the namespace + # This is not tested by the function, but is allowed by `_parse_namespace()`, so + # one might be confusied thinking it should also be allowed here, but that is not + # the case. + # Again, this is not checked due to the use case of the `_get_specific_namespace()` + # function. + with pytest.raises(AttributeError): + # As the first thing in the function is `namespace.startswith(...)` an + # AttributeError should be raised if `namespace` is `None`. + _get_specific_namespace(None) diff --git a/tests/conftest.py b/tests/conftest.py index 2bf4b9f..0f0663b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -632,18 +632,6 @@ def _reset_mongo_test_collections( ) -@pytest.fixture(autouse=True) -def _mock_lifespan(live_backend: bool, monkeypatch: pytest.MonkeyPatch) -> None: - """Mock the MongoDBBackend.initialize() method.""" - # Only mock the lifespan context manager if the tests are not run with a live - # backend - if not live_backend: - monkeypatch.setattr( - "entities_service.service.backend.mongodb.MongoDBBackend.initialize", - lambda _: None, - ) - - @pytest.fixture() def _empty_backend_collection( get_backend_user: GetBackendUserFixture, diff --git a/tests/service/backend/test_mongodb.py b/tests/service/backend/test_mongodb.py index 59abec5..072975b 100644 --- a/tests/service/backend/test_mongodb.py +++ b/tests/service/backend/test_mongodb.py @@ -58,7 +58,7 @@ def test_multiple_initialize(mongo_backend: GetMongoBackend) -> None: assert "_id_" in indices # Initialize the backend again, ensuring the "URI" index is not recreated - backend.initialize() + backend._initialize() indices = backend._collection.index_information() assert len(indices) == 2, indices diff --git a/tests/service/routers/test_api_entities.py b/tests/service/routers/test_api_entities.py new file mode 100644 index 0000000..b0c7522 --- /dev/null +++ b/tests/service/routers/test_api_entities.py @@ -0,0 +1,197 @@ +"""Test the /_api/entities endpoint.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any + + from ...conftest import ClientFixture + + +def test_list_entities(client: ClientFixture, static_dir: Path) -> None: + """Test calling the endpoint straight up.""" + from copy import deepcopy + + import yaml + + # Load entities + entities: list[dict[str, Any]] = yaml.safe_load( + (static_dir / "valid_entities.yaml").read_text() + ) + + # Update entities according to the expected response + expected_response_entities: list[dict[str, Any]] = [] + for entity in entities: + new_response_entity = deepcopy(entity) + + if "identity" in entity: + new_response_entity["uri"] = new_response_entity.pop("identity") + + # SOFT5 style + if isinstance(entity["properties"], list): + if "dimensions" not in entity: + new_response_entity["dimensions"] = [] + + # SOFT7 + elif isinstance(entity["properties"], dict): + if "dimensions" not in entity: + new_response_entity["dimensions"] = {} + + else: + pytest.fail(f"Invalid entity: {entity}") + + expected_response_entities.append(new_response_entity) + + # List entities + with client() as client_: + response = client_.get("/_api/entities") + + response_json = response.json() + + # Check response + assert response.status_code == 200, response_json + assert isinstance(response_json, list), response_json + assert len(response_json) == len(entities), response_json + assert response_json == expected_response_entities, response_json + + +def test_list_entities_specified_namespaces( + live_backend: bool, + client: ClientFixture, + static_dir: Path, + existing_specific_namespace: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test calling the endpoint with the 'namespaces' query parameter.""" + import json + from copy import deepcopy + + import yaml + + from entities_service.service.config import CONFIG + + # Load entities + entities: list[dict[str, Any]] = yaml.safe_load( + (static_dir / "valid_entities.yaml").read_text() + ) + original_length = len(entities) + + # Add specific namespace entities + core_namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + specific_namespace = f"{core_namespace}/{existing_specific_namespace}" + + for entity in deepcopy(entities): + id_key = "uri" if "uri" in entity else "identity" + if id_key in entity: + entity[id_key] = entity[id_key].replace(core_namespace, specific_namespace) + + if "namespace" in entity: + entity["namespace"] = specific_namespace + + entities.append(entity) + + # Update entities according to the expected response + expected_response_entities: list[dict[str, Any]] = [] + for entity in entities: + new_response_entity = deepcopy(entity) + + if "identity" in entity: + new_response_entity["uri"] = new_response_entity.pop("identity") + + # SOFT5 style + if isinstance(entity["properties"], list): + if "dimensions" not in entity: + new_response_entity["dimensions"] = [] + + # SOFT7 + elif isinstance(entity["properties"], dict): + if "dimensions" not in entity: + new_response_entity["dimensions"] = {} + + else: + pytest.fail(f"Invalid entity: {entity}") + + expected_response_entities.append(new_response_entity) + + # List entities + with client() as client_: + response = client_.get( + "/_api/entities", + params={ + "namespace": [ + existing_specific_namespace, + core_namespace, + "/", + specific_namespace, + f"{core_namespace}/1.0/Entity", + ] + }, + ) + + response_json = response.json() + + sorted_expected_response = [ + {key: entity[key] for key in sorted(entity)} + for entity in expected_response_entities + ] + + # Check response + assert response.status_code == 200, response_json + assert isinstance(response_json, list), response_json + assert len(response_json) == 2 * original_length, response_json + for entity in response_json: + sorted_entity = {key: entity[key] for key in sorted(entity)} + assert sorted_entity in sorted_expected_response, ( + f"{json.dumps(sorted_entity, indent=2)}\n\n" + "not found in expected response:\n\n" + f"{json.dumps(sorted_expected_response, indent=2)}" + ) + + if not live_backend: + # Check logs + assert ( + f"Namespace {core_namespace + '/1.0/Entity'!r} is a URI (as a URL)." + in caplog.text + ), caplog.text + + for full_namespace in (core_namespace, specific_namespace): + assert ( + f"Namespace {full_namespace!r} is a 'regular' full namespace." + in caplog.text + ), caplog.text + + +def test_list_entities_invalid_namespaces( + live_backend: bool, client: ClientFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test calling the endpoint with invalid 'namespaces' query parameter.""" + from entities_service.service.config import CONFIG + + invalid_namespace = "http://example.com" + expected_log_error = ( + f"Namespace {invalid_namespace!r} does not start with the base URL " + f"{CONFIG.base_url}." + ) + expected_response = f"Invalid namespace: {invalid_namespace}." + + with client() as client_: + response = client_.get( + "/_api/entities", params={"namespace": [invalid_namespace]} + ) + + response_json = response.json() + + # Check response + assert response.status_code == 400, response_json + assert isinstance(response_json, dict), response_json + assert "detail" in response_json, response_json + assert response_json["detail"] == expected_response, response_json + + if not live_backend: + # Check logs + assert expected_log_error in caplog.text, caplog.text diff --git a/tests/service/routers/test_api_namespaces.py b/tests/service/routers/test_api_namespaces.py new file mode 100644 index 0000000..af77a67 --- /dev/null +++ b/tests/service/routers/test_api_namespaces.py @@ -0,0 +1,106 @@ +"""Test the /_api/namespaces endpoint.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any + + from ...conftest import ClientFixture, GetBackendUserFixture + + +def test_list_namespaces(client: ClientFixture) -> None: + """Test calling the endpoint straight up.""" + from entities_service.service.config import CONFIG + + # List namespaces + with client() as client_: + response = client_.get("/_api/namespaces") + + response_json = response.json() + + expected_response = [ + str(CONFIG.model_fields["base_url"].default).rstrip("/") + specific_namespace + for specific_namespace in ("", "/test") + ] + + # Check response + assert response.status_code == 200, response_json + assert isinstance(response_json, list), response_json + assert set(response_json) == set(expected_response), response_json + + +@pytest.mark.usefixtures("_empty_backend_collection") +def test_empty_dbs(client: ClientFixture) -> None: + """Test calling the endpoint with no or empty backend databases.""" + # List namespaces + with client() as client_: + response = client_.get("/_api/namespaces") + + response_json = response.json() + + expected_response = {"detail": "No namespaces found in the backend."} + + # Check response + assert response.status_code == 500, response_json + assert isinstance(response_json, dict), response_json + assert response_json == expected_response, response_json + + +@pytest.mark.usefixtures("_empty_backend_collection") +def test_namespace_from_entity_namespace( + client: ClientFixture, + static_dir: Path, + get_backend_user: GetBackendUserFixture, +) -> None: + """Test retrieving the namespace from an entity's 'namespace' attribute.""" + import yaml + + from entities_service.service.backend import get_backend + from entities_service.service.config import CONFIG + + entity: dict[str, Any] | None = None + entities: list[dict[str, Any]] = yaml.safe_load( + (static_dir / "valid_entities.yaml").read_text() + ) + + for entity in entities: + if "namespace" in entity: + break + else: + pytest.fails( + "No entity with the 'namespace' attribute found in 'valid_entities.yaml'." + ) + + assert entity is not None + + # Ensure namespace is the core namespace + entity["namespace"] = str(CONFIG.model_fields["base_url"].default).rstrip("/") + + # Remove uri/identity if it exists + entity.pop("uri", entity.pop("identity", None)) + + # Add entity with 'namespace' attribute (and not 'uri') + backend_user = get_backend_user(auth_role="write") + backend = get_backend( + settings={ + "mongo_username": backend_user["username"], + "mongo_password": backend_user["password"], + } + ) + backend.create([entity]) + + # List namespaces + with client() as client_: + response = client_.get("/_api/namespaces") + + response_json = response.json() + + # Check response + assert response.status_code == 200, response_json + assert isinstance(response_json, list), response_json + assert response_json == [entity["namespace"]], response_json diff --git a/tests/service/test_utils.py b/tests/service/test_utils.py new file mode 100644 index 0000000..cff308b --- /dev/null +++ b/tests/service/test_utils.py @@ -0,0 +1,52 @@ +"""Test utils.py under service.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from typing import Literal + + +@pytest.mark.parametrize("specific_namespace", [False, True]) +async def test_get_entities( + specific_namespace: Literal[False, True], existing_specific_namespace: str +) -> None: + """Test _get_entities.""" + from entities_service.models import URI_REGEX + from entities_service.service.backend import get_backend + from entities_service.service.config import CONFIG + from entities_service.service.utils import _get_entities + + db = existing_specific_namespace if specific_namespace else None + + backend = get_backend(CONFIG.backend, auth_level="read", db=db) + entities = list(backend) + + namespace = str(CONFIG.model_fields["base_url"].default).rstrip("/") + + if specific_namespace: + namespace += "/test" + + for entity in entities: + if "dimensions" not in entity: + if isinstance(entity["properties"], list): + entity["dimensions"] = [] + elif isinstance(entity["properties"], dict): + entity["dimensions"] = {} + else: + pytest.fails("Invalid entity.") + + if "namespace" in entity: + assert entity["namespace"] == namespace + + id_key = "uri" if "uri" in entity else "identity" + if id_key in entity: + match = URI_REGEX.match(entity[id_key]) + assert match is not None + assert match.group("specific_namespace") == db + + # Test with no entities + assert await _get_entities(db) == entities