Skip to content

Commit c7d92be

Browse files
authored
[MPT-14951] Integrated api client into product api service and flow (#159)
Integrated api client into product api service and flow https://softwareone.atlassian.net/browse/MPT-18516 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> Closes [MPT-14951](https://softwareone.atlassian.net/browse/MPT-14951) - Add create_api_mpt_client_from_account(account) and wire api_mpt_client into account and product containers - Replace direct MPT HTTP client usage with collection-service abstractions (mpt_client.catalog.{products, items, price_lists, ...}) and update container wiring and service constructors - APIService / RelatedAPIService now delegate to collection_service for exists, get, list, post (form/json + files), post_action and update (including sub-resource updates) - Swap wrap_http_error for wrap_mpt_api_error across API and flow layers for consistent error mapping - Update product and price-list flows to use RQLQuery filtering and collection.fetch_page pagination with explicit metadata validation - Item API/service: new ItemAPIService ctor accepts collection_service and mpt_client; UOM lookups switched to use api_mpt_client; search_uom_by_name accepts optional name and returns clearer 404s - Tests refactored to mock the new MPT API client and collection_service behaviors (use mocker.Mock, model-based test data); fixtures and many test cases adjusted accordingly - Test utilities updated (ANSI-stripping helper, mock factories) and small TODO comment added for settings-update handling <!-- end of auto-generated comment: release notes by coderabbit.ai --> [MPT-14951]: https://softwareone.atlassian.net/browse/MPT-14951?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2 parents 54ffe71 + 925d4d2 commit c7d92be

34 files changed

+704
-299
lines changed

cli/core/accounts/containers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from cli.core.accounts.app import get_active_account
22
from cli.core.mpt.client import client_from_account
3+
from cli.core.mpt.mpt_client import create_api_mpt_client_from_account
34
from dependency_injector import containers, providers
45

56

@@ -10,8 +11,10 @@ class AccountContainer(containers.DeclarativeContainer):
1011
Attributes:
1112
account: Provides the active account.
1213
mpt_client: Provides the MPT client based on the active account.
14+
api_mpt_client: Provides the API MPT client based on the active account.
1315
1416
"""
1517

1618
account = providers.Singleton(get_active_account)
1719
mpt_client = providers.Singleton(client_from_account, account)
20+
api_mpt_client = providers.Singleton(create_api_mpt_client_from_account, account)

cli/core/mpt/api.py

Lines changed: 89 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from abc import ABC
1+
import json as json_module
2+
from abc import ABC, abstractmethod
23
from typing import TYPE_CHECKING, Any
34

4-
from cli.core.errors import MPTAPIError, wrap_http_error
5-
from cli.core.mpt.client import MPTClient
6-
from cli.core.mpt.models import Meta
5+
from cli.core.errors import MPTAPIError, wrap_mpt_api_error
6+
from mpt_api_client import MPTClient, RQLQuery
77

88
if TYPE_CHECKING:
99
from pydantic import BaseModel
@@ -25,7 +25,16 @@ class APIService[APIModel: "BaseModel"](ABC):
2525
_api_model: APIModel
2626

2727
def __init__(self, client: MPTClient):
28-
self.client = client
28+
self._client = client
29+
30+
@property
31+
@abstractmethod
32+
def api_collection(self):
33+
raise NotImplementedError
34+
35+
@property
36+
def client(self) -> MPTClient:
37+
return self._client
2938

3039
@property
3140
def api_model(self) -> APIModel:
@@ -35,7 +44,7 @@ def api_model(self) -> APIModel:
3544
def url(self) -> str:
3645
return self._base_url
3746

38-
@wrap_http_error
47+
@wrap_mpt_api_error
3948
def exists(self, query_params: dict[str, Any] | None = None) -> bool:
4049
"""Check if any resources exist matching the given parameters.
4150
@@ -46,13 +55,15 @@ def exists(self, query_params: dict[str, Any] | None = None) -> bool:
4655
True if at least one resource exists, False otherwise.
4756
4857
"""
49-
query_params = query_params or {}
50-
query_params["limit"] = 0
51-
response = self.client.get(f"{self.url}", params=query_params)
52-
response.raise_for_status()
53-
return response.json()["$meta"]["pagination"]["total"] > 0
54-
55-
@wrap_http_error
58+
service = self.api_collection
59+
if query_params:
60+
service = service.filter(RQLQuery(**query_params))
61+
service_collection_data = service.fetch_page(limit=0)
62+
if service_collection_data.meta is None or service_collection_data.meta.pagination is None:
63+
raise MPTAPIError("Missing pagination metadata in response.", "Invalid response")
64+
return service_collection_data.meta.pagination.total > 0
65+
66+
@wrap_mpt_api_error
5667
def get(
5768
self,
5869
resource_id: str,
@@ -71,18 +82,15 @@ def get(
7182
MPTAPIError: If the resource is not found.
7283
7384
"""
74-
query_params = query_params or {}
75-
response = self.client.get(f"{self.url}/{resource_id}", params=query_params)
76-
response.raise_for_status()
77-
response_payload = response.json()
78-
if not response_payload:
85+
select_value = (query_params or {}).get("select")
86+
resource_data = self.api_collection.get(resource_id, select=select_value)
87+
if not resource_data:
7988
raise MPTAPIError(
8089
f"Resource with ID {resource_id} not found at {self.url}", "404 not found"
8190
)
82-
self.api_model.model_validate(response_payload)
83-
return response_payload
91+
return resource_data.to_dict()
8492

85-
@wrap_http_error
93+
@wrap_mpt_api_error
8694
def list(self, query_params: dict[str, Any] | None = None) -> dict[str, Any]:
8795
"""List resources with optional query parameters.
8896
@@ -93,17 +101,34 @@ def list(self, query_params: dict[str, Any] | None = None) -> dict[str, Any]:
93101
A dictionary containing meta information and the list of resources.
94102
95103
"""
96-
query_params = query_params or {}
97-
response = self.client.get(self.url, params=query_params)
98-
response.raise_for_status()
99-
json_body = response.json()
100-
meta = Meta.model_validate(json_body["$meta"]["pagination"])
101-
return {"meta": meta.model_dump(), "data": json_body["data"]}
102-
103-
@wrap_http_error
104+
query_params = dict(query_params or {})
105+
limit = query_params.pop("limit", 100)
106+
offset = query_params.pop("offset", 0)
107+
select = query_params.pop("select", None)
108+
service = self.api_collection
109+
110+
if query_params:
111+
service = service.filter(RQLQuery(**query_params))
112+
if select:
113+
service = service.select(select)
114+
115+
collection = service.fetch_page(limit=limit, offset=offset)
116+
if collection.meta is None or collection.meta.pagination is None:
117+
raise MPTAPIError("Missing pagination metadata in response.", "Invalid response")
118+
pagination = collection.meta.pagination
119+
return {
120+
"meta": {
121+
"limit": pagination.limit,
122+
"offset": pagination.offset,
123+
"total": pagination.total,
124+
},
125+
"data": [resource.to_dict() for resource in collection.resources],
126+
}
127+
128+
@wrap_mpt_api_error
104129
def post(
105130
self,
106-
form_payload: dict[str, Any] | None = None,
131+
form_payload: Any | None = None,
107132
json: dict[str, Any] | None = None,
108133
headers: dict[str, Any] | None = None,
109134
) -> dict[str, Any]:
@@ -119,11 +144,22 @@ def post(
119144
120145
"""
121146
headers = headers or {}
122-
response = self.client.post(self.url, data=form_payload, json=json, headers=headers)
123-
response.raise_for_status()
124-
return self.api_model.model_validate(response.json(), by_alias=True).model_dump()
125147

126-
@wrap_http_error
148+
if form_payload is None:
149+
created_resource = self.api_collection.create(json)
150+
else:
151+
json_data = None
152+
file_data = None
153+
for form_value in form_payload.fields.values():
154+
if isinstance(form_value, tuple):
155+
file_data = form_value
156+
elif isinstance(form_value, str):
157+
json_data = json_module.loads(form_value)
158+
created_resource = self.api_collection.create(json_data, file=file_data)
159+
160+
return created_resource.to_dict()
161+
162+
@wrap_mpt_api_error
127163
def post_action(self, resource_id: str, action: str) -> None:
128164
"""Perform an action on a specific resource.
129165
@@ -132,10 +168,12 @@ def post_action(self, resource_id: str, action: str) -> None:
132168
action: The action to perform.
133169
134170
"""
135-
response = self.client.post(f"{self.url}/{resource_id}/{action}")
136-
response.raise_for_status()
171+
action_handler = getattr(self.api_collection, action, None)
172+
if action_handler is None:
173+
raise MPTAPIError(f"Unsupported action '{action}' at {self.url}", "400 bad request")
174+
return action_handler(resource_id)
137175

138-
@wrap_http_error
176+
@wrap_mpt_api_error
139177
def update(self, resource_id: str, json_payload: dict[str, Any]) -> None:
140178
"""Update a resource by its ID.
141179
@@ -144,14 +182,25 @@ def update(self, resource_id: str, json_payload: dict[str, Any]) -> None:
144182
json_payload: The data to update the resource with.
145183
146184
"""
147-
response = self.client.put(f"{self.url}/{resource_id}", json=json_payload)
148-
response.raise_for_status()
185+
if "/" in resource_id:
186+
base_id, sub_resource = resource_id.split("/", 1)
187+
updater = getattr(self.api_collection, f"update_{sub_resource}", None)
188+
if updater is None:
189+
raise MPTAPIError(
190+
f"Unsupported sub-resource update '{sub_resource}' at {self.url}",
191+
"400 bad request",
192+
)
193+
updated_resource = updater(base_id, json_payload)
194+
else:
195+
updated_resource = self.api_collection.update(resource_id, json_payload)
196+
197+
return updated_resource.to_dict()
149198

150199

151200
class RelatedAPIService(APIService, ABC):
152201
"""Abstract base class for related API service operations."""
153202

154-
def __init__(self, client, resource_id: str):
203+
def __init__(self, client: MPTClient, resource_id: str):
155204
super().__init__(client)
156205
self.resource_id = resource_id
157206

cli/core/mpt/flows.py

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1-
from functools import cache
2-
from urllib.parse import quote_plus
3-
4-
from cli.core.errors import MPTAPIError, wrap_http_error
5-
from cli.core.mpt.client import MPTClient
1+
from cli.core.errors import MPTAPIError, wrap_mpt_api_error
62
from cli.core.mpt.models import (
73
ListResponse,
84
Meta,
95
Product,
106
Uom,
117
)
8+
from mpt_api_client import MPTClient, RQLQuery
129

1310

14-
@wrap_http_error
11+
@wrap_mpt_api_error
1512
def get_products(
1613
mpt_client: MPTClient, limit: int, offset: int, query: str | None = None
1714
) -> ListResponse[Product]:
@@ -27,20 +24,20 @@ def get_products(
2724
A tuple containing pagination metadata and a list of Product objects.
2825
2926
"""
30-
url = f"/catalog/products?limit={quote_plus(str(limit))}&offset={quote_plus(str(offset))}"
27+
product_collection = mpt_client.catalog.products
3128
if query:
32-
url = f"{url}&{quote_plus(query)}"
33-
response = mpt_client.get(url)
34-
response.raise_for_status()
35-
json_body = response.json()
29+
product_collection = product_collection.filter(RQLQuery.from_string(query))
30+
product_response = product_collection.fetch_page(limit=limit, offset=offset)
31+
if product_response.meta is None or product_response.meta.pagination is None:
32+
raise MPTAPIError("Missing pagination metadata in product response.", "Invalid response")
33+
pagination = product_response.meta.pagination
3634
return (
37-
Meta.model_validate(json_body["$meta"]["pagination"]),
38-
[Product.model_validate(product_data) for product_data in json_body["data"]],
35+
Meta(limit=pagination.limit, offset=pagination.offset, total=pagination.total),
36+
[Product.model_validate(resource.to_dict()) for resource in product_response.resources],
3937
)
4038

4139

42-
@cache
43-
@wrap_http_error
40+
@wrap_mpt_api_error
4441
def search_uom_by_name(mpt_client: MPTClient, uom_name: str) -> Uom:
4542
"""Searches for a unit of measure by name using the MPT Platform.
4643
@@ -55,11 +52,11 @@ def search_uom_by_name(mpt_client: MPTClient, uom_name: str) -> Uom:
5552
MPTAPIError: If the unit of measure is not found.
5653
5754
"""
58-
response = mpt_client.get(f"/catalog/units-of-measure?name={uom_name}&limit=1&offset=0")
59-
response.raise_for_status()
55+
uom_collection = mpt_client.catalog.units_of_measure.filter(RQLQuery(name=uom_name))
56+
57+
collection_page = uom_collection.fetch_page(limit=1, offset=0)
6058

61-
response_data = response.json()["data"]
62-
if not response_data:
59+
if not collection_page.resources:
6360
raise MPTAPIError(f"Unit of measure by name '{uom_name}' is not found.", "404 not found")
6461

65-
return Uom.model_validate(response_data[0])
62+
return Uom.model_validate(collection_page.resources[0].to_dict())

cli/core/mpt/mpt_client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from cli.core.accounts.models import Account
2+
from mpt_api_client import MPTClient
3+
4+
5+
def create_api_mpt_client_from_account(account: Account):
6+
"""Create an API client MPTClient instance using credentials from the given account.
7+
8+
Args:
9+
account: An Account object containing the base URL and API token.
10+
11+
Returns:
12+
An instance of MPTClient to be used for API Client operations.
13+
"""
14+
return MPTClient.from_config(api_token=account.token, base_url=account.environment)

cli/core/price_lists/api/price_list_api_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class PriceListAPIService(APIService):
77

88
_base_url = "/catalog/price-lists/"
99
_api_model = PriceList
10+
11+
@property
12+
def api_collection(self):
13+
return self._client.catalog.price_lists

cli/core/price_lists/api/price_list_item_api_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ def __init__(self, client, price_list_id: str):
1212
super().__init__(client)
1313
self._price_list_id = price_list_id
1414

15+
@property
16+
def api_collection(self):
17+
return self._client.catalog.price_lists.items(self._price_list_id)
18+
1519
@property
1620
def url(self) -> str:
1721
return self._base_url.format(price_list_id=self._price_list_id)

cli/core/price_lists/app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from cli.core.accounts.app import get_active_account
66
from cli.core.console import console
77
from cli.core.file_discovery import get_files_path
8-
from cli.core.mpt.client import client_from_account
8+
from cli.core.mpt.mpt_client import create_api_mpt_client_from_account
99
from cli.core.price_lists.api import PriceListAPIService
1010
from cli.core.price_lists.api.price_list_item_api_service import PriceListItemAPIService
1111
from cli.core.price_lists.handlers import (
@@ -49,7 +49,7 @@ def sync_price_lists( # noqa: C901
4949
)
5050

5151
active_account = get_active_account()
52-
mpt_client = client_from_account(active_account)
52+
mpt_client = create_api_mpt_client_from_account(active_account)
5353
stats = PriceListStatsCollector()
5454
has_error = False
5555
for file_path in file_paths:
@@ -142,7 +142,7 @@ def export( # noqa: C901
142142
raise typer.Exit(code=4)
143143

144144
out_path = out_path if out_path is not None else str(Path.cwd())
145-
mpt_client = client_from_account(active_account)
145+
mpt_client = create_api_mpt_client_from_account(active_account)
146146
stats = PriceListStatsCollector()
147147
has_error = False
148148
for price_list_id in price_list_ids:

cli/core/products/api/item_api_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class ItemAPIService(RelatedAPIService):
77

88
_base_url = "/catalog/items"
99
_api_model = MPTItem
10+
11+
@property
12+
def api_collection(self):
13+
return self._client.catalog.items

cli/core/products/api/item_group_api_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class ItemGroupAPIService(RelatedAPIService):
77

88
_base_url = "/catalog/products/{resource_id}/item-groups"
99
_api_model = ItemGroup
10+
11+
@property
12+
def api_collection(self):
13+
return self._client.catalog.products.item_groups(self.resource_id)

cli/core/products/api/parameter_group_api_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class ParameterGroupAPIService(RelatedAPIService):
77

88
_base_url = "/catalog/products/{resource_id}/parameter-groups"
99
_api_model = ParameterGroup
10+
11+
@property
12+
def api_collection(self):
13+
return self._client.catalog.products.parameter_groups(self.resource_id)

0 commit comments

Comments
 (0)