From f586f0454669e4e2ddc90f220f49f890459f47f3 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 10:27:35 -0500 Subject: [PATCH 1/7] chore: bump version to 2.24.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dec8bda..896c6d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ofsc" -version = "2.24.2" +version = "2.24.3" description = "Python wrapper for Oracle Field Service API" authors = [{ name = "Borja Toron", email = "borja.toron@gmail.com" }] requires-python = ">=3.13" diff --git a/uv.lock b/uv.lock index d1e0a63..056600e 100644 --- a/uv.lock +++ b/uv.lock @@ -424,7 +424,7 @@ wheels = [ [[package]] name = "ofsc" -version = "2.24.2" +version = "2.24.3" source = { editable = "." } dependencies = [ { name = "cachetools" }, From 57d5d2914c391d8e8deff2b3ad103036c8094505 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 12:06:49 -0500 Subject: [PATCH 2/7] chore: add docs/patterns to .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 77057ec..b363bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,5 @@ tests/saved_responses plans tmp docs/qa.md -tests/**/audit_results \ No newline at end of file +tests/**/audit_results +docs/patterns \ No newline at end of file From a8a89a7ca2e7e42703cc651ef83046e03f29165a Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 13:14:07 -0500 Subject: [PATCH 3/7] refactor: extract AsyncClientBase to eliminate cross-module duplication (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create ofsc/async_client/_base.py with AsyncClientBase and ofsc/async_client/_protocols.py with shared _CoreBaseProtocol. All 5 async modules now inherit from AsyncClientBase, removing ~400 lines of duplicated __init__, config, baseUrl, headers, _parse_error_response, and _handle_http_error boilerplate. core/resources.py and core/users.py import _CoreBaseProtocol from the shared _protocols.py instead of defining it locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- docs/ENDPOINTS.md | 16 ++-- ofsc/async_client/_base.py | 144 ++++++++++++++++++++++++++++ ofsc/async_client/_protocols.py | 19 ++++ ofsc/async_client/capacity.py | 110 +-------------------- ofsc/async_client/core/_base.py | 135 +------------------------- ofsc/async_client/core/resources.py | 15 +-- ofsc/async_client/core/users.py | 12 +-- ofsc/async_client/metadata.py | 139 +-------------------------- ofsc/async_client/oauth.py | 79 +-------------- ofsc/async_client/statistics.py | 110 +-------------------- 10 files changed, 190 insertions(+), 589 deletions(-) create mode 100644 ofsc/async_client/_base.py create mode 100644 ofsc/async_client/_protocols.py diff --git a/docs/ENDPOINTS.md b/docs/ENDPOINTS.md index fbdb6e6..e6888ba 100644 --- a/docs/ENDPOINTS.md +++ b/docs/ENDPOINTS.md @@ -1,7 +1,7 @@ # OFSC API Endpoints Reference -**Version:** 2.24.0 -**Last Updated:** 2026-03-04 +**Version:** 2.24.3 +**Last Updated:** 2026-03-05 This document provides a comprehensive reference of all Oracle Field Service Cloud (OFSC) API endpoints and their implementation status in pyOFSC. @@ -54,7 +54,7 @@ This document provides a comprehensive reference of all Oracle Field Service Clo |ME025G|`/rest/ofscMetadata/v1/languages` |metadata |GET |async | |ME026G|`/rest/ofscMetadata/v1/linkTemplates` |metadata |GET |async | |ME027G|`/rest/ofscMetadata/v1/linkTemplates/{label}` |metadata |GET |async | -|ME027P|`/rest/ofscMetadata/v1/linkTemplates/{label}` |metadata |POST |async | +|ME027P|`/rest/ofscMetadata/v1/linkTemplates/{label}` |metadata |POST |- | |ME027A|`/rest/ofscMetadata/v1/linkTemplates/{label}` |metadata |PATCH |async | |ME028G|`/rest/ofscMetadata/v1/mapLayers` |metadata |GET |async | |ME028P|`/rest/ofscMetadata/v1/mapLayers` |metadata |POST |async | @@ -267,11 +267,11 @@ This document provides a comprehensive reference of all Oracle Field Service Clo ## Implementation Summary - **Sync only**: 4 endpoints -- **Async only**: 110 endpoints +- **Async only**: 109 endpoints - **Both**: 85 endpoints -- **Not implemented**: 44 endpoints +- **Not implemented**: 45 endpoints - **Total sync**: 89 endpoints -- **Total async**: 195 endpoints +- **Total async**: 194 endpoints ## Implementation Statistics by Module and Method @@ -292,14 +292,14 @@ This document provides a comprehensive reference of all Oracle Field Service Clo | Module | GET |Write (POST/PUT/PATCH)| DELETE | Total | |-------------|-------------------|----------------------|-----------------|-------------------| -|metadata |51/51 (100.0%) |29/30 (96.7%) |5/5 (100.0%) |85/86 (98.8%) | +|metadata |51/51 (100.0%) |28/30 (93.3%) |5/5 (100.0%) |84/86 (97.7%) | |core |44/51 (86.3%) |31/56 (55.4%) |18/20 (90.0%) |93/127 (73.2%) | |capacity |6/7 (85.7%) |4/5 (80.0%) |0/0 (0%) |10/12 (83.3%) | |statistics |3/3 (100.0%) |3/3 (100.0%) |0/0 (0%) |6/6 (100.0%) | |partscatalog |0/0 (0%) |0/2 (0.0%) |0/1 (0.0%) |0/3 (0.0%) | |collaboration|0/3 (0.0%) |0/4 (0.0%) |0/0 (0%) |0/7 (0.0%) | |auth |0/0 (0%) |1/2 (50.0%) |0/0 (0%) |1/2 (50.0%) | -|**Total** |**104/115 (90.4%)**|**68/102 (66.7%)** |**23/26 (88.5%)**|**195/243 (80.2%)**| +|**Total** |**104/115 (90.4%)**|**67/102 (65.7%)** |**23/26 (88.5%)**|**194/243 (79.8%)**| ## Endpoint ID Reference diff --git a/ofsc/async_client/_base.py b/ofsc/async_client/_base.py new file mode 100644 index 0000000..80888b5 --- /dev/null +++ b/ofsc/async_client/_base.py @@ -0,0 +1,144 @@ +"""Shared base class for all async OFSC API modules.""" + +import httpx + +from ..exceptions import ( + OFSCApiError, + OFSCAuthenticationError, + OFSCAuthorizationError, + OFSCConflictError, + OFSCNotFoundError, + OFSCRateLimitError, + OFSCServerError, + OFSCValidationError, +) +from ..models import OFSConfig + + +class AsyncClientBase: + """Base class for all async API modules. + + Provides shared infrastructure: config access, URL construction, + auth headers, and HTTP error handling. + """ + + def __init__(self, config: OFSConfig, client: httpx.AsyncClient): + self._config = config + self._client = client + + @property + def config(self) -> OFSConfig: + return self._config + + @property + def baseUrl(self) -> str: + if self._config.baseURL is None: + raise ValueError("Base URL is not configured") + return self._config.baseURL + + @property + def headers(self) -> dict: + """Build authorization headers.""" + headers = {"Content-Type": "application/json;charset=UTF-8"} + if not self._config.useToken: + headers["Authorization"] = "Basic " + self._config.basicAuthString.decode("utf-8") + else: + if self._config.access_token is None: + raise ValueError("access_token required when useToken=True") + headers["Authorization"] = f"Bearer {self._config.access_token}" + return headers + + def _parse_error_response(self, response: httpx.Response) -> dict: + """Parse OFSC error response format. + + OFSC API returns errors in the format: + { + "type": "string", + "title": "string", + "detail": "string" + } + + :param response: The httpx Response object + :type response: httpx.Response + :return: Error information with type, title, and detail keys + :rtype: dict + """ + try: + error_data = response.json() + return { + "type": error_data.get("type", "about:blank"), + "title": error_data.get("title", ""), + "detail": error_data.get("detail", response.text), + } + except Exception: + return { + "type": "about:blank", + "title": f"HTTP {response.status_code}", + "detail": response.text, + } + + def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: + """Convert httpx exceptions to OFSC exceptions with error details. + + :param e: The httpx HTTPStatusError exception + :type e: httpx.HTTPStatusError + :param context: Additional context for the error message + :type context: str + :raises OFSCAuthenticationError: For 401 errors + :raises OFSCAuthorizationError: For 403 errors + :raises OFSCNotFoundError: For 404 errors + :raises OFSCConflictError: For 409 errors + :raises OFSCRateLimitError: For 429 errors + :raises OFSCValidationError: For 400, 422 errors + :raises OFSCServerError: For 5xx errors + :raises OFSCApiError: For other HTTP errors + """ + status = e.response.status_code + error_info = self._parse_error_response(e.response) + + message = f"{context}: {error_info['detail']}" if context else error_info["detail"] + + error_map = { + 401: OFSCAuthenticationError, + 403: OFSCAuthorizationError, + 404: OFSCNotFoundError, + 409: OFSCConflictError, + 429: OFSCRateLimitError, + } + + if status in error_map: + raise error_map[status]( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e + elif 400 <= status < 500: + raise OFSCValidationError( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e + elif 500 <= status < 600: + raise OFSCServerError( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e + else: + raise OFSCApiError( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e diff --git a/ofsc/async_client/_protocols.py b/ofsc/async_client/_protocols.py new file mode 100644 index 0000000..1d24346 --- /dev/null +++ b/ofsc/async_client/_protocols.py @@ -0,0 +1,19 @@ +"""Shared Protocol type stubs for async client mixins.""" + +from typing import Protocol + +import httpx + + +class _CoreBaseProtocol(Protocol): + """Type stub declaring what async core mixins expect from their base class.""" + + _client: httpx.AsyncClient + + @property + def baseUrl(self) -> str: ... + + @property + def headers(self) -> dict: ... + + def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: ... diff --git a/ofsc/async_client/capacity.py b/ofsc/async_client/capacity.py index a5d779c..168f1ff 100644 --- a/ofsc/async_client/capacity.py +++ b/ofsc/async_client/capacity.py @@ -5,17 +5,8 @@ import httpx -from ..exceptions import ( - OFSCApiError, - OFSCAuthenticationError, - OFSCAuthorizationError, - OFSCConflictError, - OFSCNetworkError, - OFSCNotFoundError, - OFSCRateLimitError, - OFSCServerError, - OFSCValidationError, -) +from ..exceptions import OFSCNetworkError +from ._base import AsyncClientBase from ..models import ( ActivityBookingOptionsResponse, BookingClosingScheduleResponse, @@ -28,7 +19,6 @@ GetCapacityResponse, GetQuotaRequest, GetQuotaResponse, - OFSConfig, QuotaUpdateRequest, QuotaUpdateResponse, ShowBookingGridRequest, @@ -37,103 +27,9 @@ from ..capacity import _convert_model_to_api_params -class AsyncOFSCapacity: +class AsyncOFSCapacity(AsyncClientBase): """Async version of OFSCapacity API module.""" - def __init__(self, config: OFSConfig, client: httpx.AsyncClient): - self._config = config - self._client = client - - @property - def config(self) -> OFSConfig: - return self._config - - @property - def baseUrl(self) -> str: - if self._config.baseURL is None: - raise ValueError("Base URL is not configured") - return self._config.baseURL - - @property - def headers(self) -> dict: - """Build authorization headers.""" - headers = {"Content-Type": "application/json;charset=UTF-8"} - if not self._config.useToken: - headers["Authorization"] = "Basic " + self._config.basicAuthString.decode("utf-8") - else: - if self._config.access_token is None: - raise ValueError("access_token required when useToken=True") - headers["Authorization"] = f"Bearer {self._config.access_token}" - return headers - - def _parse_error_response(self, response: httpx.Response) -> dict: - """Parse OFSC error response format.""" - try: - error_data = response.json() - return { - "type": error_data.get("type", "about:blank"), - "title": error_data.get("title", ""), - "detail": error_data.get("detail", response.text), - } - except Exception: - return { - "type": "about:blank", - "title": f"HTTP {response.status_code}", - "detail": response.text, - } - - def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: - """Convert httpx exceptions to OFSC exceptions with error details.""" - status = e.response.status_code - error_info = self._parse_error_response(e.response) - - message = f"{context}: {error_info['detail']}" if context else error_info["detail"] - - error_map = { - 401: OFSCAuthenticationError, - 403: OFSCAuthorizationError, - 404: OFSCNotFoundError, - 409: OFSCConflictError, - 429: OFSCRateLimitError, - } - - if status in error_map: - raise error_map[status]( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 400 <= status < 500: - raise OFSCValidationError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 500 <= status < 600: - raise OFSCServerError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - else: - raise OFSCApiError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - # region Available Capacity async def get_available_capacity( diff --git a/ofsc/async_client/core/_base.py b/ofsc/async_client/core/_base.py index 53385e8..3e81946 100644 --- a/ofsc/async_client/core/_base.py +++ b/ofsc/async_client/core/_base.py @@ -7,16 +7,9 @@ import httpx from ...exceptions import ( - OFSCApiError, - OFSCAuthenticationError, - OFSCAuthorizationError, - OFSCConflictError, OFSCNetworkError, - OFSCNotFoundError, - OFSCRateLimitError, - OFSCServerError, - OFSCValidationError, ) +from .._base import AsyncClientBase from ...models import ( Activity, ActivityCapacityCategoriesResponse, @@ -32,7 +25,6 @@ InventoryListResponse, LinkedActivitiesResponse, LinkedActivity, - OFSConfig, OFSResponseList, RequiredInventoriesResponse, RequiredInventory, @@ -44,132 +36,9 @@ ) -class _AsyncOFSCoreBase: +class _AsyncOFSCoreBase(AsyncClientBase): """Base class for AsyncOFSCore - all non-user methods.""" - def __init__(self, config: OFSConfig, client: httpx.AsyncClient): - self._config = config - self._client = client - - @property - def config(self) -> OFSConfig: - return self._config - - @property - def baseUrl(self) -> str: - if self._config.baseURL is None: - raise ValueError("Base URL is not configured") - return self._config.baseURL - - @property - def headers(self) -> dict: - """Build authorization headers.""" - headers = {"Content-Type": "application/json;charset=UTF-8"} - if not self._config.useToken: - headers["Authorization"] = "Basic " + self._config.basicAuthString.decode("utf-8") - else: - if self._config.access_token is None: - raise ValueError("access_token required when useToken=True") - headers["Authorization"] = f"Bearer {self._config.access_token}" - return headers - - def _parse_error_response(self, response: httpx.Response) -> dict: - """Parse OFSC error response format. - - OFSC API returns errors in the format: - { - "type": "string", - "title": "string", - "detail": "string" - } - - :param response: The httpx Response object - :type response: httpx.Response - :return: Error information with type, title, and detail keys - :rtype: dict - """ - try: - error_data = response.json() - return { - "type": error_data.get("type", "about:blank"), - "title": error_data.get("title", ""), - "detail": error_data.get("detail", response.text), - } - except Exception: - # If response is not JSON or doesn't match format - return { - "type": "about:blank", - "title": f"HTTP {response.status_code}", - "detail": response.text, - } - - def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: - """Convert httpx exceptions to OFSC exceptions with error details. - - :param e: The httpx HTTPStatusError exception - :type e: httpx.HTTPStatusError - :param context: Additional context for the error message - :type context: str - :raises OFSCAuthenticationError: For 401 errors - :raises OFSCAuthorizationError: For 403 errors - :raises OFSCNotFoundError: For 404 errors - :raises OFSCConflictError: For 409 errors - :raises OFSCRateLimitError: For 429 errors - :raises OFSCValidationError: For 400, 422 errors - :raises OFSCServerError: For 5xx errors - :raises OFSCApiError: For other HTTP errors - """ - status = e.response.status_code - error_info = self._parse_error_response(e.response) - - # Build message with detail - message = f"{context}: {error_info['detail']}" if context else error_info["detail"] - - error_map = { - 401: OFSCAuthenticationError, - 403: OFSCAuthorizationError, - 404: OFSCNotFoundError, - 409: OFSCConflictError, - 429: OFSCRateLimitError, - } - - if status in error_map: - raise error_map[status]( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 400 <= status < 500: - raise OFSCValidationError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 500 <= status < 600: - raise OFSCServerError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - else: - raise OFSCApiError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - # region Activities async def get_activities(self, params: GetActivitiesParams | dict, offset: int = 0, limit: int = 100) -> ActivityListResponse: diff --git a/ofsc/async_client/core/resources.py b/ofsc/async_client/core/resources.py index f6b1388..4f03b23 100644 --- a/ofsc/async_client/core/resources.py +++ b/ofsc/async_client/core/resources.py @@ -7,6 +7,7 @@ import httpx from ...exceptions import OFSCNetworkError +from .._protocols import _CoreBaseProtocol as _SharedCoreProtocol from ...models import Inventory, InventoryListResponse from ...models.resources import ( AssignedLocationsResponse, @@ -31,18 +32,8 @@ ) -class _CoreBaseProtocol(Protocol): - """Type stub declaring what AsyncOFSCoreResourcesMixin expects from its base class.""" - - _client: httpx.AsyncClient - - @property - def baseUrl(self) -> str: ... - - @property - def headers(self) -> dict: ... - - def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: ... +class _CoreBaseProtocol(_SharedCoreProtocol, Protocol): + """Type stub for AsyncOFSCoreResourcesMixin — extends shared protocol with resource helpers.""" def _build_expand_param( self, diff --git a/ofsc/async_client/core/users.py b/ofsc/async_client/core/users.py index 0276f15..54f162d 100644 --- a/ofsc/async_client/core/users.py +++ b/ofsc/async_client/core/users.py @@ -1,6 +1,5 @@ """Async user methods mixin for OFSCore API.""" -from typing import Protocol from urllib.parse import quote_plus, urljoin import httpx @@ -8,6 +7,7 @@ from ...exceptions import ( OFSCNetworkError, ) +from .._protocols import _CoreBaseProtocol from ...models.users import ( CollaborationGroupsResponse, User, @@ -16,16 +16,6 @@ ) -class _CoreBaseProtocol(Protocol): - """Type stub declaring what AsyncOFSCoreUsersMixin expects from its base class.""" - - _client: httpx.AsyncClient - baseUrl: str - headers: dict - - def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: ... - - class AsyncOFSCoreUsersMixin: """Mixin providing async user-related methods for AsyncOFSCore. diff --git a/ofsc/async_client/metadata.py b/ofsc/async_client/metadata.py index 1617f13..cb46762 100644 --- a/ofsc/async_client/metadata.py +++ b/ofsc/async_client/metadata.py @@ -6,17 +6,8 @@ import httpx -from ..exceptions import ( - OFSCApiError, - OFSCAuthenticationError, - OFSCAuthorizationError, - OFSCConflictError, - OFSCNetworkError, - OFSCNotFoundError, - OFSCRateLimitError, - OFSCServerError, - OFSCValidationError, -) +from ..exceptions import OFSCNetworkError +from ._base import AsyncClientBase from ..models import ( ActivityType, ActivityTypeGroup, @@ -52,7 +43,6 @@ PopulateStatusResponse, NonWorkingReason, NonWorkingReasonListResponse, - OFSConfig, Organization, OrganizationListResponse, Property, @@ -77,132 +67,9 @@ ) -class AsyncOFSMetadata: +class AsyncOFSMetadata(AsyncClientBase): """Async version of OFSMetadata API module.""" - def __init__(self, config: OFSConfig, client: httpx.AsyncClient): - self._config = config - self._client = client - - @property - def config(self) -> OFSConfig: - return self._config - - @property - def baseUrl(self) -> str: - if self._config.baseURL is None: - raise ValueError("Base URL is not configured") - return self._config.baseURL - - @property - def headers(self) -> dict: - """Build authorization headers.""" - headers = {"Content-Type": "application/json;charset=UTF-8"} - if not self._config.useToken: - headers["Authorization"] = "Basic " + self._config.basicAuthString.decode("utf-8") - else: - if self._config.access_token is None: - raise ValueError("access_token required when useToken=True") - headers["Authorization"] = f"Bearer {self._config.access_token}" - return headers - - def _parse_error_response(self, response: httpx.Response) -> dict: - """Parse OFSC error response format. - - OFSC API returns errors in the format: - { - "type": "string", - "title": "string", - "detail": "string" - } - - :param response: The httpx Response object - :type response: httpx.Response - :return: Error information with type, title, and detail keys - :rtype: dict - """ - try: - error_data = response.json() - return { - "type": error_data.get("type", "about:blank"), - "title": error_data.get("title", ""), - "detail": error_data.get("detail", response.text), - } - except Exception: - # If response is not JSON or doesn't match format - return { - "type": "about:blank", - "title": f"HTTP {response.status_code}", - "detail": response.text, - } - - def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: - """Convert httpx exceptions to OFSC exceptions with error details. - - :param e: The httpx HTTPStatusError exception - :type e: httpx.HTTPStatusError - :param context: Additional context for the error message - :type context: str - :raises OFSCAuthenticationError: For 401 errors - :raises OFSCAuthorizationError: For 403 errors - :raises OFSCNotFoundError: For 404 errors - :raises OFSCConflictError: For 409 errors - :raises OFSCRateLimitError: For 429 errors - :raises OFSCValidationError: For 400, 422 errors - :raises OFSCServerError: For 5xx errors - :raises OFSCApiError: For other HTTP errors - """ - status = e.response.status_code - error_info = self._parse_error_response(e.response) - - # Build message with detail - message = f"{context}: {error_info['detail']}" if context else error_info["detail"] - - error_map = { - 401: OFSCAuthenticationError, - 403: OFSCAuthorizationError, - 404: OFSCNotFoundError, - 409: OFSCConflictError, - 429: OFSCRateLimitError, - } - - if status in error_map: - raise error_map[status]( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 400 <= status < 500: - raise OFSCValidationError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 500 <= status < 600: - raise OFSCServerError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - else: - raise OFSCApiError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - # region Activity Type Groups async def get_activity_type_groups(self, offset: int = 0, limit: int = 100) -> ActivityTypeGroupListResponse: diff --git a/ofsc/async_client/oauth.py b/ofsc/async_client/oauth.py index 45354e0..28ad6c8 100644 --- a/ofsc/async_client/oauth.py +++ b/ofsc/async_client/oauth.py @@ -4,36 +4,14 @@ import httpx -from ..exceptions import ( - OFSCAuthenticationError, - OFSCAuthorizationError, - OFSCConflictError, - OFSCNetworkError, - OFSCNotFoundError, - OFSCRateLimitError, - OFSCServerError, - OFSCValidationError, -) -from ..models import OAuthTokenResponse, OFSConfig, OFSOAuthRequest +from ..exceptions import OFSCNetworkError +from ._base import AsyncClientBase +from ..models import OAuthTokenResponse, OFSOAuthRequest -class AsyncOFSOauth2: +class AsyncOFSOauth2(AsyncClientBase): """Async version of OFSOauth2 API module.""" - def __init__(self, config: OFSConfig, client: httpx.AsyncClient): - self._config = config - self._client = client - - @property - def config(self) -> OFSConfig: - return self._config - - @property - def baseUrl(self) -> str: - if self._config.baseURL is None: - raise ValueError("Base URL is not configured") - return self._config.baseURL - @property def _auth_headers(self) -> dict: """Build Basic auth headers for token requests (always Basic, never Bearer).""" @@ -42,55 +20,6 @@ def _auth_headers(self) -> dict: "Authorization": "Basic " + self._config.basicAuthString.decode("utf-8"), } - def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: - """Convert httpx exceptions to OFSC exceptions with error details.""" - status = e.response.status_code - try: - error_data = e.response.json() - detail = error_data.get("detail", e.response.text) - error_type = error_data.get("type", "about:blank") - title = error_data.get("title", "") - except Exception: - detail = e.response.text - error_type = "about:blank" - title = f"HTTP {status}" - - message = f"{context}: {detail}" if context else detail - error_map = { - 401: OFSCAuthenticationError, - 403: OFSCAuthorizationError, - 404: OFSCNotFoundError, - 409: OFSCConflictError, - 429: OFSCRateLimitError, - } - if status in error_map: - raise error_map[status]( - message, - status_code=status, - response=e.response, - error_type=error_type, - title=title, - detail=detail, - ) from e - elif 400 <= status < 500: - raise OFSCValidationError( - message, - status_code=status, - response=e.response, - error_type=error_type, - title=title, - detail=detail, - ) from e - else: - raise OFSCServerError( - message, - status_code=status, - response=e.response, - error_type=error_type, - title=title, - detail=detail, - ) from e - async def get_token(self, request: OFSOAuthRequest = OFSOAuthRequest()) -> OAuthTokenResponse: """Get OAuth access token via v2 endpoint (AU002P). diff --git a/ofsc/async_client/statistics.py b/ofsc/async_client/statistics.py index a4a1c03..626dcae 100644 --- a/ofsc/async_client/statistics.py +++ b/ofsc/async_client/statistics.py @@ -5,17 +5,8 @@ import httpx -from ..exceptions import ( - OFSCApiError, - OFSCAuthenticationError, - OFSCAuthorizationError, - OFSCConflictError, - OFSCNetworkError, - OFSCNotFoundError, - OFSCRateLimitError, - OFSCServerError, - OFSCValidationError, -) +from ..exceptions import OFSCNetworkError +from ._base import AsyncClientBase from ..models import ( ActivityDurationStatRequestList, ActivityDurationStatsList, @@ -23,108 +14,13 @@ ActivityTravelStatsList, AirlineDistanceBasedTravelList, AirlineDistanceBasedTravelRequestList, - OFSConfig, StatisticsPatchResponse, ) -class AsyncOFSStatistics: +class AsyncOFSStatistics(AsyncClientBase): """Async version of OFSC Statistics API module.""" - def __init__(self, config: OFSConfig, client: httpx.AsyncClient): - self._config = config - self._client = client - - @property - def config(self) -> OFSConfig: - return self._config - - @property - def baseUrl(self) -> str: - if self._config.baseURL is None: - raise ValueError("Base URL is not configured") - return self._config.baseURL - - @property - def headers(self) -> dict: - """Build authorization headers.""" - headers = {"Content-Type": "application/json;charset=UTF-8"} - if not self._config.useToken: - headers["Authorization"] = "Basic " + self._config.basicAuthString.decode("utf-8") - else: - if self._config.access_token is None: - raise ValueError("access_token required when useToken=True") - headers["Authorization"] = f"Bearer {self._config.access_token}" - return headers - - def _parse_error_response(self, response: httpx.Response) -> dict: - """Parse OFSC error response format.""" - try: - error_data = response.json() - return { - "type": error_data.get("type", "about:blank"), - "title": error_data.get("title", ""), - "detail": error_data.get("detail", response.text), - } - except Exception: - return { - "type": "about:blank", - "title": f"HTTP {response.status_code}", - "detail": response.text, - } - - def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: - """Convert httpx exceptions to OFSC exceptions with error details.""" - status = e.response.status_code - error_info = self._parse_error_response(e.response) - - message = f"{context}: {error_info['detail']}" if context else error_info["detail"] - - error_map = { - 401: OFSCAuthenticationError, - 403: OFSCAuthorizationError, - 404: OFSCNotFoundError, - 409: OFSCConflictError, - 429: OFSCRateLimitError, - } - - if status in error_map: - raise error_map[status]( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 400 <= status < 500: - raise OFSCValidationError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - elif 500 <= status < 600: - raise OFSCServerError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - else: - raise OFSCApiError( - message, - status_code=status, - response=e.response, - error_type=error_info["type"], - title=error_info["title"], - detail=error_info["detail"], - ) from e - # region Activity Duration Stats async def get_activity_duration_stats( From cbecdc1a9d23c87eea6015e3041b54845e2aecdf Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 13:38:33 -0500 Subject: [PATCH 4/7] test: add tests for ResourceCreate to validate extra fields handling --- ofsc/models/resources.py | 2 + tests/async/test_async_resources_write.py | 74 +++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/ofsc/models/resources.py b/ofsc/models/resources.py index 31639a4..939fbae 100644 --- a/ofsc/models/resources.py +++ b/ofsc/models/resources.py @@ -36,6 +36,8 @@ class Resource(BaseModel): class ResourceCreate(BaseModel): """Resource creation model enforcing required fields.""" + model_config = ConfigDict(extra="allow") + parentResourceId: str resourceType: str name: str diff --git a/tests/async/test_async_resources_write.py b/tests/async/test_async_resources_write.py index 211a545..ec6ac2d 100644 --- a/tests/async/test_async_resources_write.py +++ b/tests/async/test_async_resources_write.py @@ -942,3 +942,77 @@ async def test_set_get_delete_roundtrip(self, async_instance: AsyncOFSC, resourc await async_instance.core.delete_resource_file_property(resource_id, resource_file_property_label) except Exception: pass + + +# --------------------------------------------------------------------------- +# ResourceCreate extra fields (custom properties) +# --------------------------------------------------------------------------- + + +class TestResourceCreateExtraFields: + """Tests that ResourceCreate preserves custom properties via extra='allow'.""" + + def test_resource_create_extra_fields_preserved_in_dump(self): + """ResourceCreate.model_dump() includes extra custom properties.""" + data = { + "parentResourceId": "BUCKET", + "resourceType": "BK", + "name": "Test", + "language": "en", + "timeZone": "US/Eastern", + "XA_CUSTOM_FIELD": "custom_value", + "XA_ANOTHER": "another_value", + } + rc = ResourceCreate.model_validate(data) + dumped = rc.model_dump(exclude_none=True) + assert dumped["XA_CUSTOM_FIELD"] == "custom_value" + assert dumped["XA_ANOTHER"] == "another_value" + + def test_resource_create_extra_fields_not_dropped(self): + """ResourceCreate constructed directly preserves extra fields.""" + rc = ResourceCreate( + parentResourceId="BUCKET", + resourceType="BK", + name="Test", + language="en", + timeZone="US/Eastern", + XA_MY_PROP="hello", + ) + dumped = rc.model_dump(exclude_none=True) + assert "XA_MY_PROP" in dumped + assert dumped["XA_MY_PROP"] == "hello" + + def test_resource_extra_fields_preserved_in_dump(self): + """Resource.model_dump() (read path) also includes extra custom properties.""" + data = { + "resourceType": "BK", + "name": "Test", + "language": "en", + "timeZone": "US/Eastern", + "XA_READ_PROP": "read_value", + } + r = Resource.model_validate(data) + dumped = r.model_dump(exclude_none=True) + assert dumped["XA_READ_PROP"] == "read_value" + + @pytest.mark.asyncio + async def test_create_resource_passes_extra_fields_to_api(self, mock_instance: AsyncOFSC): + """create_resource() sends custom properties in the PUT body.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + **_resource_payload(), + "XA_CUSTOM_FIELD": "custom_value", + } + mock_response.raise_for_status = Mock() + mock_instance.core._client.put = AsyncMock(return_value=mock_response) + + payload = { + **_resource_payload(), + "XA_CUSTOM_FIELD": "custom_value", + } + await mock_instance.core.create_resource("TEST_RES_001", payload) + + call_kwargs = mock_instance.core._client.put.call_args + sent_body = call_kwargs.kwargs["json"] + assert sent_body["XA_CUSTOM_FIELD"] == "custom_value" From dfe43e6cc42a8b1da8136ca957a27b382d47eb05 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 13:42:02 -0500 Subject: [PATCH 5/7] test: add tests for UserCreate to validate preservation of extra fields --- ofsc/models/users.py | 2 ++ tests/async/test_async_users.py | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/ofsc/models/users.py b/ofsc/models/users.py index 0862f34..2df0df5 100644 --- a/ofsc/models/users.py +++ b/ofsc/models/users.py @@ -38,6 +38,8 @@ class User(BaseModel): class UserCreate(BaseModel): """User creation model enforcing required fields.""" + model_config = ConfigDict(extra="allow") + name: str userType: str language: str diff --git a/tests/async/test_async_users.py b/tests/async/test_async_users.py index a90f15e..7594549 100644 --- a/tests/async/test_async_users.py +++ b/tests/async/test_async_users.py @@ -224,6 +224,56 @@ def test_user_create_from_dict_in_create_user(self): assert body["name"] == "Test User" +class TestUserCreateExtraFields: + """Tests that extra/custom fields (e.g. XA_*) are preserved through UserCreate.""" + + def test_user_create_extra_fields_preserved_in_dump(self): + """UserCreate.model_validate() with XA_* fields should include them in model_dump.""" + data = { + "name": "Test User", + "userType": "technician", + "language": "en", + "timeZone": "US/Eastern", + "resources": ["BUCKET"], + "password": "ClaudeTest1!", + "XA_EMPLOYEE_ID": "EMP-001", + "XA_REGION": "WEST", + } + uc = UserCreate.model_validate(data) + dumped = uc.model_dump(exclude_none=True) + assert dumped["XA_EMPLOYEE_ID"] == "EMP-001" + assert dumped["XA_REGION"] == "WEST" + + def test_user_create_extra_fields_not_dropped(self): + """UserCreate constructed directly with XA_* kwargs should preserve them.""" + uc = UserCreate( + name="Test User", + userType="technician", + language="en", + timeZone="US/Eastern", + resources=["BUCKET"], + password="ClaudeTest1!", + XA_MY_PROP="hello", + ) + dumped = uc.model_dump(exclude_none=True) + assert dumped["XA_MY_PROP"] == "hello" + + def test_user_extra_fields_preserved_in_dump(self): + """User read model with extra fields should preserve them in model_dump.""" + data = { + "login": "jsmith", + "name": "John Smith", + "userType": "technician", + "language": "en", + "timeZone": "US/Eastern", + "resources": ["BUCKET"], + "XA_EMPLOYEE_ID": "EMP-999", + } + user = User.model_validate(data) + dumped = user.model_dump(exclude_none=True) + assert dumped["XA_EMPLOYEE_ID"] == "EMP-999" + + class TestAsyncGetUsers: """Model validation tests for get_users (mocked).""" From 9910c1998c6ec6aec453fc49c7e0f9d5578e0609 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 14:49:47 -0500 Subject: [PATCH 6/7] refactor: add generic HTTP helpers to AsyncClientBase and refactor metadata.py (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 8 generic helper methods to AsyncClientBase (_clean_response, _get_paginated_list, _get_single_item, _get_all_items, _put_item, _post_item, _patch_item, _delete_item) and refactor ~65 of 91 methods in async metadata.py to delegate to them, reducing the file by ~674 lines (~22%). Also fixes two bugs: - Missing quote_plus URL encoding in get_property, get_workzone, create_or_replace_property, update_property - Inconsistent 'links' removal across 55 methods, now unified via _clean_response() Adds 32 new unit tests in tests/async/test_async_base_helpers.py. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- ofsc/async_client/_base.py | 313 ++++- ofsc/async_client/metadata.py | 1442 +++++++----------------- tests/async/test_async_base_helpers.py | 578 ++++++++++ 3 files changed, 1274 insertions(+), 1059 deletions(-) create mode 100644 tests/async/test_async_base_helpers.py diff --git a/ofsc/async_client/_base.py b/ofsc/async_client/_base.py index 80888b5..8a03f07 100644 --- a/ofsc/async_client/_base.py +++ b/ofsc/async_client/_base.py @@ -1,12 +1,17 @@ """Shared base class for all async OFSC API modules.""" +from typing import Type, TypeVar +from urllib.parse import quote_plus, urljoin + import httpx +from pydantic import BaseModel from ..exceptions import ( OFSCApiError, OFSCAuthenticationError, OFSCAuthorizationError, OFSCConflictError, + OFSCNetworkError, OFSCNotFoundError, OFSCRateLimitError, OFSCServerError, @@ -14,12 +19,14 @@ ) from ..models import OFSConfig +T = TypeVar("T") + class AsyncClientBase: """Base class for all async API modules. Provides shared infrastructure: config access, URL construction, - auth headers, and HTTP error handling. + auth headers, HTTP error handling, and generic HTTP operation helpers. """ def __init__(self, config: OFSConfig, client: httpx.AsyncClient): @@ -142,3 +149,307 @@ def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> Non title=error_info["title"], detail=error_info["detail"], ) from e + + # region Generic HTTP helpers + + def _clean_response(self, data: dict) -> dict: + """Remove the 'links' key from an API response dict. + + The OFSC API adds a 'links' key to responses that is not represented + in Pydantic models. This helper removes it unconditionally, replacing + the previous inconsistent dual-pattern approach. + + :param data: Parsed JSON response dict + :type data: dict + :return: The same dict with 'links' removed if present + :rtype: dict + """ + data.pop("links", None) + return data + + async def _get_paginated_list( + self, + endpoint: str, + response_model: Type[T], + error_context: str, + offset: int = 0, + limit: int = 100, + extra_params: dict | None = None, + ) -> T: + """GET a paginated list resource and return a validated model. + + :param endpoint: API path (e.g. '/rest/ofscMetadata/v1/workZones') + :type endpoint: str + :param response_model: Pydantic model class to validate the response + :type response_model: Type[T] + :param error_context: Human-readable context for error messages + :type error_context: str + :param offset: Pagination offset (default 0) + :type offset: int + :param limit: Pagination limit (default 100) + :type limit: int + :param extra_params: Additional query parameters to merge + :type extra_params: dict | None + :return: Validated response model instance + :rtype: T + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin(self.baseUrl, endpoint) + params: dict = {"offset": offset, "limit": limit} + if extra_params: + params.update(extra_params) + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = self._clean_response(response.json()) + return response_model.model_validate(data) # type: ignore[union-attr] + except httpx.HTTPStatusError as e: + self._handle_http_error(e, error_context) + raise # satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def _get_single_item( + self, + endpoint_template: str, + label: str, + response_model: Type[T], + error_context: str, + ) -> T: + """GET a single resource by label and return a validated model. + + The label is always URL-encoded with quote_plus before substitution. + + :param endpoint_template: API path template with '{label}' placeholder + (e.g. '/rest/ofscMetadata/v1/workZones/{label}') + :type endpoint_template: str + :param label: Resource label (will be URL-encoded) + :type label: str + :param response_model: Pydantic model class to validate the response + :type response_model: Type[T] + :param error_context: Human-readable context for error messages + :type error_context: str + :return: Validated response model instance + :rtype: T + :raises OFSCNotFoundError: If the resource is not found (404) + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + encoded_label = quote_plus(label) + url = urljoin(self.baseUrl, endpoint_template.format(label=encoded_label)) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = self._clean_response(response.json()) + return response_model.model_validate(data) # type: ignore[union-attr] + except httpx.HTTPStatusError as e: + self._handle_http_error(e, error_context) + raise # satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def _get_all_items( + self, + endpoint: str, + response_model: Type[T], + error_context: str, + ) -> T: + """GET all items from a non-paginated resource. + + :param endpoint: API path (e.g. '/rest/ofscMetadata/v1/applications') + :type endpoint: str + :param response_model: Pydantic model class to validate the response + :type response_model: Type[T] + :param error_context: Human-readable context for error messages + :type error_context: str + :return: Validated response model instance + :rtype: T + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin(self.baseUrl, endpoint) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = self._clean_response(response.json()) + return response_model.model_validate(data) # type: ignore[union-attr] + except httpx.HTTPStatusError as e: + self._handle_http_error(e, error_context) + raise # satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def _put_item( + self, + endpoint: str, + data: BaseModel, + response_model: Type[T], + error_context: str, + ) -> T: + """PUT (create or replace) a resource and return the validated response. + + :param endpoint: Full API path including the resource identifier + :type endpoint: str + :param data: Pydantic model to serialize as the request body + :type data: BaseModel + :param response_model: Pydantic model class to validate the response + :type response_model: Type[T] + :param error_context: Human-readable context for error messages + :type error_context: str + :return: Validated response model instance + :rtype: T + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCValidationError: If validation fails (400, 422) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin(self.baseUrl, endpoint) + + try: + response = await self._client.put( + url, + headers=self.headers, + json=data.model_dump(exclude_none=True, mode="json"), + ) + response.raise_for_status() + result = self._clean_response(response.json()) + return response_model.model_validate(result) # type: ignore[union-attr] + except httpx.HTTPStatusError as e: + self._handle_http_error(e, error_context) + raise # satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def _post_item( + self, + endpoint: str, + data: BaseModel, + response_model: Type[T], + error_context: str, + ) -> T: + """POST (create) a resource and return the validated response. + + :param endpoint: API path (e.g. '/rest/ofscMetadata/v1/workZones') + :type endpoint: str + :param data: Pydantic model to serialize as the request body + :type data: BaseModel + :param response_model: Pydantic model class to validate the response + :type response_model: Type[T] + :param error_context: Human-readable context for error messages + :type error_context: str + :return: Validated response model instance + :rtype: T + :raises OFSCConflictError: If the resource already exists (409) + :raises OFSCValidationError: If validation fails (400) + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin(self.baseUrl, endpoint) + + try: + response = await self._client.post( + url, + headers=self.headers, + content=data.model_dump_json(exclude_none=True), + ) + response.raise_for_status() + result = self._clean_response(response.json()) + return response_model.model_validate(result) # type: ignore[union-attr] + except httpx.HTTPStatusError as e: + self._handle_http_error(e, error_context) + raise # satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def _patch_item( + self, + endpoint: str, + data: BaseModel, + response_model: Type[T], + error_context: str, + ) -> T: + """PATCH (partial update) a resource and return the validated response. + + :param endpoint: Full API path including the resource identifier + :type endpoint: str + :param data: Pydantic model to serialize as the request body + :type data: BaseModel + :param response_model: Pydantic model class to validate the response + :type response_model: Type[T] + :param error_context: Human-readable context for error messages + :type error_context: str + :return: Validated response model instance + :rtype: T + :raises OFSCNotFoundError: If the resource is not found (404) + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCValidationError: If validation fails (400, 422) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin(self.baseUrl, endpoint) + + try: + response = await self._client.patch( + url, + headers=self.headers, + content=data.model_dump_json(exclude_none=True).encode("utf-8"), + ) + response.raise_for_status() + result = self._clean_response(response.json()) + return response_model.model_validate(result) # type: ignore[union-attr] + except httpx.HTTPStatusError as e: + self._handle_http_error(e, error_context) + raise # satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def _delete_item( + self, + endpoint_template: str, + label: str, + error_context: str, + ) -> None: + """DELETE a resource by label. + + The label is always URL-encoded with quote_plus before substitution. + + :param endpoint_template: API path template with '{label}' placeholder + (e.g. '/rest/ofscMetadata/v1/workZones/{label}') + :type endpoint_template: str + :param label: Resource label (will be URL-encoded) + :type label: str + :param error_context: Human-readable context for error messages + :type error_context: str + :raises OFSCNotFoundError: If the resource is not found (404) + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + encoded_label = quote_plus(label) + url = urljoin(self.baseUrl, endpoint_template.format(label=encoded_label)) + + try: + response = await self._client.delete(url, headers=self.headers) + response.raise_for_status() + except httpx.HTTPStatusError as e: + self._handle_http_error(e, error_context) + raise # satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + # endregion diff --git a/ofsc/async_client/metadata.py b/ofsc/async_client/metadata.py index cb46762..f0f591a 100644 --- a/ofsc/async_client/metadata.py +++ b/ofsc/async_client/metadata.py @@ -86,23 +86,13 @@ async def get_activity_type_groups(self, offset: int = 0, limit: int = 100) -> A :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/activityTypeGroups") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(ActivityTypeGroupListResponse, "links"): - del data["links"] - - return ActivityTypeGroupListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get activity type groups") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/activityTypeGroups", + ActivityTypeGroupListResponse, + "Failed to get activity type groups", + offset, + limit, + ) async def get_activity_type_group(self, label: str) -> ActivityTypeGroup: """Get a single activity type group by label. @@ -117,23 +107,12 @@ async def get_activity_type_group(self, label: str) -> ActivityTypeGroup: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/activityTypeGroups/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(ActivityTypeGroup, "links"): - del data["links"] - - return ActivityTypeGroup.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get activity type group '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/activityTypeGroups/{label}", + label, + ActivityTypeGroup, + f"Failed to get activity type group '{label}'", + ) async def create_or_replace_activity_type_group(self, data: ActivityTypeGroup) -> ActivityTypeGroup: """Create or replace an activity type group. @@ -149,28 +128,13 @@ async def create_or_replace_activity_type_group(self, data: ActivityTypeGroup) - :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin( - self.baseUrl, + return await self._put_item( f"/rest/ofscMetadata/v1/activityTypeGroups/{encoded_label}", + data, + ActivityTypeGroup, + f"Failed to create/replace activity type group '{data.label}'", ) - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return ActivityTypeGroup.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/replace activity type group '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - # endregion # region Activity Types @@ -189,23 +153,13 @@ async def get_activity_types(self, offset: int = 0, limit: int = 100) -> Activit :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/activityTypes") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(ActivityTypeListResponse, "links"): - del data["links"] - - return ActivityTypeListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get activity types") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/activityTypes", + ActivityTypeListResponse, + "Failed to get activity types", + offset, + limit, + ) async def get_activity_type(self, label: str) -> ActivityType: """Get a single activity type by label. @@ -220,23 +174,12 @@ async def get_activity_type(self, label: str) -> ActivityType: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/activityTypes/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(ActivityType, "links"): - del data["links"] - - return ActivityType.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get activity type '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/activityTypes/{label}", + label, + ActivityType, + f"Failed to get activity type '{label}'", + ) async def create_or_replace_activity_type(self, data: ActivityType) -> ActivityType: """Create or replace an activity type. @@ -252,24 +195,12 @@ async def create_or_replace_activity_type(self, data: ActivityType) -> ActivityT :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/activityTypes/{encoded_label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return ActivityType.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/replace activity type '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._put_item( + f"/rest/ofscMetadata/v1/activityTypes/{encoded_label}", + data, + ActivityType, + f"Failed to create/replace activity type '{data.label}'", + ) # endregion @@ -285,20 +216,11 @@ async def get_applications(self) -> ApplicationListResponse: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/applications") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return ApplicationListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get applications") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_all_items( + "/rest/ofscMetadata/v1/applications", + ApplicationListResponse, + "Failed to get applications", + ) async def get_application(self, label: str) -> Application: """Get a single application by label. @@ -313,21 +235,12 @@ async def get_application(self, label: str) -> Application: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/applications/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return Application.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get application '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/applications/{label}", + label, + Application, + f"Failed to get application '{label}'", + ) async def get_application_api_accesses(self, label: str) -> ApplicationApiAccessListResponse: """Get all API accesses for an application. @@ -351,9 +264,7 @@ async def get_application_api_accesses(self, label: str) -> ApplicationApiAccess try: response = await self._client.get(url, headers=self.headers) response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] + data = self._clean_response(response.json()) return ApplicationApiAccessListResponse.model_validate(data) except httpx.HTTPStatusError as e: self._handle_http_error(e, f"Failed to get API accesses for application '{label}'") @@ -376,6 +287,8 @@ async def get_application_api_access(self, label: str, access_id: str) -> Applic :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ + from ..models import parse_application_api_access + encoded_label = quote_plus(label) encoded_access_id = quote_plus(access_id) url = urljoin( @@ -386,12 +299,7 @@ async def get_application_api_access(self, label: str, access_id: str) -> Applic try: response = await self._client.get(url, headers=self.headers) response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - # Import parse function from models - from ..models import parse_application_api_access - + data = self._clean_response(response.json()) return parse_application_api_access(data) except httpx.HTTPStatusError as e: self._handle_http_error( @@ -416,24 +324,12 @@ async def create_or_replace_application(self, data: Application) -> Application: :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/applications/{encoded_label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return Application.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/replace application '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._put_item( + f"/rest/ofscMetadata/v1/applications/{encoded_label}", + data, + Application, + f"Failed to create/replace application '{data.label}'", + ) async def update_application_api_access(self, label: str, api_label: str, data: dict) -> ApplicationApiAccess: """Update API access settings for an application. @@ -464,9 +360,7 @@ async def update_application_api_access(self, label: str, api_label: str, data: try: response = await self._client.patch(url, headers=self.headers, json=data) response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] + result = self._clean_response(response.json()) return parse_application_api_access(result) except httpx.HTTPStatusError as e: self._handle_http_error( @@ -550,9 +444,7 @@ async def get_capacity_areas( try: response = await self._client.get(url, headers=self.headers, params=params if params else None) response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(CapacityAreaListResponse, "links"): - del data["links"] + data = self._clean_response(response.json()) return CapacityAreaListResponse.model_validate(data) except httpx.HTTPStatusError as e: self._handle_http_error(e, "Failed to get capacity areas") @@ -573,21 +465,12 @@ async def get_capacity_area(self, label: str) -> CapacityArea: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/capacityAreas/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(CapacityArea, "links"): - del data["links"] - return CapacityArea.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get capacity area '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/capacityAreas/{label}", + label, + CapacityArea, + f"Failed to get capacity area '{label}'", + ) async def get_capacity_area_capacity_categories(self, label: str) -> CapacityAreaCapacityCategoriesResponse: """Get capacity categories for a capacity area (ME012G). @@ -603,24 +486,12 @@ async def get_capacity_area_capacity_categories(self, label: str) -> CapacityAre :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(label) - url = urljoin( - self.baseUrl, + return await self._get_all_items( f"/rest/ofscMetadata/v1/capacityAreas/{encoded_label}/capacityCategories", + CapacityAreaCapacityCategoriesResponse, + f"Failed to get capacity categories for area '{label}'", ) - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return CapacityAreaCapacityCategoriesResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get capacity categories for area '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_capacity_area_workzones(self, label: str) -> CapacityAreaWorkZonesResponse: """Get workzones for a capacity area using v2 API (ME013G). @@ -635,24 +506,12 @@ async def get_capacity_area_workzones(self, label: str) -> CapacityAreaWorkZones :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(label) - url = urljoin( - self.baseUrl, + return await self._get_all_items( f"/rest/ofscMetadata/v2/capacityAreas/{encoded_label}/workZones", + CapacityAreaWorkZonesResponse, + f"Failed to get workzones for capacity area '{label}'", ) - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return CapacityAreaWorkZonesResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get workzones for capacity area '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_capacity_area_workzones_v1(self, label: str) -> CapacityAreaWorkZonesV1Response: """Get workzones for a capacity area using v1 API (ME014G). @@ -670,24 +529,12 @@ async def get_capacity_area_workzones_v1(self, label: str) -> CapacityAreaWorkZo :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(label) - url = urljoin( - self.baseUrl, + return await self._get_all_items( f"/rest/ofscMetadata/v1/capacityAreas/{encoded_label}/workZones", + CapacityAreaWorkZonesV1Response, + f"Failed to get workzones (v1) for capacity area '{label}'", ) - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return CapacityAreaWorkZonesV1Response.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get workzones (v1) for capacity area '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_capacity_area_time_slots(self, label: str) -> CapacityAreaTimeSlotsResponse: """Get time slots for a capacity area (ME015G). @@ -702,24 +549,12 @@ async def get_capacity_area_time_slots(self, label: str) -> CapacityAreaTimeSlot :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(label) - url = urljoin( - self.baseUrl, + return await self._get_all_items( f"/rest/ofscMetadata/v1/capacityAreas/{encoded_label}/timeSlots", + CapacityAreaTimeSlotsResponse, + f"Failed to get time slots for capacity area '{label}'", ) - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return CapacityAreaTimeSlotsResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get time slots for capacity area '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_capacity_area_time_intervals(self, label: str) -> CapacityAreaTimeIntervalsResponse: """Get time intervals for a capacity area (ME016G). @@ -734,24 +569,12 @@ async def get_capacity_area_time_intervals(self, label: str) -> CapacityAreaTime :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(label) - url = urljoin( - self.baseUrl, + return await self._get_all_items( f"/rest/ofscMetadata/v1/capacityAreas/{encoded_label}/timeIntervals", + CapacityAreaTimeIntervalsResponse, + f"Failed to get time intervals for capacity area '{label}'", ) - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return CapacityAreaTimeIntervalsResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get time intervals for capacity area '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_capacity_area_organizations(self, label: str) -> CapacityAreaOrganizationsResponse: """Get organizations for a capacity area (ME017G). @@ -766,24 +589,12 @@ async def get_capacity_area_organizations(self, label: str) -> CapacityAreaOrgan :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(label) - url = urljoin( - self.baseUrl, + return await self._get_all_items( f"/rest/ofscMetadata/v1/capacityAreas/{encoded_label}/organizations", + CapacityAreaOrganizationsResponse, + f"Failed to get organizations for capacity area '{label}'", ) - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return CapacityAreaOrganizationsResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get organizations for capacity area '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_capacity_area_children( self, label: str, @@ -831,9 +642,7 @@ async def get_capacity_area_children( try: response = await self._client.get(url, headers=self.headers, params=params if params else None) response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] + data = self._clean_response(response.json()) return CapacityAreaChildrenResponse.model_validate(data) except httpx.HTTPStatusError as e: self._handle_http_error(e, f"Failed to get children for capacity area '{label}'") @@ -859,21 +668,13 @@ async def get_capacity_categories(self, offset: int = 0, limit: int = 100) -> Ca :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/capacityCategories") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(CapacityCategoryListResponse, "links"): - del data["links"] - return CapacityCategoryListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get capacity categories") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/capacityCategories", + CapacityCategoryListResponse, + "Failed to get capacity categories", + offset, + limit, + ) async def get_capacity_category(self, label: str) -> CapacityCategory: """Get a single capacity category by label. @@ -888,21 +689,12 @@ async def get_capacity_category(self, label: str) -> CapacityCategory: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/capacityCategories/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(CapacityCategory, "links"): - del data["links"] - return CapacityCategory.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get capacity category '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/capacityCategories/{label}", + label, + CapacityCategory, + f"Failed to get capacity category '{label}'", + ) async def create_or_replace_capacity_category(self, data: CapacityCategory) -> CapacityCategory: """Create or replace a capacity category. @@ -918,28 +710,13 @@ async def create_or_replace_capacity_category(self, data: CapacityCategory) -> C :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin( - self.baseUrl, + return await self._put_item( f"/rest/ofscMetadata/v1/capacityCategories/{encoded_label}", + data, + CapacityCategory, + f"Failed to create/replace capacity category '{data.label}'", ) - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return CapacityCategory.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/replace capacity category '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def delete_capacity_category(self, label: str) -> None: """Delete a capacity category. @@ -951,21 +728,12 @@ async def delete_capacity_category(self, label: str) -> None: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin( - self.baseUrl, - f"/rest/ofscMetadata/v1/capacityCategories/{encoded_label}", + return await self._delete_item( + "/rest/ofscMetadata/v1/capacityCategories/{label}", + label, + f"Failed to delete capacity category '{label}'", ) - try: - response = await self._client.delete(url, headers=self.headers) - response.raise_for_status() - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to delete capacity category '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e - # endregion # region Forms @@ -984,21 +752,13 @@ async def get_forms(self, offset: int = 0, limit: int = 100) -> FormListResponse :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/forms") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(FormListResponse, "links"): - del data["links"] - return FormListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get forms") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/forms", + FormListResponse, + "Failed to get forms", + offset, + limit, + ) async def get_form(self, label: str) -> Form: """Get a single form by label. @@ -1013,21 +773,12 @@ async def get_form(self, label: str) -> Form: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/forms/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(Form, "links"): - del data["links"] - return Form.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get form '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/forms/{label}", + label, + Form, + f"Failed to get form '{label}'", + ) async def create_or_replace_form(self, data: Form) -> Form: """Create or replace a form. @@ -1043,24 +794,12 @@ async def create_or_replace_form(self, data: Form) -> Form: :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/forms/{encoded_label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return Form.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/replace form '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._put_item( + f"/rest/ofscMetadata/v1/forms/{encoded_label}", + data, + Form, + f"Failed to create/replace form '{data.label}'", + ) async def delete_form(self, label: str) -> None: """Delete a form. @@ -1073,17 +812,11 @@ async def delete_form(self, label: str) -> None: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/forms/{encoded_label}") - - try: - response = await self._client.delete(url, headers=self.headers) - response.raise_for_status() - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to delete form '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._delete_item( + "/rest/ofscMetadata/v1/forms/{label}", + label, + f"Failed to delete form '{label}'", + ) # endregion @@ -1103,23 +836,13 @@ async def get_inventory_types(self, offset: int = 0, limit: int = 100) -> Invent :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/inventoryTypes") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(InventoryTypeListResponse, "links"): - del data["links"] - - return InventoryTypeListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get inventory types") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/inventoryTypes", + InventoryTypeListResponse, + "Failed to get inventory types", + offset, + limit, + ) async def get_inventory_type(self, label: str) -> InventoryType: """Get a single inventory type by label. @@ -1134,23 +857,12 @@ async def get_inventory_type(self, label: str) -> InventoryType: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/inventoryTypes/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(InventoryType, "links"): - del data["links"] - - return InventoryType.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get inventory type '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/inventoryTypes/{label}", + label, + InventoryType, + f"Failed to get inventory type '{label}'", + ) async def create_or_replace_inventory_type(self, data: InventoryType) -> InventoryType: """Create or replace an inventory type. @@ -1166,24 +878,12 @@ async def create_or_replace_inventory_type(self, data: InventoryType) -> Invento :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/inventoryTypes/{encoded_label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return InventoryType.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/replace inventory type '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._put_item( + f"/rest/ofscMetadata/v1/inventoryTypes/{encoded_label}", + data, + InventoryType, + f"Failed to create/replace inventory type '{data.label}'", + ) # endregion @@ -1203,26 +903,16 @@ async def get_languages(self, offset: int = 0, limit: int = 100) -> LanguageList :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/languages") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(LanguageListResponse, "links"): - del data["links"] - - return LanguageListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get languages") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/languages", + LanguageListResponse, + "Failed to get languages", + offset, + limit, + ) async def get_language(self, label: str) -> Language: - raise NotImplementedError("Async method not yet implemented") + raise NotImplementedError(f"Async get_language({label!r}) not yet implemented") # endregion @@ -1242,21 +932,13 @@ async def get_link_templates(self, offset: int = 0, limit: int = 100) -> LinkTem :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/linkTemplates") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(LinkTemplateListResponse, "links"): - del data["links"] - return LinkTemplateListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get link templates") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/linkTemplates", + LinkTemplateListResponse, + "Failed to get link templates", + offset, + limit, + ) async def get_link_template(self, label: str) -> LinkTemplate: """Get a single link template by label. @@ -1271,21 +953,12 @@ async def get_link_template(self, label: str) -> LinkTemplate: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/linkTemplates/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(LinkTemplate, "links"): - del data["links"] - return LinkTemplate.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get link template '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/linkTemplates/{label}", + label, + LinkTemplate, + f"Failed to get link template '{label}'", + ) async def create_link_template(self, data: LinkTemplate) -> LinkTemplate: """Create a new link template. @@ -1301,24 +974,12 @@ async def create_link_template(self, data: LinkTemplate) -> LinkTemplate: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/linkTemplates") - - try: - response = await self._client.post( - url, - headers=self.headers, - content=data.model_dump_json(exclude_none=True), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return LinkTemplate.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to create link template") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._post_item( + "/rest/ofscMetadata/v1/linkTemplates", + data, + LinkTemplate, + "Failed to create link template", + ) async def update_link_template(self, data: LinkTemplate) -> LinkTemplate: """Update a link template (partial update). @@ -1335,24 +996,12 @@ async def update_link_template(self, data: LinkTemplate) -> LinkTemplate: :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/linkTemplates/{encoded_label}") - - try: - response = await self._client.patch( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return LinkTemplate.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to update link template '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._patch_item( + f"/rest/ofscMetadata/v1/linkTemplates/{encoded_label}", + data, + LinkTemplate, + f"Failed to update link template '{data.label}'", + ) # endregion @@ -1372,21 +1021,13 @@ async def get_map_layers(self, offset: int = 0, limit: int = 100) -> MapLayerLis :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/mapLayers") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(MapLayerListResponse, "links"): - del data["links"] - return MapLayerListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get map layers") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/mapLayers", + MapLayerListResponse, + "Failed to get map layers", + offset, + limit, + ) async def get_map_layer(self, label: str) -> MapLayer: """Get a single map layer by label. @@ -1401,21 +1042,12 @@ async def get_map_layer(self, label: str) -> MapLayer: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/mapLayers/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(MapLayer, "links"): - del data["links"] - return MapLayer.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get map layer '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/mapLayers/{label}", + label, + MapLayer, + f"Failed to get map layer '{label}'", + ) async def create_or_replace_map_layer(self, data: MapLayer) -> MapLayer: """Create or replace a map layer. @@ -1431,24 +1063,12 @@ async def create_or_replace_map_layer(self, data: MapLayer) -> MapLayer: :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/mapLayers/{encoded_label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return MapLayer.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/replace map layer '{data.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._put_item( + f"/rest/ofscMetadata/v1/mapLayers/{encoded_label}", + data, + MapLayer, + f"Failed to create/replace map layer '{data.label}'", + ) async def create_map_layer(self, data: MapLayer) -> MapLayer: """Create a new map layer. @@ -1464,24 +1084,12 @@ async def create_map_layer(self, data: MapLayer) -> MapLayer: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/mapLayers") - - try: - response = await self._client.post( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] - return MapLayer.model_validate(result) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to create map layer") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._post_item( + "/rest/ofscMetadata/v1/mapLayers", + data, + MapLayer, + "Failed to create map layer", + ) async def populate_map_layers(self, data: bytes | Path) -> None: """Populate map layers from a file upload. @@ -1537,9 +1145,7 @@ async def get_populate_map_layers_status(self, download_id: int) -> PopulateStat try: response = await self._client.get(url, headers=self.headers) response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] + data = self._clean_response(response.json()) return PopulateStatusResponse.model_validate(data) except httpx.HTTPStatusError as e: self._handle_http_error( @@ -1568,23 +1174,13 @@ async def get_non_working_reasons(self, offset: int = 0, limit: int = 100) -> No :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/nonWorkingReasons") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(NonWorkingReasonListResponse, "links"): - del data["links"] - - return NonWorkingReasonListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get non-working reasons") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/nonWorkingReasons", + NonWorkingReasonListResponse, + "Failed to get non-working reasons", + offset, + limit, + ) async def get_non_working_reason(self, label: str) -> NonWorkingReason: """Get a single non-working reason by label. @@ -1599,7 +1195,7 @@ async def get_non_working_reason(self, label: str) -> NonWorkingReason: :raises NotImplementedError: This operation is not supported by the API """ raise NotImplementedError( - "Oracle Field Service API does not support retrieving individual non-working reasons by label. " + f"Oracle Field Service API does not support retrieving individual non-working reasons by label ({label!r}). " "Use get_non_working_reasons() and filter the results instead." ) @@ -1617,20 +1213,11 @@ async def get_organizations(self) -> OrganizationListResponse: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/organizations") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(OrganizationListResponse, "links"): - del data["links"] - return OrganizationListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get organizations") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_all_items( + "/rest/ofscMetadata/v1/organizations", + OrganizationListResponse, + "Failed to get organizations", + ) async def get_organization(self, label: str) -> Organization: """Get a single organization by label. @@ -1645,21 +1232,12 @@ async def get_organization(self, label: str) -> Organization: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/organizations/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(Organization, "links"): - del data["links"] - return Organization.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get organization '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/organizations/{label}", + label, + Organization, + f"Failed to get organization '{label}'", + ) # endregion @@ -1770,23 +1348,13 @@ async def get_properties(self, offset: int = 0, limit: int = 100) -> PropertyLis :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/properties") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(PropertyListResponse, "links"): - del data["links"] - - return PropertyListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get properties") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/properties", + PropertyListResponse, + "Failed to get properties", + offset, + limit, + ) async def get_property(self, label: str) -> Property: """Get a single property by label. @@ -1800,23 +1368,13 @@ async def get_property(self, label: str) -> Property: :raises OFSCAuthorizationError: If authorization fails (403) :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors - """ - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/properties/{label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(Property, "links"): - del data["links"] - - return Property.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get property '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + """ + return await self._get_single_item( + "/rest/ofscMetadata/v1/properties/{label}", + label, + Property, + f"Failed to get property '{label}'", + ) async def create_or_replace_property(self, property: Property) -> Property: """Create or replace a property. @@ -1831,26 +1389,13 @@ async def create_or_replace_property(self, property: Property) -> Property: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/properties/{property.label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - content=property.model_dump_json(exclude_none=True).encode("utf-8"), - ) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(Property, "links"): - del data["links"] - - return Property.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create or replace property '{property.label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + encoded_label = quote_plus(property.label) + return await self._put_item( + f"/rest/ofscMetadata/v1/properties/{encoded_label}", + property, + Property, + f"Failed to create or replace property '{property.label}'", + ) async def update_property(self, property: Property) -> Property: """Update a property (partial update). @@ -1866,24 +1411,13 @@ async def update_property(self, property: Property) -> Property: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/properties/{property.label}") - - try: - response = await self._client.patch( - url, - headers=self.headers, - content=property.model_dump_json(exclude_none=True).encode("utf-8"), - ) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(Property, "links"): - del data["links"] - return Property.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to update property '{property.label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + encoded_label = quote_plus(property.label) + return await self._patch_item( + f"/rest/ofscMetadata/v1/properties/{encoded_label}", + property, + Property, + f"Failed to update property '{property.label}'", + ) async def get_enumeration_values(self, label: str, offset: int = 0, limit: int = 100) -> EnumerationValueList: """Get enumeration values for a property. @@ -1902,23 +1436,13 @@ async def get_enumeration_values(self, label: str, offset: int = 0, limit: int = :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/properties/{label}/enumerationList") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(EnumerationValueList, "links"): - del data["links"] - - return EnumerationValueList.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get enumeration values for property '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + f"/rest/ofscMetadata/v1/properties/{label}/enumerationList", + EnumerationValueList, + f"Failed to get enumeration values for property '{label}'", + offset, + limit, + ) async def create_or_update_enumeration_value(self, label: str, value: Tuple[EnumerationValue, ...]) -> EnumerationValueList: """Create or update enumeration values for a property. @@ -1945,11 +1469,7 @@ async def create_or_update_enumeration_value(self, label: str, value: Tuple[Enum try: response = await self._client.put(url, headers=self.headers, json=data) response.raise_for_status() - response_data = response.json() - # Remove links if not in model - if "links" in response_data and not hasattr(EnumerationValueList, "links"): - del response_data["links"] - + response_data = self._clean_response(response.json()) return EnumerationValueList.model_validate(response_data) except httpx.HTTPStatusError as e: self._handle_http_error( @@ -1974,22 +1494,11 @@ async def get_resource_types(self) -> ResourceTypeListResponse: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/resourceTypes") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(ResourceTypeListResponse, "links"): - del data["links"] - - return ResourceTypeListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get resource types") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_all_items( + "/rest/ofscMetadata/v1/resourceTypes", + ResourceTypeListResponse, + "Failed to get resource types", + ) # endregion @@ -2009,23 +1518,13 @@ async def get_routing_profiles(self, offset: int = 0, limit: int = 100) -> Routi :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/routingProfiles") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(RoutingProfileList, "links"): - del data["links"] - - return RoutingProfileList.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get routing profiles") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/routingProfiles", + RoutingProfileList, + "Failed to get routing profiles", + offset, + limit, + ) async def get_routing_profile_plans(self, profile_label: str, offset: int = 0, limit: int = 100) -> RoutingPlanList: """Get all routing plans for a routing profile. @@ -2045,23 +1544,13 @@ async def get_routing_profile_plans(self, profile_label: str, offset: int = 0, l :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(profile_label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/routingProfiles/{encoded_label}/plans") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(RoutingPlanList, "links"): - del data["links"] - - return RoutingPlanList.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get routing plans for profile '{profile_label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + f"/rest/ofscMetadata/v1/routingProfiles/{encoded_label}/plans", + RoutingPlanList, + f"Failed to get routing plans for profile '{profile_label}'", + offset, + limit, + ) async def export_routing_plan(self, profile_label: str, plan_label: str) -> RoutingPlanData: """Export a routing plan. @@ -2093,11 +1582,7 @@ async def export_routing_plan(self, profile_label: str, plan_label: str) -> Rout response = await self._client.get(url, headers=headers) response.raise_for_status() # Response is JSON in bytes, need to parse it - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(RoutingPlanData, "links"): - del data["links"] - + data = self._clean_response(response.json()) return RoutingPlanData.model_validate(data) except httpx.HTTPStatusError as e: self._handle_http_error( @@ -2284,21 +1769,13 @@ async def get_shifts(self, offset: int = 0, limit: int = 100) -> ShiftListRespon :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/shifts") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(ShiftListResponse, "links"): - del data["links"] - return ShiftListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get shifts") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/shifts", + ShiftListResponse, + "Failed to get shifts", + offset, + limit, + ) async def get_shift(self, label: str) -> Shift: """Get a single shift by label. @@ -2313,21 +1790,12 @@ async def get_shift(self, label: str) -> Shift: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/shifts/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data and not hasattr(Shift, "links"): - del data["links"] - return Shift.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get shift '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/shifts/{label}", + label, + Shift, + f"Failed to get shift '{label}'", + ) async def create_or_replace_shift(self, data: Shift | ShiftUpdate) -> Shift: """Create or replace a shift. @@ -2352,9 +1820,7 @@ async def create_or_replace_shift(self, data: Shift | ShiftUpdate) -> Shift: json=data.model_dump(exclude_none=True, mode="json"), ) response.raise_for_status() - result = response.json() - if "links" in result: - del result["links"] + result = self._clean_response(response.json()) return Shift.model_validate(result) except httpx.HTTPStatusError as e: self._handle_http_error(e, f"Failed to create/replace shift '{data.label}'") @@ -2373,17 +1839,11 @@ async def delete_shift(self, label: str) -> None: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/shifts/{encoded_label}") - - try: - response = await self._client.delete(url, headers=self.headers) - response.raise_for_status() - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to delete shift '{label}'") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._delete_item( + "/rest/ofscMetadata/v1/shifts/{label}", + label, + f"Failed to delete shift '{label}'", + ) # endregion @@ -2403,23 +1863,13 @@ async def get_time_slots(self, offset: int = 0, limit: int = 100) -> TimeSlotLis :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/timeSlots") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(TimeSlotListResponse, "links"): - del data["links"] - - return TimeSlotListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get time slots") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/timeSlots", + TimeSlotListResponse, + "Failed to get time slots", + offset, + limit, + ) async def get_time_slot(self, label: str) -> TimeSlot: """Get a single time slot by label. @@ -2436,7 +1886,7 @@ async def get_time_slot(self, label: str) -> TimeSlot: :raises NotImplementedError: This operation is not supported by the Oracle API """ raise NotImplementedError( - "Oracle Field Service API does not support retrieving individual time slots by label. " + f"Oracle Field Service API does not support retrieving individual time slots by label ({label!r}). " "Use get_time_slots() and filter the results instead." ) @@ -2458,21 +1908,13 @@ async def get_workskills(self, offset: int = 0, limit: int = 100) -> WorkskillLi :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/workSkills") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return WorkskillListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get work skills") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/workSkills", + WorkskillListResponse, + "Failed to get work skills", + offset, + limit, + ) async def get_workskill(self, label: str) -> Workskill: """Get a single work skill by label. @@ -2487,21 +1929,12 @@ async def get_workskill(self, label: str) -> Workskill: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/workSkills/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return Workskill.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get work skill '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/workSkills/{label}", + label, + Workskill, + f"Failed to get work skill '{label}'", + ) async def create_or_update_workskill(self, skill: Workskill) -> Workskill: """Create or update a work skill. @@ -2517,24 +1950,12 @@ async def create_or_update_workskill(self, skill: Workskill) -> Workskill: :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(skill.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/workSkills/{encoded_label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - json=skill.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return Workskill.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/update work skill '{skill.label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._put_item( + f"/rest/ofscMetadata/v1/workSkills/{encoded_label}", + skill, + Workskill, + f"Failed to create/update work skill '{skill.label}'", + ) async def delete_workskill(self, label: str) -> None: """Delete a work skill. @@ -2547,17 +1968,11 @@ async def delete_workskill(self, label: str) -> None: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/workSkills/{encoded_label}") - - try: - response = await self._client.delete(url, headers=self.headers) - response.raise_for_status() - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to delete work skill '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._delete_item( + "/rest/ofscMetadata/v1/workSkills/{label}", + label, + f"Failed to delete work skill '{label}'", + ) async def get_workskill_conditions(self) -> WorkskillConditionList: """Get all work skill conditions. @@ -2623,20 +2038,11 @@ async def get_workskill_groups(self) -> WorkskillGroupListResponse: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/workSkillGroups") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return WorkskillGroupListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get work skill groups") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_all_items( + "/rest/ofscMetadata/v1/workSkillGroups", + WorkskillGroupListResponse, + "Failed to get work skill groups", + ) async def get_workskill_group(self, label: str) -> WorkskillGroup: """Get a single work skill group by label. @@ -2651,21 +2057,12 @@ async def get_workskill_group(self, label: str) -> WorkskillGroup: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/workSkillGroups/{encoded_label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return WorkskillGroup.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get work skill group '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/workSkillGroups/{label}", + label, + WorkskillGroup, + f"Failed to get work skill group '{label}'", + ) async def create_or_update_workskill_group(self, data: WorkskillGroup) -> WorkskillGroup: """Create or update a work skill group. @@ -2681,24 +2078,12 @@ async def create_or_update_workskill_group(self, data: WorkskillGroup) -> Worksk :raises OFSCNetworkError: For network/transport errors """ encoded_label = quote_plus(data.label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/workSkillGroups/{encoded_label}") - - try: - response = await self._client.put( - url, - headers=self.headers, - json=data.model_dump(exclude_none=True, mode="json"), - ) - response.raise_for_status() - response_data = response.json() - if "links" in response_data: - del response_data["links"] - return WorkskillGroup.model_validate(response_data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create/update work skill group '{data.label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._put_item( + f"/rest/ofscMetadata/v1/workSkillGroups/{encoded_label}", + data, + WorkskillGroup, + f"Failed to create/update work skill group '{data.label}'", + ) async def delete_workskill_group(self, label: str) -> None: """Delete a work skill group. @@ -2711,17 +2096,11 @@ async def delete_workskill_group(self, label: str) -> None: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - encoded_label = quote_plus(label) - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/workSkillGroups/{encoded_label}") - - try: - response = await self._client.delete(url, headers=self.headers) - response.raise_for_status() - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to delete work skill group '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._delete_item( + "/rest/ofscMetadata/v1/workSkillGroups/{label}", + label, + f"Failed to delete work skill group '{label}'", + ) # endregion @@ -2741,23 +2120,13 @@ async def get_workzones(self, offset: int = 0, limit: int = 100) -> WorkzoneList :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/workZones") - params = {"offset": offset, "limit": limit} - - try: - response = await self._client.get(url, headers=self.headers, params=params) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(WorkzoneListResponse, "links"): - del data["links"] - - return WorkzoneListResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get workzones") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_paginated_list( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "Failed to get workzones", + offset, + limit, + ) async def get_workzone(self, label: str) -> Workzone: """Get a single workzone by label. @@ -2772,22 +2141,12 @@ async def get_workzone(self, label: str) -> Workzone: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, f"/rest/ofscMetadata/v1/workZones/{label}") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(Workzone, "links"): - del data["links"] - - return Workzone.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to get workzone '{label}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_single_item( + "/rest/ofscMetadata/v1/workZones/{label}", + label, + Workzone, + f"Failed to get workzone '{label}'", + ) async def create_workzone(self, workzone: Workzone) -> Workzone: """Create a new workzone. @@ -2803,26 +2162,12 @@ async def create_workzone(self, workzone: Workzone) -> Workzone: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/workZones") - - try: - response = await self._client.post( - url, - headers=self.headers, - content=workzone.model_dump_json(exclude_none=True), - ) - response.raise_for_status() - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(Workzone, "links"): - del data["links"] - - return Workzone.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, f"Failed to create workzone '{workzone.workZoneLabel}'") - raise # This will never execute, but satisfies type checker - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._post_item( + "/rest/ofscMetadata/v1/workZones", + workzone, + Workzone, + f"Failed to create workzone '{workzone.workZoneLabel}'", + ) async def replace_workzone(self, workzone: Workzone, auto_resolve_conflicts: bool = False) -> Workzone | None: """Replace an existing workzone. @@ -2863,16 +2208,12 @@ async def replace_workzone(self, workzone: Workzone, auto_resolve_conflicts: boo if response.status_code == 204: return None - data = response.json() - # Remove links if not in model - if "links" in data and not hasattr(Workzone, "links"): - del data["links"] - + data = self._clean_response(response.json()) return Workzone.model_validate(data) except httpx.HTTPStatusError as e: self._handle_http_error(e, f"Failed to replace workzone '{workzone.workZoneLabel}'") raise # This will never execute, but satisfies type checker - except OFSCNetworkError as e: + except httpx.TransportError as e: raise OFSCNetworkError(f"Network error: {str(e)}") from e async def replace_workzones(self, data: list[Workzone]) -> WorkzoneListResponse: @@ -2896,9 +2237,7 @@ async def replace_workzones(self, data: list[Workzone]) -> WorkzoneListResponse: try: response = await self._client.put(url, headers=self.headers, json=body) response.raise_for_status() - response_data = response.json() - if "links" in response_data: - del response_data["links"] + response_data = self._clean_response(response.json()) return WorkzoneListResponse.model_validate(response_data) except httpx.HTTPStatusError as e: self._handle_http_error(e, "Failed to replace workzones") @@ -2925,9 +2264,7 @@ async def update_workzones(self, data: list[Workzone]) -> WorkzoneListResponse: try: response = await self._client.patch(url, headers=self.headers, json=body) response.raise_for_status() - response_data = response.json() - if "links" in response_data: - del response_data["links"] + response_data = self._clean_response(response.json()) return WorkzoneListResponse.model_validate(response_data) except httpx.HTTPStatusError as e: self._handle_http_error(e, "Failed to update workzones") @@ -2989,9 +2326,7 @@ async def get_populate_workzone_shapes_status(self, download_id: int) -> Populat try: response = await self._client.get(url, headers=self.headers) response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] + data = self._clean_response(response.json()) return PopulateStatusResponse.model_validate(data) except httpx.HTTPStatusError as e: self._handle_http_error( @@ -3012,19 +2347,10 @@ async def get_workzone_key(self) -> WorkZoneKeyResponse: :raises OFSCApiError: For other API errors :raises OFSCNetworkError: For network/transport errors """ - url = urljoin(self.baseUrl, "/rest/ofscMetadata/v1/workZoneKey") - - try: - response = await self._client.get(url, headers=self.headers) - response.raise_for_status() - data = response.json() - if "links" in data: - del data["links"] - return WorkZoneKeyResponse.model_validate(data) - except httpx.HTTPStatusError as e: - self._handle_http_error(e, "Failed to get workzone key") - raise - except httpx.TransportError as e: - raise OFSCNetworkError(f"Network error: {str(e)}") from e + return await self._get_all_items( + "/rest/ofscMetadata/v1/workZoneKey", + WorkZoneKeyResponse, + "Failed to get workzone key", + ) # endregion diff --git a/tests/async/test_async_base_helpers.py b/tests/async/test_async_base_helpers.py new file mode 100644 index 0000000..4e59e11 --- /dev/null +++ b/tests/async/test_async_base_helpers.py @@ -0,0 +1,578 @@ +"""Unit tests for AsyncClientBase helper methods.""" + +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from ofsc.async_client import AsyncOFSC +from ofsc.exceptions import ( + OFSCNetworkError, + OFSCNotFoundError, + OFSCAuthenticationError, + OFSCValidationError, +) +from ofsc.models import Workzone, WorkzoneListResponse + +# Complete Workzone dict that satisfies all required fields +_WORKZONE_DATA = { + "workZoneLabel": "TEST", + "workZoneName": "Test Zone", + "status": "active", + "travelArea": "urban", +} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def mock_instance() -> AsyncOFSC: + """Return an AsyncOFSC instance with dummy credentials.""" + async with AsyncOFSC(clientID="test", companyName="test", secret="test") as instance: + yield instance + + +def _make_response(status: int = 200, json_data: dict | None = None, raise_exc: Exception | None = None) -> Mock: + """Build a minimal httpx response mock.""" + mock = Mock() + mock.status_code = status + if json_data is not None: + mock.json.return_value = json_data + if raise_exc is not None: + mock.raise_for_status.side_effect = raise_exc + else: + mock.raise_for_status = Mock() + return mock + + +# --------------------------------------------------------------------------- +# _clean_response +# --------------------------------------------------------------------------- + + +class TestCleanResponse: + """Tests for _clean_response helper.""" + + @pytest.mark.asyncio + async def test_removes_links_key(self, mock_instance: AsyncOFSC) -> None: + """_clean_response removes the 'links' key from a dict.""" + data = {"items": [], "totalResults": 0, "links": [{"href": "..."}]} + result = mock_instance.metadata._clean_response(data) + assert "links" not in result + assert result["items"] == [] + assert result["totalResults"] == 0 + + @pytest.mark.asyncio + async def test_no_op_when_links_absent(self, mock_instance: AsyncOFSC) -> None: + """_clean_response is a no-op when 'links' is not present.""" + data = {"items": [1, 2], "totalResults": 2} + result = mock_instance.metadata._clean_response(data) + assert result == {"items": [1, 2], "totalResults": 2} + + @pytest.mark.asyncio + async def test_returns_same_dict(self, mock_instance: AsyncOFSC) -> None: + """_clean_response returns the same dict object (mutates in place).""" + data = {"key": "value", "links": []} + result = mock_instance.metadata._clean_response(data) + assert result is data + + +# --------------------------------------------------------------------------- +# _get_paginated_list +# --------------------------------------------------------------------------- + + +class TestGetPaginatedList: + """Tests for _get_paginated_list helper.""" + + @pytest.mark.asyncio + async def test_returns_validated_model(self, mock_instance: AsyncOFSC) -> None: + """_get_paginated_list returns a validated Pydantic model.""" + mock_response = _make_response(json_data={"items": [], "totalResults": 0}) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + result = await mock_instance.metadata._get_paginated_list( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + ) + assert isinstance(result, WorkzoneListResponse) + + @pytest.mark.asyncio + async def test_sends_offset_and_limit_params(self, mock_instance: AsyncOFSC) -> None: + """_get_paginated_list passes offset and limit as query params.""" + mock_response = _make_response(json_data={"items": [], "totalResults": 0}) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._get_paginated_list( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + offset=10, + limit=50, + ) + + call_kwargs = mock_instance.metadata._client.get.call_args[1] + assert call_kwargs["params"]["offset"] == 10 + assert call_kwargs["params"]["limit"] == 50 + + @pytest.mark.asyncio + async def test_merges_extra_params(self, mock_instance: AsyncOFSC) -> None: + """_get_paginated_list merges extra_params into query string.""" + mock_response = _make_response(json_data={"items": [], "totalResults": 0}) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._get_paginated_list( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + extra_params={"filter": "active"}, + ) + + call_kwargs = mock_instance.metadata._client.get.call_args[1] + assert call_kwargs["params"]["filter"] == "active" + + @pytest.mark.asyncio + async def test_strips_links_from_response(self, mock_instance: AsyncOFSC) -> None: + """_get_paginated_list removes 'links' before model validation.""" + mock_response = _make_response(json_data={"items": [], "totalResults": 0, "links": [{"href": "next"}]}) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + # Should not raise a validation error from unexpected 'links' field + result = await mock_instance.metadata._get_paginated_list( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + ) + assert isinstance(result, WorkzoneListResponse) + + @pytest.mark.asyncio + async def test_404_raises_ofsc_not_found_error(self, mock_instance: AsyncOFSC) -> None: + """_get_paginated_list propagates 404 as OFSCNotFoundError.""" + mock_response = _make_response(status=404) + mock_response.json.return_value = {"detail": "not found", "type": "about:blank", "title": "Not Found"} + mock_response.text = "not found" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError("404", request=Mock(), response=mock_response) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + with pytest.raises(OFSCNotFoundError): + await mock_instance.metadata._get_paginated_list( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + ) + + @pytest.mark.asyncio + async def test_transport_error_raises_ofsc_network_error(self, mock_instance: AsyncOFSC) -> None: + """_get_paginated_list wraps TransportError as OFSCNetworkError.""" + mock_instance.metadata._client.get = AsyncMock(side_effect=httpx.TransportError("connection refused")) + + with pytest.raises(OFSCNetworkError): + await mock_instance.metadata._get_paginated_list( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + ) + + +# --------------------------------------------------------------------------- +# _get_single_item +# --------------------------------------------------------------------------- + + +class TestGetSingleItem: + """Tests for _get_single_item helper.""" + + @pytest.mark.asyncio + async def test_returns_validated_model(self, mock_instance: AsyncOFSC) -> None: + """_get_single_item returns a validated Pydantic model.""" + mock_response = _make_response(json_data=_WORKZONE_DATA) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + result = await mock_instance.metadata._get_single_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "TEST", + Workzone, + "test context", + ) + assert isinstance(result, Workzone) + assert result.workZoneLabel == "TEST" + + @pytest.mark.asyncio + async def test_applies_quote_plus_to_label(self, mock_instance: AsyncOFSC) -> None: + """_get_single_item URL-encodes the label with quote_plus.""" + mock_response = _make_response(json_data={**_WORKZONE_DATA, "workZoneLabel": "A B", "workZoneName": "A B"}) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._get_single_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "A B", + Workzone, + "test context", + ) + + called_url = mock_instance.metadata._client.get.call_args[0][0] + assert "A+B" in called_url + + @pytest.mark.asyncio + async def test_404_raises_ofsc_not_found_error(self, mock_instance: AsyncOFSC) -> None: + """_get_single_item propagates 404 as OFSCNotFoundError.""" + mock_response = _make_response(status=404) + mock_response.json.return_value = {"detail": "not found", "type": "about:blank", "title": "Not Found"} + mock_response.text = "not found" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError("404", request=Mock(), response=mock_response) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + with pytest.raises(OFSCNotFoundError): + await mock_instance.metadata._get_single_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "NONEXISTENT", + Workzone, + "test context", + ) + + @pytest.mark.asyncio + async def test_transport_error_raises_ofsc_network_error(self, mock_instance: AsyncOFSC) -> None: + """_get_single_item wraps TransportError as OFSCNetworkError.""" + mock_instance.metadata._client.get = AsyncMock(side_effect=httpx.TransportError("timeout")) + + with pytest.raises(OFSCNetworkError): + await mock_instance.metadata._get_single_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "TEST", + Workzone, + "test context", + ) + + +# --------------------------------------------------------------------------- +# _get_all_items +# --------------------------------------------------------------------------- + + +class TestGetAllItems: + """Tests for _get_all_items helper.""" + + @pytest.mark.asyncio + async def test_returns_validated_model(self, mock_instance: AsyncOFSC) -> None: + """_get_all_items returns a validated Pydantic model.""" + mock_response = _make_response(json_data={"items": [], "totalResults": 0}) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + result = await mock_instance.metadata._get_all_items( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + ) + assert isinstance(result, WorkzoneListResponse) + + @pytest.mark.asyncio + async def test_sends_no_pagination_params(self, mock_instance: AsyncOFSC) -> None: + """_get_all_items does not send offset/limit params.""" + mock_response = _make_response(json_data={"items": [], "totalResults": 0}) + mock_instance.metadata._client.get = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._get_all_items( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + ) + + # Should only pass headers, no params keyword + call_kwargs = mock_instance.metadata._client.get.call_args[1] + assert "params" not in call_kwargs + + @pytest.mark.asyncio + async def test_transport_error_raises_ofsc_network_error(self, mock_instance: AsyncOFSC) -> None: + """_get_all_items wraps TransportError as OFSCNetworkError.""" + mock_instance.metadata._client.get = AsyncMock(side_effect=httpx.TransportError("connection refused")) + + with pytest.raises(OFSCNetworkError): + await mock_instance.metadata._get_all_items( + "/rest/ofscMetadata/v1/workZones", + WorkzoneListResponse, + "test context", + ) + + +# --------------------------------------------------------------------------- +# _put_item +# --------------------------------------------------------------------------- + + +class TestPutItem: + """Tests for _put_item helper.""" + + @pytest.mark.asyncio + async def test_returns_validated_model(self, mock_instance: AsyncOFSC) -> None: + """_put_item returns a validated Pydantic model.""" + wz = Workzone.model_validate(_WORKZONE_DATA) + mock_response = _make_response(json_data=_WORKZONE_DATA) + mock_instance.metadata._client.put = AsyncMock(return_value=mock_response) + + result = await mock_instance.metadata._put_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + assert isinstance(result, Workzone) + + @pytest.mark.asyncio + async def test_uses_put_method(self, mock_instance: AsyncOFSC) -> None: + """_put_item calls the PUT HTTP method.""" + wz = Workzone.model_validate(_WORKZONE_DATA) + mock_response = _make_response(json_data=_WORKZONE_DATA) + mock_instance.metadata._client.put = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._put_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + + assert mock_instance.metadata._client.put.called + + @pytest.mark.asyncio + async def test_400_raises_ofsc_validation_error(self, mock_instance: AsyncOFSC) -> None: + """_put_item propagates 400 as OFSCValidationError.""" + wz = Workzone.model_validate(_WORKZONE_DATA) + mock_response = _make_response(status=400) + mock_response.json.return_value = {"detail": "bad request", "type": "about:blank", "title": "Bad Request"} + mock_response.text = "bad request" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError("400", request=Mock(), response=mock_response) + mock_instance.metadata._client.put = AsyncMock(return_value=mock_response) + + with pytest.raises(OFSCValidationError): + await mock_instance.metadata._put_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + + @pytest.mark.asyncio + async def test_transport_error_raises_ofsc_network_error(self, mock_instance: AsyncOFSC) -> None: + """_put_item wraps TransportError as OFSCNetworkError.""" + wz = Workzone.model_validate(_WORKZONE_DATA) + mock_instance.metadata._client.put = AsyncMock(side_effect=httpx.TransportError("connection refused")) + + with pytest.raises(OFSCNetworkError): + await mock_instance.metadata._put_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + + +# --------------------------------------------------------------------------- +# _post_item +# --------------------------------------------------------------------------- + + +class TestPostItem: + """Tests for _post_item helper.""" + + @pytest.mark.asyncio + async def test_returns_validated_model(self, mock_instance: AsyncOFSC) -> None: + """_post_item returns a validated Pydantic model.""" + wz = Workzone.model_validate({**_WORKZONE_DATA, "workZoneLabel": "NEW", "workZoneName": "New Zone"}) + mock_response = _make_response(json_data={**_WORKZONE_DATA, "workZoneLabel": "NEW", "workZoneName": "New Zone"}) + mock_instance.metadata._client.post = AsyncMock(return_value=mock_response) + + result = await mock_instance.metadata._post_item( + "/rest/ofscMetadata/v1/workZones", + wz, + Workzone, + "test context", + ) + assert isinstance(result, Workzone) + assert result.workZoneLabel == "NEW" + + @pytest.mark.asyncio + async def test_uses_post_method(self, mock_instance: AsyncOFSC) -> None: + """_post_item calls the POST HTTP method.""" + wz = Workzone.model_validate({**_WORKZONE_DATA, "workZoneLabel": "NEW", "workZoneName": "New Zone"}) + mock_response = _make_response(json_data={**_WORKZONE_DATA, "workZoneLabel": "NEW", "workZoneName": "New Zone"}) + mock_instance.metadata._client.post = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._post_item( + "/rest/ofscMetadata/v1/workZones", + wz, + Workzone, + "test context", + ) + + assert mock_instance.metadata._client.post.called + + @pytest.mark.asyncio + async def test_transport_error_raises_ofsc_network_error(self, mock_instance: AsyncOFSC) -> None: + """_post_item wraps TransportError as OFSCNetworkError.""" + wz = Workzone.model_validate({**_WORKZONE_DATA, "workZoneLabel": "NEW", "workZoneName": "New Zone"}) + mock_instance.metadata._client.post = AsyncMock(side_effect=httpx.TransportError("connection refused")) + + with pytest.raises(OFSCNetworkError): + await mock_instance.metadata._post_item( + "/rest/ofscMetadata/v1/workZones", + wz, + Workzone, + "test context", + ) + + +# --------------------------------------------------------------------------- +# _patch_item +# --------------------------------------------------------------------------- + + +class TestPatchItem: + """Tests for _patch_item helper.""" + + @pytest.mark.asyncio + async def test_returns_validated_model(self, mock_instance: AsyncOFSC) -> None: + """_patch_item returns a validated Pydantic model.""" + wz = Workzone.model_validate({**_WORKZONE_DATA, "workZoneName": "Updated Name"}) + mock_response = _make_response(json_data={**_WORKZONE_DATA, "workZoneName": "Updated Name"}) + mock_instance.metadata._client.patch = AsyncMock(return_value=mock_response) + + result = await mock_instance.metadata._patch_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + assert isinstance(result, Workzone) + + @pytest.mark.asyncio + async def test_uses_patch_method(self, mock_instance: AsyncOFSC) -> None: + """_patch_item calls the PATCH HTTP method.""" + wz = Workzone.model_validate({**_WORKZONE_DATA, "workZoneName": "Updated"}) + mock_response = _make_response(json_data={**_WORKZONE_DATA, "workZoneName": "Updated"}) + mock_instance.metadata._client.patch = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._patch_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + + assert mock_instance.metadata._client.patch.called + + @pytest.mark.asyncio + async def test_404_raises_ofsc_not_found_error(self, mock_instance: AsyncOFSC) -> None: + """_patch_item propagates 404 as OFSCNotFoundError.""" + wz = Workzone.model_validate({**_WORKZONE_DATA, "workZoneName": "X"}) + mock_response = _make_response(status=404) + mock_response.json.return_value = {"detail": "not found", "type": "about:blank", "title": "Not Found"} + mock_response.text = "not found" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError("404", request=Mock(), response=mock_response) + mock_instance.metadata._client.patch = AsyncMock(return_value=mock_response) + + with pytest.raises(OFSCNotFoundError): + await mock_instance.metadata._patch_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + + @pytest.mark.asyncio + async def test_transport_error_raises_ofsc_network_error(self, mock_instance: AsyncOFSC) -> None: + """_patch_item wraps TransportError as OFSCNetworkError.""" + wz = Workzone.model_validate({**_WORKZONE_DATA, "workZoneName": "X"}) + mock_instance.metadata._client.patch = AsyncMock(side_effect=httpx.TransportError("connection refused")) + + with pytest.raises(OFSCNetworkError): + await mock_instance.metadata._patch_item( + "/rest/ofscMetadata/v1/workZones/TEST", + wz, + Workzone, + "test context", + ) + + +# --------------------------------------------------------------------------- +# _delete_item +# --------------------------------------------------------------------------- + + +class TestDeleteItem: + """Tests for _delete_item helper.""" + + @pytest.mark.asyncio + async def test_returns_none_on_success(self, mock_instance: AsyncOFSC) -> None: + """_delete_item returns None on a successful delete.""" + mock_response = _make_response(status=204) + mock_instance.metadata._client.delete = AsyncMock(return_value=mock_response) + + result = await mock_instance.metadata._delete_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "TEST", + "test context", + ) + assert result is None + + @pytest.mark.asyncio + async def test_applies_quote_plus_to_label(self, mock_instance: AsyncOFSC) -> None: + """_delete_item URL-encodes the label with quote_plus.""" + mock_response = _make_response(status=204) + mock_instance.metadata._client.delete = AsyncMock(return_value=mock_response) + + await mock_instance.metadata._delete_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "A B", + "test context", + ) + + called_url = mock_instance.metadata._client.delete.call_args[0][0] + assert "A+B" in called_url + + @pytest.mark.asyncio + async def test_404_raises_ofsc_not_found_error(self, mock_instance: AsyncOFSC) -> None: + """_delete_item propagates 404 as OFSCNotFoundError.""" + mock_response = _make_response(status=404) + mock_response.json.return_value = {"detail": "not found", "type": "about:blank", "title": "Not Found"} + mock_response.text = "not found" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError("404", request=Mock(), response=mock_response) + mock_instance.metadata._client.delete = AsyncMock(return_value=mock_response) + + with pytest.raises(OFSCNotFoundError): + await mock_instance.metadata._delete_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "NONEXISTENT", + "test context", + ) + + @pytest.mark.asyncio + async def test_401_raises_ofsc_authentication_error(self, mock_instance: AsyncOFSC) -> None: + """_delete_item propagates 401 as OFSCAuthenticationError.""" + mock_response = _make_response(status=401) + mock_response.json.return_value = {"detail": "unauthorized", "type": "about:blank", "title": "Unauthorized"} + mock_response.text = "unauthorized" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError("401", request=Mock(), response=mock_response) + mock_instance.metadata._client.delete = AsyncMock(return_value=mock_response) + + with pytest.raises(OFSCAuthenticationError): + await mock_instance.metadata._delete_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "TEST", + "test context", + ) + + @pytest.mark.asyncio + async def test_transport_error_raises_ofsc_network_error(self, mock_instance: AsyncOFSC) -> None: + """_delete_item wraps TransportError as OFSCNetworkError.""" + mock_instance.metadata._client.delete = AsyncMock(side_effect=httpx.TransportError("connection refused")) + + with pytest.raises(OFSCNetworkError): + await mock_instance.metadata._delete_item( + "/rest/ofscMetadata/v1/workZones/{label}", + "TEST", + "test context", + ) From b0b8cdde7f94b1497084118fa1722b27cf6603e7 Mon Sep 17 00:00:00 2001 From: Borja Toron Date: Thu, 5 Mar 2026 14:58:29 -0500 Subject: [PATCH 7/7] fix: detect helper-delegated methods in update_endpoints_doc.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HELPER_METHOD_MAP and a second detection pass in scan_file_for_endpoints() to recognize calls to AsyncClientBase helpers (_get_paginated_list, _get_single_item, _put_item, etc.). This restores correct endpoint detection after the #116 refactor that replaced urljoin() calls with helper delegation — async metadata now shows 85 endpoints instead of 22. Co-Authored-By: Claude Sonnet 4.6 --- scripts/update_endpoints_doc.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/update_endpoints_doc.py b/scripts/update_endpoints_doc.py index 778cca9..7ed93c2 100644 --- a/scripts/update_endpoints_doc.py +++ b/scripts/update_endpoints_doc.py @@ -34,6 +34,17 @@ "collaboration": (None, None), } +# Maps helper method names (used in refactored async code) to their HTTP methods +HELPER_METHOD_MAP = { + "_get_paginated_list": "GET", + "_get_single_item": "GET", + "_get_all_items": "GET", + "_put_item": "PUT", + "_post_item": "POST", + "_patch_item": "PATCH", + "_delete_item": "DELETE", +} + def dict_list_to_markdown_table(data: list[dict]) -> str: """Convert list of dictionaries to GitHub-flavored markdown table. @@ -293,6 +304,17 @@ def scan_file_for_endpoints(file_path: Path) -> dict[tuple[str, str], bool]: if http_method: http_methods.append((http_method, child.lineno)) + # Check if this is a call to a helper method (e.g. self._get_paginated_list) + # These are used in refactored async code that delegates to AsyncClientBase helpers + if isinstance(func, ast.Attribute) and func.attr in HELPER_METHOD_MAP: + if child.args: + url = _extract_url_from_ast_node(child.args[0]) + if url and url.startswith("/rest/"): + url = re.sub(r"\?[^/]*$", "", url).rstrip("/") + helper_method = HELPER_METHOD_MAP[func.attr] + is_implemented = not is_stub + endpoints[(url, helper_method)] = is_implemented + # Match urljoin calls with HTTP methods in the same function # Heuristic: pair each urljoin with the closest HTTP method call after it for url, urljoin_line in urljoin_urls: