From c2375a2d51b527151270a1030f835d77f9c233e6 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Sat, 7 Mar 2026 21:15:30 +0200 Subject: [PATCH] Refactor collection and request handling - Introduced a new for read-only query functions related to collections and requests, separating them from CRUD operations. - Removed redundant functions from and updated imports accordingly. - Enhanced service layer to utilize the new query repository for fetching collections and requests. - Added utility functions for parsing headers in the HTTP service layer, improving code reusability. - Updated tests to reflect changes in repository structure and ensure coverage for new functionalities. --- .github/copilot-instructions.md | 29 +- .../instructions/architecture.instructions.md | 4 +- .../instructions/sqlalchemy.instructions.md | 14 + .../service-repository-reference/SKILL.md | 45 ++- .../collection_query_repository.py | 343 ++++++++++++++++++ .../collections/collection_repository.py | 335 +---------------- src/services/__init__.py | 22 ++ src/services/collection_service.py | 35 +- src/services/environment_service.py | 53 +-- src/services/http/__init__.py | 13 + src/services/http/header_utils.py | 27 ++ src/services/http/http_service.py | 21 +- src/services/http/snippet_generator.py | 18 +- src/ui/main_window/tab_controller.py | 8 +- src/ui/main_window/variable_controller.py | 2 +- .../request/request_editor/editor_widget.py | 5 +- tests/conftest.py | 32 ++ tests/ui/request/conftest.py | 29 ++ .../ui/request/test_request_editor_search.py | 2 - tests/unit/database/test_repository.py | 12 +- tests/unit/services/http/test_http_service.py | 17 +- tests/unit/services/test_import_service.py | 2 +- 22 files changed, 645 insertions(+), 423 deletions(-) create mode 100644 src/database/models/collections/collection_query_repository.py create mode 100644 src/services/http/header_utils.py create mode 100644 tests/ui/request/conftest.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e5ed1b5..6ecfaba 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -107,6 +107,30 @@ poetry run pytest `extraPaths` in `pyproject.toml`). Imports use bare module names: `from database.database import init_db`. +## LLM Navigation Quick-Start + +Fastest paths to understand and navigate the codebase: + +- **All services at a glance:** Read `src/services/__init__.py` — re-exports + `CollectionService`, `EnvironmentService`, `ImportService`, and key + TypedDicts (`RequestLoadDict`, `VariableDetail`, `LocalOverride`). +- **HTTP subsystem:** Read `src/services/http/__init__.py` — re-exports + `HttpService`, `GraphQLSchemaService`, `SnippetGenerator`, + `HttpResponseDict`, `parse_header_dict`. +- **All DB models:** Read `src/database/database.py` — re-exports all four + ORM models (`CollectionModel`, `RequestModel`, `SavedResponseModel`, + `EnvironmentModel`). +- **Collection CRUD vs queries:** Mutations live in + `collection_repository.py`; read-only tree/breadcrumb/ancestor queries + live in `collection_query_repository.py`. +- **Signal flow:** Load the `signal-flow` skill for complete wiring diagrams. +- **TypedDicts:** Cross-module dict schemas live in the service that owns + them (e.g. `RequestLoadDict` in `collection_service.py`, + `HttpResponseDict` in `http_service.py`). +- **Test fixtures:** `make_collection_with_request` (root `conftest.py`) and + `make_request_dict` (`tests/ui/request/conftest.py`) reduce setup + boilerplate. + ## Architecture ``` @@ -118,6 +142,7 @@ src/ │ ├── base.py # DeclarativeBase │ ├── collections/ │ │ ├── collection_repository.py # CRUD for collections + requests +│ │ ├── collection_query_repository.py # Read-only tree/breadcrumb/ancestor queries │ │ ├── import_repository.py # Atomic bulk-import of parsed data │ │ └── model/ │ │ ├── collection_model.py # CollectionModel (folders) @@ -134,7 +159,8 @@ src/ │ ├── http/ # HTTP request/response handling │ │ ├── http_service.py # HttpService (httpx) + response TypedDicts │ │ ├── graphql_schema_service.py # GraphQL introspection + schema parsing -│ │ └── snippet_generator.py # Code snippet generation (cURL/Python/JS) +│ │ ├── snippet_generator.py # Code snippet generation (cURL/Python/JS) +│ │ └── header_utils.py # Shared header parsing utility │ └── import_parser/ # Parser sub-package │ ├── models.py # TypedDict schemas for parsed data │ ├── postman_parser.py # Postman collection/environment parser @@ -251,6 +277,7 @@ tests/ │ ├── test_console_panel.py │ └── test_history_panel.py └── request/ # Request/response editing tests + ├── conftest.py # make_request_dict fixture factory ├── test_folder_editor.py ├── test_http_worker.py ├── test_request_editor.py diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md index f24d75a..677b75b 100644 --- a/.github/instructions/architecture.instructions.md +++ b/.github/instructions/architecture.instructions.md @@ -180,7 +180,9 @@ them. Service validation errors (empty names, missing parents) are silently discarded. **If you add a new service method**, its errors will be invisible unless you -also add explicit UI feedback (e.g. a `QMessageBox`). +also add explicit UI feedback (e.g. a `QMessageBox`). For user-visible +errors, pair the service call with `QMessageBox.warning()` or emit a status +signal instead of relying on `_safe_svc_call`. ### 5. Sort ordering diff --git a/.github/instructions/sqlalchemy.instructions.md b/.github/instructions/sqlalchemy.instructions.md index 86660c0..552b8a3 100644 --- a/.github/instructions/sqlalchemy.instructions.md +++ b/.github/instructions/sqlalchemy.instructions.md @@ -147,6 +147,20 @@ with get_session() as session: All models inherit from `Base` defined in `src/database/models/base.py`. Do not create a second base class. +## Detached-object decision checklist + +After the `get_session()` block exits, the ORM object is *detached*. +Use this quick checklist to decide whether to return the object or a dict: + +| What you need after session close | Safe? | Action | +|---|---|---| +| Scalar attributes only (`id`, `name`, `body`, ...) | Yes | Return the ORM object directly | +| One-level eager relation (`collection.requests`) | Yes | `selectin` loads it during the query | +| Two+ levels deep (`coll.children[0].children`) | **No** | Convert to dict inside session | +| Relationship on an object from a different query | **No** | Re-query or join-load explicitly | + +When in doubt, convert to a dict inside the open session. + ## Lightweight schema migration — forward-only column additions `database.py` contains `_migrate_add_missing_columns(engine)`, called by diff --git a/.github/skills/service-repository-reference/SKILL.md b/.github/skills/service-repository-reference/SKILL.md index 1b7a325..ea8d5d7 100644 --- a/.github/skills/service-repository-reference/SKILL.md +++ b/.github/skills/service-repository-reference/SKILL.md @@ -11,20 +11,28 @@ cross-layer data interchange. ## Repository function catalogue -### Collection repository (`collection_repository.py`) +### Collection repository — CRUD (`collection_repository.py`) | Function | Returns | Purpose | |----------|---------|---------| -| `fetch_all_collections()` | `dict[str, Any]` | All root collections as nested dict | | `create_new_collection(name, parent_id?)` | `CollectionModel` | Create a folder | | `rename_collection(collection_id, new_name)` | `None` | Update name | | `delete_collection(collection_id)` | `None` | Delete + cascade children and requests | -| `get_collection_by_id(collection_id)` | `CollectionModel \| None` | PK lookup | | `create_new_request(collection_id, method, url, name, ...)` | `RequestModel` | Create a request | | `rename_request(request_id, new_name)` | `None` | Update name | | `delete_request(request_id)` | `None` | Delete a single request | | `update_request_collection(request_id, new_collection_id)` | `None` | Move request | | `update_collection_parent(collection_id, new_parent_id)` | `None` | Move collection | +| `save_response(request_id, ...)` | `int` | Persist a response snapshot, return its ID | +| `update_collection(collection_id, **fields)` | `None` | Generic field update on a collection | +| `update_request(request_id, **fields)` | `None` | Generic field update on a request | + +### Collection query repository (`collection_query_repository.py`) + +| Function | Returns | Purpose | +|----------|---------|---------| +| `fetch_all_collections()` | `dict[str, Any]` | All root collections as nested dict | +| `get_collection_by_id(collection_id)` | `CollectionModel \| None` | PK lookup | | `get_request_by_id(request_id)` | `RequestModel \| None` | PK lookup | | `get_request_auth_chain(request_id)` | `dict[str, Any] \| None` | Walk parent chain for auth config | | `get_request_variable_chain(request_id)` | `dict[str, str]` | Collect variables up the parent chain | @@ -32,11 +40,13 @@ cross-layer data interchange. | `get_request_breadcrumb(request_id)` | `list[dict[str, Any]]` | Ancestor path for breadcrumb bar | | `get_collection_breadcrumb(collection_id)` | `list[dict[str, Any]]` | Ancestor path for collection breadcrumb | | `get_saved_responses_for_request(request_id)` | `list[dict[str, Any]]` | Saved responses for a request | -| `save_response(request_id, ...)` | `int` | Persist a response snapshot, return its ID | -| `update_collection(collection_id, **fields)` | `None` | Generic field update on a collection | -| `update_request(request_id, **fields)` | `None` | Generic field update on a request | | `count_collection_requests(collection_id)` | `int` | Total request count in folder subtree | | `get_recent_requests_for_collection(collection_id, ...)` | `list[dict[str, Any]]` | Recently modified requests in subtree | + +### Import repository (`import_repository.py`) + +| Function | Returns | Purpose | +|----------|---------|---------| | `import_collection_tree(parsed)` | `dict[str, int]` | Atomic bulk-import of a full collection tree | ### Environment repository (`environment_repository.py`) @@ -146,6 +156,12 @@ All methods are `@staticmethod`. | `available_languages()` | List of supported language names | | `generate(language, method, url, headers, body)` | Dispatch to language-specific generator | +### Shared HTTP utilities (`services/http/header_utils.py`) + +| Function | Returns | Purpose | +|----------|---------|---------| +| `parse_header_dict(raw)` | `dict[str, str]` | Parse `Key: Value\n` lines into a dict | + ## TypedDict schemas ### HttpService TypedDicts (`services/http/http_service.py`) @@ -185,6 +201,23 @@ class HttpResponseDict(TypedDict): network: NotRequired[NetworkDict] ``` +### CollectionService TypedDicts (`services/collection_service.py`) + +```python +class RequestLoadDict(TypedDict, total=False): + name: str + method: str + url: str + body: str | None + request_parameters: str | list[dict[str, Any]] | None + headers: str | list[dict[str, Any]] | None + description: str | None + scripts: dict[str, str] | None + body_mode: str | None + body_options: dict[str, Any] | None + auth: dict[str, Any] | None +``` + ### EnvironmentService TypedDicts (`services/environment_service.py`) ```python diff --git a/src/database/models/collections/collection_query_repository.py b/src/database/models/collections/collection_query_repository.py new file mode 100644 index 0000000..ed05aa3 --- /dev/null +++ b/src/database/models/collections/collection_query_repository.py @@ -0,0 +1,343 @@ +"""Read-only query functions for collections and requests. + +Tree traversal, breadcrumb resolution, ancestor chain walks, +and aggregate queries live here. Mutation / CRUD functions +live in ``collection_repository``. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import func as sa_func +from sqlalchemy import select + +from database.database import get_session + +from .model.collection_model import CollectionModel +from .model.request_model import RequestModel + +logger = logging.getLogger(__name__) + + +def _get_descendant_collection_ids( + session: Any, + root_id: int, +) -> list[int]: + """Return *root_id* and all its descendant collection IDs via BFS.""" + queue = [root_id] + all_ids: list[int] = [root_id] + while queue: + current = queue.pop(0) + stmt = select(CollectionModel.id).where(CollectionModel.parent_id == current) + children = list(session.execute(stmt).scalars().all()) + all_ids.extend(children) + queue.extend(children) + return all_ids + + +def _build_tree_dict_lightweight() -> dict[str, Any]: + """Build the sidebar tree dict by streaming two lightweight queries. + + Rows are consumed one at a time via ``yield_per`` and written directly + into the target dict, so the intermediate SQLAlchemy ``Row`` objects + are discarded immediately rather than being held alongside the final + dict. Only columns needed for the tree display are fetched: + + - collections: ``id``, ``name``, ``parent_id`` + - requests: ``id``, ``name``, ``method``, ``collection_id`` + + Heavy columns (body, headers, JSON blobs) and ``saved_responses`` are + never touched. + """ + _YIELD_CHUNK = 500 + + col_by_id: dict[int, dict[str, Any]] = {} + + with get_session() as session: + # 1. Stream collections into the lookup dict + col_stmt = select( + CollectionModel.id, + CollectionModel.name, + CollectionModel.parent_id, + ) + for cid, cname, pid in session.execute(col_stmt).yield_per(_YIELD_CHUNK): + col_by_id[cid] = { + "id": cid, + "name": cname, + "parent_id": pid, + "type": "folder", + "children": {}, + } + + # 2. Stream requests directly into their parent collection + req_stmt = select( + RequestModel.id, + RequestModel.name, + RequestModel.method, + RequestModel.collection_id, + ) + for rid, rname, rmethod, rcol_id in session.execute(req_stmt).yield_per(_YIELD_CHUNK): + parent = col_by_id.get(rcol_id) + if parent is not None: + parent["children"][str(rid)] = { + "type": "request", + "id": rid, + "name": rname, + "method": rmethod, + } + + # 3. Build the tree by nesting children under parents + roots: dict[str, Any] = {} + for cid, node in col_by_id.items(): + pid = node.pop("parent_id") # no longer needed in output + if pid is None: + roots[str(cid)] = node + else: + parent = col_by_id.get(pid) + if parent is not None: + parent["children"][str(cid)] = node + + return roots + + +def fetch_all_collections() -> dict[str, Any]: + """Return every root collection as a nested dict. + + Uses two streamed scalar queries (``yield_per``) that build the tree + dict in place, so intermediate ``Row`` objects are discarded + immediately. Only the columns needed for the sidebar tree are + fetched — heavy columns and relationships are never touched. + """ + return _build_tree_dict_lightweight() + + +def get_collection_by_id(collection_id: int) -> CollectionModel | None: + """Return the collection with the given *collection_id*, or ``None``.""" + with get_session() as session: + return ( + session.execute(select(CollectionModel).where(CollectionModel.id == collection_id)) + .scalars() + .first() + ) + + +def get_request_by_id(request_id: int) -> RequestModel | None: + """Return the request with the given *request_id*, or ``None``.""" + with get_session() as session: + return ( + session.execute(select(RequestModel).where(RequestModel.id == request_id)) + .scalars() + .first() + ) + + +def get_request_auth_chain(request_id: int) -> dict[str, Any] | None: + """Walk the parent collection chain to find the effective auth config. + + Returns the request's own auth if set and not ``noauth``. Otherwise + walks up through parent collections and returns the first auth found. + Returns ``None`` if no auth is configured anywhere in the chain. + """ + with get_session() as session: + req = session.get(RequestModel, request_id) + if req is None: + return None + # 1. Check request's own auth + if req.auth and req.auth.get("type") not in (None, "noauth"): + return req.auth + # 2. Walk parent collection chain + coll = session.get(CollectionModel, req.collection_id) + while coll is not None: + if coll.auth and coll.auth.get("type") not in (None, "noauth"): + return coll.auth + if coll.parent_id is None: + break + coll = session.get(CollectionModel, coll.parent_id) + return None + + +def get_request_variable_chain(request_id: int) -> dict[str, str]: + """Walk the parent collection chain and merge all collection variables. + + Starts from the request's immediate parent collection and walks up to + the root. Variables defined on closer ancestors take priority over + those defined further up the tree (child overrides parent). + + Returns an empty dict if no collection variables are found. + """ + with get_session() as session: + req = session.get(RequestModel, request_id) + if req is None: + return {} + # 1. Collect variable lists from nearest ancestor first + layers: list[list[dict[str, Any]]] = [] + coll = session.get(CollectionModel, req.collection_id) + while coll is not None: + if coll.variables: + layers.append(coll.variables) + if coll.parent_id is None: + break + coll = session.get(CollectionModel, coll.parent_id) + # 2. Merge from root to leaf so child overrides parent + merged: dict[str, str] = {} + for var_list in reversed(layers): + for entry in var_list: + if not entry.get("enabled", True): + continue + key = entry.get("key", "") + value = entry.get("value", "") + if key: + merged[key] = value + return merged + + +def get_request_variable_chain_detailed(request_id: int) -> dict[str, tuple[str, int]]: + """Walk the parent chain and return ``{key: (value, collection_id)}``. + + Like :func:`get_request_variable_chain` but each entry also carries + the ``collection_id`` where the variable is defined. This is used + by the variable popup to know which collection to update when the + user edits a value. + """ + with get_session() as session: + req = session.get(RequestModel, request_id) + if req is None: + return {} + layers: list[tuple[int, list[dict[str, Any]]]] = [] + coll = session.get(CollectionModel, req.collection_id) + while coll is not None: + if coll.variables: + layers.append((coll.id, coll.variables)) + if coll.parent_id is None: + break + coll = session.get(CollectionModel, coll.parent_id) + merged: dict[str, tuple[str, int]] = {} + for coll_id, var_list in reversed(layers): + for entry in var_list: + if not entry.get("enabled", True): + continue + key = entry.get("key", "") + value = entry.get("value", "") + if key: + merged[key] = (value, coll_id) + return merged + + +def get_request_breadcrumb(request_id: int) -> list[dict[str, Any]]: + """Return the breadcrumb path from root collection to the request. + + Each entry has ``id``, ``name``, and ``type`` (``folder`` or + ``request``) keys. + """ + with get_session() as session: + req = session.get(RequestModel, request_id) + if req is None: + return [] + path: list[dict[str, Any]] = [] + # Walk up the collection chain + coll = session.get(CollectionModel, req.collection_id) + while coll is not None: + path.append({"id": coll.id, "name": coll.name, "type": "folder"}) + if coll.parent_id is None: + break + coll = session.get(CollectionModel, coll.parent_id) + path.reverse() + path.append({"id": req.id, "name": req.name, "type": "request"}) + return path + + +def get_collection_breadcrumb(collection_id: int) -> list[dict[str, Any]]: + """Return the breadcrumb path from root collection to the given folder. + + Each entry has ``id``, ``name``, and ``type`` (always ``folder``) keys. + """ + with get_session() as session: + coll = session.get(CollectionModel, collection_id) + if coll is None: + return [] + path: list[dict[str, Any]] = [] + while coll is not None: + path.append({"id": coll.id, "name": coll.name, "type": "folder"}) + if coll.parent_id is None: + break + coll = session.get(CollectionModel, coll.parent_id) + path.reverse() + return path + + +def count_collection_requests(collection_id: int) -> int: + """Count all requests recursively under a collection. + + Walks the collection subtree (children and their descendants) and + returns the total number of requests contained. + """ + with get_session() as session: + # 1. Gather all descendant collection IDs (BFS) + all_ids = _get_descendant_collection_ids(session, collection_id) + + # 2. Count requests in all collected collection IDs + count_stmt = ( + select(sa_func.count()) + .select_from(RequestModel) + .where(RequestModel.collection_id.in_(all_ids)) + ) + result = session.execute(count_stmt).scalar() + return result or 0 + + +def get_recent_requests_for_collection( + collection_id: int, + limit: int = 10, +) -> list[dict[str, Any]]: + """Return the most recently updated requests under *collection_id*. + + Walks the collection subtree (BFS) and returns up to *limit* + requests ordered by ``updated_at DESC``. Each entry is a dict with + ``name``, ``method``, and ``updated_at`` keys. + """ + with get_session() as session: + # 1. Gather all descendant collection IDs (BFS) + all_ids = _get_descendant_collection_ids(session, collection_id) + + # 2. Fetch recently-updated requests + req_stmt = ( + select( + RequestModel.name, + RequestModel.method, + RequestModel.updated_at, + ) + .where(RequestModel.collection_id.in_(all_ids)) + .order_by(RequestModel.updated_at.desc()) + .limit(limit) + ) + rows = session.execute(req_stmt).all() + return [ + { + "name": r.name, + "method": r.method, + "updated_at": r.updated_at, + } + for r in rows + ] + + +def get_saved_responses_for_request(request_id: int) -> list[dict[str, Any]]: + """Return all saved responses for a request as dicts.""" + from .model.saved_response_model import SavedResponseModel + + with get_session() as session: + stmt = select(SavedResponseModel).where(SavedResponseModel.request_id == request_id) + responses = list(session.execute(stmt).scalars().all()) + return [ + { + "id": sr.id, + "name": sr.name, + "status": sr.status, + "code": sr.code, + "headers": sr.headers, + "body": sr.body, + } + for sr in responses + ] diff --git a/src/database/models/collections/collection_repository.py b/src/database/models/collections/collection_repository.py index 05ba4fc..901aa53 100644 --- a/src/database/models/collections/collection_repository.py +++ b/src/database/models/collections/collection_repository.py @@ -1,8 +1,10 @@ -"""Repository layer -- CRUD functions for collections and requests. +"""Repository layer -- CRUD / mutation functions for collections and requests. -This module is the single point of database access for collections and -requests. UI code must **not** import this directly -- use the service -layer (``services.collection_service``) instead. +Read-only queries (tree fetching, breadcrumbs, ancestor chain walks) +live in :mod:`collection_query_repository`. + +UI code must **not** import this directly -- use the service layer +(``services.collection_service``) instead. """ from __future__ import annotations @@ -10,8 +12,7 @@ import logging from typing import Any -from sqlalchemy import func as sa_func -from sqlalchemy import select, update +from sqlalchemy import update from database.database import get_session @@ -21,82 +22,6 @@ logger = logging.getLogger(__name__) -def _build_tree_dict_lightweight() -> dict[str, Any]: - """Build the sidebar tree dict by streaming two lightweight queries. - - Rows are consumed one at a time via ``yield_per`` and written directly - into the target dict, so the intermediate SQLAlchemy ``Row`` objects - are discarded immediately rather than being held alongside the final - dict. Only columns needed for the tree display are fetched: - - - collections: ``id``, ``name``, ``parent_id`` - - requests: ``id``, ``name``, ``method``, ``collection_id`` - - Heavy columns (body, headers, JSON blobs) and ``saved_responses`` are - never touched. - """ - _YIELD_CHUNK = 500 - - col_by_id: dict[int, dict[str, Any]] = {} - - with get_session() as session: - # 1. Stream collections into the lookup dict - col_stmt = select( - CollectionModel.id, - CollectionModel.name, - CollectionModel.parent_id, - ) - for cid, cname, pid in session.execute(col_stmt).yield_per(_YIELD_CHUNK): - col_by_id[cid] = { - "id": cid, - "name": cname, - "parent_id": pid, - "type": "folder", - "children": {}, - } - - # 2. Stream requests directly into their parent collection - req_stmt = select( - RequestModel.id, - RequestModel.name, - RequestModel.method, - RequestModel.collection_id, - ) - for rid, rname, rmethod, rcol_id in session.execute(req_stmt).yield_per(_YIELD_CHUNK): - parent = col_by_id.get(rcol_id) - if parent is not None: - parent["children"][str(rid)] = { - "type": "request", - "id": rid, - "name": rname, - "method": rmethod, - } - - # 3. Build the tree by nesting children under parents - roots: dict[str, Any] = {} - for cid, node in col_by_id.items(): - pid = node.pop("parent_id") # no longer needed in output - if pid is None: - roots[str(cid)] = node - else: - parent = col_by_id.get(pid) - if parent is not None: - parent["children"][str(cid)] = node - - return roots - - -def fetch_all_collections() -> dict[str, Any]: - """Return every root collection as a nested dict. - - Uses two streamed scalar queries (``yield_per``) that build the tree - dict in place, so intermediate ``Row`` objects are discarded - immediately. Only the columns needed for the sidebar tree are - fetched — heavy columns and relationships are never touched. - """ - return _build_tree_dict_lightweight() - - def create_new_collection(name: str, parent_id: int | None = None) -> CollectionModel: """Create a new collection with the specified *name* and optional *parent_id*. @@ -238,161 +163,6 @@ def update_collection_parent(collection_id: int, new_parent_id: int | None) -> N session.execute(stmt) -def get_collection_by_id(collection_id: int) -> CollectionModel | None: - """Return the collection with the given *collection_id*, or ``None``.""" - with get_session() as session: - return ( - session.execute(select(CollectionModel).where(CollectionModel.id == collection_id)) - .scalars() - .first() - ) - - -def get_request_by_id(request_id: int) -> RequestModel | None: - """Return the request with the given *request_id*, or ``None``.""" - with get_session() as session: - return ( - session.execute(select(RequestModel).where(RequestModel.id == request_id)) - .scalars() - .first() - ) - - -def get_request_auth_chain(request_id: int) -> dict[str, Any] | None: - """Walk the parent collection chain to find the effective auth config. - - Returns the request's own auth if set and not ``noauth``. Otherwise - walks up through parent collections and returns the first auth found. - Returns ``None`` if no auth is configured anywhere in the chain. - """ - with get_session() as session: - req = session.get(RequestModel, request_id) - if req is None: - return None - # 1. Check request's own auth - if req.auth and req.auth.get("type") not in (None, "noauth"): - return req.auth - # 2. Walk parent collection chain - coll = session.get(CollectionModel, req.collection_id) - while coll is not None: - if coll.auth and coll.auth.get("type") not in (None, "noauth"): - return coll.auth - if coll.parent_id is None: - break - coll = session.get(CollectionModel, coll.parent_id) - return None - - -def get_request_variable_chain(request_id: int) -> dict[str, str]: - """Walk the parent collection chain and merge all collection variables. - - Starts from the request's immediate parent collection and walks up to - the root. Variables defined on closer ancestors take priority over - those defined further up the tree (child overrides parent). - - Returns an empty dict if no collection variables are found. - """ - with get_session() as session: - req = session.get(RequestModel, request_id) - if req is None: - return {} - # 1. Collect variable lists from nearest ancestor first - layers: list[list[dict[str, Any]]] = [] - coll = session.get(CollectionModel, req.collection_id) - while coll is not None: - if coll.variables: - layers.append(coll.variables) - if coll.parent_id is None: - break - coll = session.get(CollectionModel, coll.parent_id) - # 2. Merge from root to leaf so child overrides parent - merged: dict[str, str] = {} - for var_list in reversed(layers): - for entry in var_list: - if not entry.get("enabled", True): - continue - key = entry.get("key", "") - value = entry.get("value", "") - if key: - merged[key] = value - return merged - - -def get_request_variable_chain_detailed(request_id: int) -> dict[str, tuple[str, int]]: - """Walk the parent chain and return ``{key: (value, collection_id)}``. - - Like :func:`get_request_variable_chain` but each entry also carries - the ``collection_id`` where the variable is defined. This is used - by the variable popup to know which collection to update when the - user edits a value. - """ - with get_session() as session: - req = session.get(RequestModel, request_id) - if req is None: - return {} - layers: list[tuple[int, list[dict[str, Any]]]] = [] - coll = session.get(CollectionModel, req.collection_id) - while coll is not None: - if coll.variables: - layers.append((coll.id, coll.variables)) - if coll.parent_id is None: - break - coll = session.get(CollectionModel, coll.parent_id) - merged: dict[str, tuple[str, int]] = {} - for coll_id, var_list in reversed(layers): - for entry in var_list: - if not entry.get("enabled", True): - continue - key = entry.get("key", "") - value = entry.get("value", "") - if key: - merged[key] = (value, coll_id) - return merged - - -def get_request_breadcrumb(request_id: int) -> list[dict[str, Any]]: - """Return the breadcrumb path from root collection to the request. - - Each entry has ``id``, ``name``, and ``type`` (``folder`` or - ``request``) keys. - """ - with get_session() as session: - req = session.get(RequestModel, request_id) - if req is None: - return [] - path: list[dict[str, Any]] = [] - # Walk up the collection chain - coll = session.get(CollectionModel, req.collection_id) - while coll is not None: - path.append({"id": coll.id, "name": coll.name, "type": "folder"}) - if coll.parent_id is None: - break - coll = session.get(CollectionModel, coll.parent_id) - path.reverse() - path.append({"id": req.id, "name": req.name, "type": "request"}) - return path - - -def get_saved_responses_for_request(request_id: int) -> list[dict[str, Any]]: - """Return all saved responses for a request as dicts.""" - from .model.saved_response_model import SavedResponseModel - - with get_session() as session: - stmt = select(SavedResponseModel).where(SavedResponseModel.request_id == request_id) - responses = list(session.execute(stmt).scalars().all()) - return [ - { - "id": sr.id, - "name": sr.name, - "status": sr.status, - "code": sr.code, - "headers": sr.headers, - "body": sr.body, - } - for sr in responses - ] - - def save_response( request_id: int, name: str, @@ -449,52 +219,6 @@ def update_collection(collection_id: int, **fields: Any) -> None: session.execute(stmt) -def get_collection_breadcrumb(collection_id: int) -> list[dict[str, Any]]: - """Return the breadcrumb path from root collection to the given folder. - - Each entry has ``id``, ``name``, and ``type`` (always ``folder``) keys. - """ - with get_session() as session: - coll = session.get(CollectionModel, collection_id) - if coll is None: - return [] - path: list[dict[str, Any]] = [] - while coll is not None: - path.append({"id": coll.id, "name": coll.name, "type": "folder"}) - if coll.parent_id is None: - break - coll = session.get(CollectionModel, coll.parent_id) - path.reverse() - return path - - -def count_collection_requests(collection_id: int) -> int: - """Count all requests recursively under a collection. - - Walks the collection subtree (children and their descendants) and - returns the total number of requests contained. - """ - with get_session() as session: - # 1. Gather all descendant collection IDs (BFS) - queue = [collection_id] - all_ids: list[int] = [collection_id] - while queue: - current = queue.pop(0) - stmt = select(CollectionModel.id).where(CollectionModel.parent_id == current) - children = list(session.execute(stmt).scalars().all()) - all_ids.extend(children) - queue.extend(children) - - # 2. Count requests in all collected collection IDs - count_stmt = ( - select(sa_func.count()) - .select_from(RequestModel) - .where(RequestModel.collection_id.in_(all_ids)) - ) - result = session.execute(count_stmt).scalar() - return result or 0 - - # Columns on RequestModel that may be updated via update_request(). _EDITABLE_REQUEST_FIELDS = { "name", @@ -534,48 +258,3 @@ def update_request(request_id: int, **fields: Any) -> None: raise ValueError(f"No request found with id={request_id}") stmt = update(RequestModel).where(RequestModel.id == request_id).values(**fields) session.execute(stmt) - - -def get_recent_requests_for_collection( - collection_id: int, - limit: int = 10, -) -> list[dict[str, Any]]: - """Return the most recently updated requests under *collection_id*. - - Walks the collection subtree (BFS) and returns up to *limit* - requests ordered by ``updated_at DESC``. Each entry is a dict with - ``name``, ``method``, and ``updated_at`` keys. - """ - with get_session() as session: - # 1. Gather all descendant collection IDs (BFS) - queue = [collection_id] - all_ids: list[int] = [collection_id] - while queue: - current = queue.pop(0) - stmt = select(CollectionModel.id).where( - CollectionModel.parent_id == current, - ) - children = list(session.execute(stmt).scalars().all()) - all_ids.extend(children) - queue.extend(children) - - # 2. Fetch recently-updated requests - req_stmt = ( - select( - RequestModel.name, - RequestModel.method, - RequestModel.updated_at, - ) - .where(RequestModel.collection_id.in_(all_ids)) - .order_by(RequestModel.updated_at.desc()) - .limit(limit) - ) - rows = session.execute(req_stmt).all() - return [ - { - "name": r.name, - "method": r.method, - "updated_at": r.updated_at, - } - for r in rows - ] diff --git a/src/services/__init__.py b/src/services/__init__.py index e69de29..669f0b5 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -0,0 +1,22 @@ +"""Service layer package. + +Re-exports the main service classes so LLMs and IDE users can +discover the full public API from a single file read:: + + from services import CollectionService, EnvironmentService +""" + +from __future__ import annotations + +from services.collection_service import CollectionService, RequestLoadDict +from services.environment_service import EnvironmentService, LocalOverride, VariableDetail +from services.import_service import ImportService + +__all__ = [ + "CollectionService", + "EnvironmentService", + "ImportService", + "LocalOverride", + "RequestLoadDict", + "VariableDetail", +] diff --git a/src/services/collection_service.py b/src/services/collection_service.py index 1175173..4abe9ac 100644 --- a/src/services/collection_service.py +++ b/src/services/collection_service.py @@ -7,14 +7,10 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, TypedDict -from database.models.collections.collection_repository import ( +from database.models.collections.collection_query_repository import ( count_collection_requests, - create_new_collection, - create_new_request, - delete_collection, - delete_request, fetch_all_collections, get_collection_breadcrumb, get_collection_by_id, @@ -24,6 +20,12 @@ get_request_by_id, get_request_variable_chain, get_saved_responses_for_request, +) +from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + delete_collection, + delete_request, rename_collection, rename_request, save_response, @@ -38,6 +40,27 @@ logger = logging.getLogger(__name__) +class RequestLoadDict(TypedDict, total=False): + """Data dict used to populate a :class:`RequestEditorWidget`. + + Built from :class:`RequestModel` attributes in + ``_TabControllerMixin._open_request``. All keys are optional + because callers may omit fields they don't need. + """ + + name: str + method: str + url: str + body: str | None + request_parameters: str | list[dict[str, Any]] | None + headers: str | list[dict[str, Any]] | None + description: str | None + scripts: dict[str, Any] | None + body_mode: str | None + body_options: dict[str, Any] | None + auth: dict[str, Any] | None + + class CollectionService: """Service that wraps repository calls with validation and logging. diff --git a/src/services/environment_service.py b/src/services/environment_service.py index 093b712..35ddd00 100644 --- a/src/services/environment_service.py +++ b/src/services/environment_service.py @@ -26,6 +26,23 @@ _VAR_PATTERN = re.compile(r"\{\{(.+?)\}\}") +def _patch_variable_in_list( + var_list: list[dict[str, Any]], + key: str, + value: str, +) -> bool: + """Set *key* to *value* in *var_list*, returning ``True`` if found. + + Mutates *var_list* in place. Returns ``False`` when no entry with + a matching ``key`` exists (caller should append instead). + """ + for entry in var_list: + if entry.get("key") == key: + entry["value"] = value + return True + return False + + class _VariableDetailRequired(TypedDict): """Required fields for variable metadata.""" @@ -168,7 +185,9 @@ def build_combined_variable_map( Returns an empty dict if neither source provides variables. """ - from database.models.collections.collection_repository import get_request_variable_chain + from database.models.collections.collection_query_repository import ( + get_request_variable_chain, + ) # 1. Collection-level variables (inherited up the tree) variables: dict[str, str] = {} @@ -193,7 +212,7 @@ def build_combined_variable_detail_map( whether it came from ``"collection"`` or ``"environment"``. Environment variables take precedence over collection variables. """ - from database.models.collections.collection_repository import ( + from database.models.collections.collection_query_repository import ( get_request_variable_chain_detailed, ) @@ -259,11 +278,7 @@ def _update_collection_variable( return variables = list(coll.variables or []) - for entry in variables: - if entry.get("key") == key: - entry["value"] = new_value - break - + _patch_variable_in_list(variables, key, new_value) update_collection(collection_id, variables=variables) logger.info( "Updated collection variable %r in collection id=%s", @@ -283,11 +298,7 @@ def _update_environment_variable( return values = list(env.values or []) - for entry in values: - if entry.get("key") == key: - entry["value"] = new_value - break - + _patch_variable_in_list(values, key, new_value) update_environment_values(environment_id, values) logger.info( "Updated environment variable %r in environment id=%s", @@ -355,14 +366,9 @@ def _add_collection_variable( return variables = list(coll.variables or []) - # Update existing or append new - for entry in variables: - if entry.get("key") == key: - entry["value"] = value - update_collection(collection_id, variables=variables) - return + if not _patch_variable_in_list(variables, key, value): + variables.append({"key": key, "value": value}) - variables.append({"key": key, "value": value}) update_collection(collection_id, variables=variables) logger.info( "Added collection variable %r to collection id=%s", @@ -382,14 +388,9 @@ def _add_environment_variable( return values = list(env.values or []) - # Update existing or append new - for entry in values: - if entry.get("key") == key: - entry["value"] = value - update_environment_values(environment_id, values) - return + if not _patch_variable_in_list(values, key, value): + values.append({"key": key, "value": value}) - values.append({"key": key, "value": value}) update_environment_values(environment_id, values) logger.info( "Added environment variable %r to environment id=%s", diff --git a/src/services/http/__init__.py b/src/services/http/__init__.py index 934faed..fe457d5 100644 --- a/src/services/http/__init__.py +++ b/src/services/http/__init__.py @@ -1,3 +1,16 @@ """HTTP request, response, and code-generation services.""" from __future__ import annotations + +from services.http.graphql_schema_service import GraphQLSchemaService +from services.http.header_utils import parse_header_dict +from services.http.http_service import HttpResponseDict, HttpService +from services.http.snippet_generator import SnippetGenerator + +__all__ = [ + "GraphQLSchemaService", + "HttpResponseDict", + "HttpService", + "SnippetGenerator", + "parse_header_dict", +] diff --git a/src/services/http/header_utils.py b/src/services/http/header_utils.py new file mode 100644 index 0000000..f505e2a --- /dev/null +++ b/src/services/http/header_utils.py @@ -0,0 +1,27 @@ +"""Shared header-parsing utilities for the HTTP service layer. + +Provides a single ``parse_header_dict`` function used by +:mod:`http_service`, :mod:`snippet_generator`, and the import parser. +""" + +from __future__ import annotations + + +def parse_header_dict(raw: str | None) -> dict[str, str]: + """Parse a newline-separated ``Key: Value`` header string into a mapping. + + Each line should contain at least one colon. Malformed lines + (no colon) are silently skipped. Leading/trailing whitespace on + both key and value is stripped. + + Returns an empty dict when *raw* is ``None`` or empty. + """ + if not raw: + return {} + + headers: dict[str, str] = {} + for line in raw.splitlines(): + if ":" in line: + key, _, value = line.partition(":") + headers[key.strip()] = value.strip() + return headers diff --git a/src/services/http/http_service.py b/src/services/http/http_service.py index 00bae9c..df7913e 100644 --- a/src/services/http/http_service.py +++ b/src/services/http/http_service.py @@ -21,6 +21,8 @@ import httpx +from services.http.header_utils import parse_header_dict + logger = logging.getLogger(__name__) # Default timeout in seconds for HTTP requests. @@ -88,23 +90,6 @@ class HttpResponseDict(TypedDict): network: NotRequired[NetworkDict] -def _build_headers(raw: str | None) -> dict[str, str]: - """Parse a newline-separated header string into a mapping. - - Each line should be ``Key: Value``. Malformed lines are silently - skipped. - """ - if not raw: - return {} - - headers: dict[str, str] = {} - for line in raw.splitlines(): - if ":" in line: - key, _, value = line.partition(":") - headers[key.strip()] = value.strip() - return headers - - def _phase_ms(trace_times: dict[str, float], *prefixes: str) -> float: """Compute the duration of a trace phase from start/complete timestamps. @@ -181,7 +166,7 @@ def send_request( An :class:`HttpResponseDict` with response details or an ``error`` key describing the failure. """ - parsed_headers = _build_headers(headers) + parsed_headers = parse_header_dict(headers) content: bytes | None = body.encode("utf-8") if body else None # -- 1. DNS pre-resolve ---------------------------------------- diff --git a/src/services/http/snippet_generator.py b/src/services/http/snippet_generator.py index a2ad250..346abf5 100644 --- a/src/services/http/snippet_generator.py +++ b/src/services/http/snippet_generator.py @@ -10,17 +10,7 @@ import json import shlex - -def _parse_headers(headers_text: str | None) -> dict[str, str]: - """Parse ``Key: Value`` header lines into a dict.""" - if not headers_text: - return {} - result: dict[str, str] = {} - for line in headers_text.splitlines(): - if ": " in line: - key, _, value = line.partition(": ") - result[key.strip()] = value.strip() - return result +from services.http.header_utils import parse_header_dict class SnippetGenerator: @@ -39,7 +29,7 @@ def curl( ) -> str: """Generate a cURL command.""" parts = ["curl", "-X", method.upper(), shlex.quote(url)] - for key, value in _parse_headers(headers).items(): + for key, value in parse_header_dict(headers).items(): parts.append("-H") parts.append(shlex.quote(f"{key}: {value}")) if body: @@ -57,7 +47,7 @@ def python_requests( ) -> str: """Generate a Python ``requests`` snippet.""" lines = ["import requests", ""] - hdr = _parse_headers(headers) + hdr = parse_header_dict(headers) if hdr: lines.append(f"headers = {json.dumps(hdr, indent=4)}") @@ -90,7 +80,7 @@ def javascript_fetch( body: str | None = None, ) -> str: """Generate a JavaScript ``fetch`` snippet.""" - hdr = _parse_headers(headers) + hdr = parse_header_dict(headers) opts: list[str] = [f' method: "{method.upper()}"'] if hdr: hdr_str = json.dumps(hdr, indent=4) diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index b55e6f4..dc88156 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -9,7 +9,7 @@ import logging from typing import TYPE_CHECKING -from services.collection_service import CollectionService +from services.collection_service import CollectionService, RequestLoadDict from ui.request.navigation.tab_manager import TabContext from ui.request.request_editor import RequestEditorWidget from ui.request.response_viewer import ResponseViewerWidget @@ -90,7 +90,7 @@ def _open_request( logger.warning("Request id=%s not found", request_id) return - data = { + data: RequestLoadDict = { "name": request.name, "method": request.method, "url": request.url, @@ -137,7 +137,7 @@ def _open_request( def _create_tab( self, request_id: int, - data: dict, + data: RequestLoadDict, *, is_preview: bool = False, ) -> int: @@ -185,7 +185,7 @@ def _replace_tab( self, index: int, request_id: int, - data: dict, + data: RequestLoadDict, *, is_preview: bool = False, ) -> None: diff --git a/src/ui/main_window/variable_controller.py b/src/ui/main_window/variable_controller.py index 01f59f8..154bc5b 100644 --- a/src/ui/main_window/variable_controller.py +++ b/src/ui/main_window/variable_controller.py @@ -152,7 +152,7 @@ def _on_add_unresolved_variable( request_id = ctx.request_id if ctx else None if request_id is None: return - from database.models.collections.collection_repository import get_request_by_id + from database.models.collections.collection_query_repository import get_request_by_id req = get_request_by_id(request_id) if req is None: diff --git a/src/ui/request/request_editor/editor_widget.py b/src/ui/request/request_editor/editor_widget.py index 083a196..82159ff 100644 --- a/src/ui/request/request_editor/editor_widget.py +++ b/src/ui/request/request_editor/editor_widget.py @@ -37,6 +37,7 @@ from ui.widgets.variable_line_edit import VariableLineEdit if TYPE_CHECKING: + from services.collection_service import RequestLoadDict from services.environment_service import VariableDetail # HTTP methods shown in the dropdown @@ -245,7 +246,7 @@ def set_variable_map(self, variables: dict[str, VariableDetail]) -> None: finally: self._loading = False - def load_request(self, data: dict, *, request_id: int | None = None) -> None: + def load_request(self, data: RequestLoadDict, *, request_id: int | None = None) -> None: """Populate the editor from a request data dict. Expected keys: ``name``, ``method``, ``url``, and optionally @@ -296,7 +297,7 @@ def load_request(self, data: dict, *, request_id: int | None = None) -> None: self._loading = False self._sync_tab_indicators() - def _load_body_content(self, data: dict, body_mode: str) -> None: + def _load_body_content(self, data: RequestLoadDict, body_mode: str) -> None: """Load body content into the correct widget for *body_mode*.""" body = data.get("body") or "" if body_mode in ("form-data", "x-www-form-urlencoded"): diff --git a/tests/conftest.py b/tests/conftest.py index 765dba9..5661c5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,3 +62,35 @@ def _fresh_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # Cleanup: reset state for the next test db_mod._engine = None db_mod._SessionLocal = None + + +# ------------------------------------------------------------------ +# Collection + request factory (convenience for UI & service tests) +# ------------------------------------------------------------------ +@pytest.fixture() +def make_collection_with_request(): + """Factory that creates a persisted collection with one request. + + Returns ``(collection, request)`` — both are detached model snapshots. + + Usage:: + + coll, req = make_collection_with_request() + coll, req = make_collection_with_request( + name="MyColl", method="POST", url="http://x", req_name="R", + ) + """ + from services.collection_service import CollectionService + + def _make( + name: str = "Coll", + method: str = "GET", + url: str = "http://x", + req_name: str = "Req", + ): + svc = CollectionService() + coll = svc.create_collection(name) + req = svc.create_request(coll.id, method, url, req_name) + return coll, req + + return _make diff --git a/tests/ui/request/conftest.py b/tests/ui/request/conftest.py new file mode 100644 index 0000000..bafe06d --- /dev/null +++ b/tests/ui/request/conftest.py @@ -0,0 +1,29 @@ +"""Shared fixtures for request and response editor UI tests.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from services.collection_service import RequestLoadDict + + +@pytest.fixture() +def make_request_dict(): + """Factory that builds a :class:`RequestLoadDict` with sane defaults. + + Override any key via keyword argument:: + + data = make_request_dict(method="POST", body='{"x": 1}') + """ + + def _make(**overrides: Any) -> RequestLoadDict: + base: RequestLoadDict = { + "name": "X", + "method": "GET", + "url": "http://x", + } + return {**base, **overrides} # type: ignore[typeddict-item] + + return _make diff --git a/tests/ui/request/test_request_editor_search.py b/tests/ui/request/test_request_editor_search.py index 34359ab..018a727 100644 --- a/tests/ui/request/test_request_editor_search.py +++ b/tests/ui/request/test_request_editor_search.py @@ -23,7 +23,6 @@ def _make_editor_with_raw_body( "method": "POST", "url": "http://example.com", "body_mode": "raw", - "raw_type": fmt, "body": body, } ) @@ -139,7 +138,6 @@ def _make_editor_with_raw_body( "method": "POST", "url": "http://example.com", "body_mode": "raw", - "raw_type": fmt, "body": body, } ) diff --git a/tests/unit/database/test_repository.py b/tests/unit/database/test_repository.py index 7957ae0..7d9df19 100644 --- a/tests/unit/database/test_repository.py +++ b/tests/unit/database/test_repository.py @@ -4,12 +4,8 @@ import pytest -from database.models.collections.collection_repository import ( +from database.models.collections.collection_query_repository import ( count_collection_requests, - create_new_collection, - create_new_request, - delete_collection, - delete_request, fetch_all_collections, get_collection_breadcrumb, get_collection_by_id, @@ -17,6 +13,12 @@ get_request_by_id, get_request_variable_chain, get_request_variable_chain_detailed, +) +from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + delete_collection, + delete_request, rename_collection, rename_request, update_collection, diff --git a/tests/unit/services/http/test_http_service.py b/tests/unit/services/http/test_http_service.py index 08f0920..8d6ae43 100644 --- a/tests/unit/services/http/test_http_service.py +++ b/tests/unit/services/http/test_http_service.py @@ -7,41 +7,42 @@ import httpx -from services.http.http_service import HttpService, _build_headers, _phase_ms +from services.http.header_utils import parse_header_dict +from services.http.http_service import HttpService, _phase_ms -class TestBuildHeaders: +class TestParseHeaderDict: """Tests for the header parsing helper.""" def test_empty_string(self) -> None: """Empty string yields an empty dict.""" - assert _build_headers("") == {} + assert parse_header_dict("") == {} def test_none_input(self) -> None: """None input yields an empty dict.""" - assert _build_headers(None) == {} + assert parse_header_dict(None) == {} def test_single_header(self) -> None: """Single well-formed header is parsed correctly.""" - result = _build_headers("Content-Type: application/json") + result = parse_header_dict("Content-Type: application/json") assert result == {"Content-Type": "application/json"} def test_multiple_headers(self) -> None: """Multiple newline-separated headers are all parsed.""" raw = "Accept: text/html\nAuthorization: Bearer abc123" - result = _build_headers(raw) + result = parse_header_dict(raw) assert result == {"Accept": "text/html", "Authorization": "Bearer abc123"} def test_malformed_line_skipped(self) -> None: """Lines without a colon are silently skipped.""" raw = "Good: value\nbadline\nAlso-Good: ok" - result = _build_headers(raw) + result = parse_header_dict(raw) assert result == {"Good": "value", "Also-Good": "ok"} def test_value_with_colon(self) -> None: """Only the first colon splits key from value.""" raw = "Authorization: Bearer token:with:colons" - result = _build_headers(raw) + result = parse_header_dict(raw) assert result == {"Authorization": "Bearer token:with:colons"} diff --git a/tests/unit/services/test_import_service.py b/tests/unit/services/test_import_service.py index 54ca452..f95ab45 100644 --- a/tests/unit/services/test_import_service.py +++ b/tests/unit/services/test_import_service.py @@ -5,7 +5,7 @@ import json from pathlib import Path -from database.models.collections.collection_repository import fetch_all_collections +from database.models.collections.collection_query_repository import fetch_all_collections from database.models.environments.environment_repository import fetch_all_environments from services.import_service import ImportService