linkedapi 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linkedapi/__init__.py +127 -0
- linkedapi/admin/__init__.py +13 -0
- linkedapi/admin/accounts.py +91 -0
- linkedapi/admin/admin.py +22 -0
- linkedapi/admin/http_client.py +73 -0
- linkedapi/admin/limits.py +70 -0
- linkedapi/admin/subscription.py +69 -0
- linkedapi/client.py +160 -0
- linkedapi/config.py +10 -0
- linkedapi/core/__init__.py +4 -0
- linkedapi/core/operation.py +89 -0
- linkedapi/core/polling.py +48 -0
- linkedapi/errors.py +119 -0
- linkedapi/http/__init__.py +4 -0
- linkedapi/http/base.py +22 -0
- linkedapi/http/linked_api_http_client.py +74 -0
- linkedapi/mappers/__init__.py +16 -0
- linkedapi/mappers/array.py +49 -0
- linkedapi/mappers/base.py +70 -0
- linkedapi/mappers/simple.py +56 -0
- linkedapi/mappers/then.py +175 -0
- linkedapi/mappers/void.py +33 -0
- linkedapi/operations/__init__.py +58 -0
- linkedapi/operations/check_connection_status.py +15 -0
- linkedapi/operations/comment_on_post.py +12 -0
- linkedapi/operations/create_post.py +15 -0
- linkedapi/operations/custom_workflow.py +22 -0
- linkedapi/operations/fetch_company.py +35 -0
- linkedapi/operations/fetch_person.py +43 -0
- linkedapi/operations/fetch_post.py +33 -0
- linkedapi/operations/nv_fetch_company.py +33 -0
- linkedapi/operations/nv_fetch_person.py +23 -0
- linkedapi/operations/nv_search_companies.py +15 -0
- linkedapi/operations/nv_search_people.py +15 -0
- linkedapi/operations/nv_send_message.py +12 -0
- linkedapi/operations/nv_sync_conversation.py +12 -0
- linkedapi/operations/react_to_post.py +12 -0
- linkedapi/operations/remove_connection.py +12 -0
- linkedapi/operations/retrieve_connections.py +15 -0
- linkedapi/operations/retrieve_pending_requests.py +15 -0
- linkedapi/operations/retrieve_performance.py +15 -0
- linkedapi/operations/retrieve_ssi.py +15 -0
- linkedapi/operations/search_companies.py +15 -0
- linkedapi/operations/search_people.py +15 -0
- linkedapi/operations/send_connection_request.py +12 -0
- linkedapi/operations/send_message.py +12 -0
- linkedapi/operations/sync_conversation.py +12 -0
- linkedapi/operations/withdraw_connection_request.py +12 -0
- linkedapi/py.typed +1 -0
- linkedapi/types/__init__.py +336 -0
- linkedapi/types/account.py +8 -0
- linkedapi/types/admin/__init__.py +91 -0
- linkedapi/types/admin/accounts.py +71 -0
- linkedapi/types/admin/config.py +8 -0
- linkedapi/types/admin/limits.py +77 -0
- linkedapi/types/admin/subscription.py +58 -0
- linkedapi/types/base.py +47 -0
- linkedapi/types/company.py +140 -0
- linkedapi/types/connection.py +86 -0
- linkedapi/types/message.py +48 -0
- linkedapi/types/params.py +15 -0
- linkedapi/types/person.py +105 -0
- linkedapi/types/post.py +119 -0
- linkedapi/types/responses.py +18 -0
- linkedapi/types/search_companies.py +72 -0
- linkedapi/types/search_people.py +46 -0
- linkedapi/types/statistics.py +27 -0
- linkedapi/types/workflow.py +55 -0
- linkedapi-1.0.0.dist-info/METADATA +125 -0
- linkedapi-1.0.0.dist-info/RECORD +72 -0
- linkedapi-1.0.0.dist-info/WHEEL +4 -0
- linkedapi-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from linkedapi.core.polling import poll_workflow_result
|
|
7
|
+
from linkedapi.errors import LinkedApiError, LinkedApiWorkflowTimeoutError
|
|
8
|
+
from linkedapi.http import HttpClient
|
|
9
|
+
from linkedapi.mappers import BaseMapper, MappedResponse
|
|
10
|
+
from linkedapi.types import (
|
|
11
|
+
WorkflowCancelResponse,
|
|
12
|
+
WorkflowCompletion,
|
|
13
|
+
WorkflowInProgressResponse,
|
|
14
|
+
WorkflowResponse,
|
|
15
|
+
WorkflowStartedResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
TParams = TypeVar("TParams")
|
|
19
|
+
TResult = TypeVar("TResult")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Operation(ABC, Generic[TParams, TResult]):
|
|
23
|
+
operation_name: str
|
|
24
|
+
mapper: BaseMapper[TParams, TResult]
|
|
25
|
+
|
|
26
|
+
def __init__(self, http_client: HttpClient[Any]) -> None:
|
|
27
|
+
self.http_client = http_client
|
|
28
|
+
|
|
29
|
+
def execute(self, params: TParams | None = None) -> WorkflowStartedResponse:
|
|
30
|
+
request = self.mapper.map_request(params)
|
|
31
|
+
response = self.http_client.post("/workflows", request)
|
|
32
|
+
if response.error:
|
|
33
|
+
raise LinkedApiError(response.error.type, response.error.message)
|
|
34
|
+
if response.result is None:
|
|
35
|
+
raise LinkedApiError.unknown_error()
|
|
36
|
+
return WorkflowStartedResponse.model_validate(response.result)
|
|
37
|
+
|
|
38
|
+
def result(
|
|
39
|
+
self,
|
|
40
|
+
workflow_id: str,
|
|
41
|
+
*,
|
|
42
|
+
poll_interval: float | None = None,
|
|
43
|
+
timeout: float | None = None,
|
|
44
|
+
) -> MappedResponse[TResult]:
|
|
45
|
+
try:
|
|
46
|
+
return poll_workflow_result(
|
|
47
|
+
lambda: self.status(workflow_id),
|
|
48
|
+
poll_interval=5.0 if poll_interval is None else poll_interval,
|
|
49
|
+
timeout=86400.0 if timeout is None else timeout,
|
|
50
|
+
)
|
|
51
|
+
except LinkedApiError as error:
|
|
52
|
+
if error.type == "workflowTimeout":
|
|
53
|
+
raise LinkedApiWorkflowTimeoutError(workflow_id, self.operation_name) from error
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
def status(self, workflow_id: str) -> WorkflowInProgressResponse | MappedResponse[TResult]:
|
|
57
|
+
workflow_result = self._get_workflow_result(workflow_id)
|
|
58
|
+
if workflow_result.workflow_status in {"running", "pending"}:
|
|
59
|
+
return WorkflowInProgressResponse(
|
|
60
|
+
workflow_id=workflow_id,
|
|
61
|
+
workflow_status=workflow_result.workflow_status,
|
|
62
|
+
message=workflow_result.message,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
completion = self._get_completion(workflow_result)
|
|
66
|
+
return self.mapper.map_response(completion)
|
|
67
|
+
|
|
68
|
+
def cancel(self, workflow_id: str) -> bool:
|
|
69
|
+
response = self.http_client.delete(f"/workflows/{workflow_id}")
|
|
70
|
+
if response.error:
|
|
71
|
+
raise LinkedApiError(response.error.type, response.error.message)
|
|
72
|
+
if response.result is None:
|
|
73
|
+
raise LinkedApiError.unknown_error()
|
|
74
|
+
return WorkflowCancelResponse.model_validate(response.result).cancelled
|
|
75
|
+
|
|
76
|
+
def _get_workflow_result(self, workflow_id: str) -> WorkflowResponse:
|
|
77
|
+
response = self.http_client.get(f"/workflows/{workflow_id}")
|
|
78
|
+
if response.error:
|
|
79
|
+
raise LinkedApiError(response.error.type, response.error.message)
|
|
80
|
+
if response.result is None:
|
|
81
|
+
raise LinkedApiError.unknown_error()
|
|
82
|
+
return WorkflowResponse.model_validate(response.result)
|
|
83
|
+
|
|
84
|
+
def _get_completion(self, response: WorkflowResponse) -> WorkflowCompletion:
|
|
85
|
+
if response.completion is None:
|
|
86
|
+
if response.failure:
|
|
87
|
+
raise LinkedApiError(response.failure.reason, response.failure.message)
|
|
88
|
+
raise LinkedApiError.unknown_error()
|
|
89
|
+
return response.completion
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from linkedapi.errors import LinkedApiError
|
|
8
|
+
from linkedapi.types import WorkflowInProgressResponse
|
|
9
|
+
|
|
10
|
+
TResult = TypeVar("TResult")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def poll_workflow_result(
|
|
14
|
+
workflow_result_fn: Callable[[], TResult | WorkflowInProgressResponse],
|
|
15
|
+
*,
|
|
16
|
+
poll_interval: float = 5.0,
|
|
17
|
+
timeout: float = 86400.0,
|
|
18
|
+
max_invalid_attempts: int = 15,
|
|
19
|
+
) -> TResult:
|
|
20
|
+
start_time = time.monotonic()
|
|
21
|
+
invalid_attempts = 0
|
|
22
|
+
|
|
23
|
+
while time.monotonic() - start_time < timeout:
|
|
24
|
+
try:
|
|
25
|
+
result = workflow_result_fn()
|
|
26
|
+
if not _is_workflow_in_progress(result):
|
|
27
|
+
return cast(TResult, result)
|
|
28
|
+
invalid_attempts = 0
|
|
29
|
+
except LinkedApiError as error:
|
|
30
|
+
if error.type == "httpError":
|
|
31
|
+
invalid_attempts += 1
|
|
32
|
+
if invalid_attempts > max_invalid_attempts:
|
|
33
|
+
raise
|
|
34
|
+
else:
|
|
35
|
+
raise
|
|
36
|
+
|
|
37
|
+
remaining = timeout - (time.monotonic() - start_time)
|
|
38
|
+
if remaining > 0:
|
|
39
|
+
time.sleep(min(poll_interval, remaining))
|
|
40
|
+
|
|
41
|
+
raise LinkedApiError("workflowTimeout", f"Workflow did not complete within {timeout}s")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_workflow_in_progress(value: object) -> bool:
|
|
45
|
+
status = getattr(value, "workflow_status", None)
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
status = value.get("workflowStatus") or value.get("workflow_status")
|
|
48
|
+
return status in {"running", "pending"}
|
linkedapi/errors.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
LinkedApiActionErrorType = Literal[
|
|
6
|
+
"personNotFound",
|
|
7
|
+
"selfProfileNotAllowed",
|
|
8
|
+
"messagingNotAllowed",
|
|
9
|
+
"alreadyPending",
|
|
10
|
+
"alreadyConnected",
|
|
11
|
+
"emailRequired",
|
|
12
|
+
"requestNotAllowed",
|
|
13
|
+
"notPending",
|
|
14
|
+
"retrievingNotAllowed",
|
|
15
|
+
"connectionNotFound",
|
|
16
|
+
"searchingNotAllowed",
|
|
17
|
+
"companyNotFound",
|
|
18
|
+
"postNotFound",
|
|
19
|
+
"commentingNotAllowed",
|
|
20
|
+
"noPostingPermission",
|
|
21
|
+
"noSalesNavigator",
|
|
22
|
+
"conversationsNotSynced",
|
|
23
|
+
]
|
|
24
|
+
LINKED_API_ACTION_ERROR_TYPES: tuple[str, ...] = (
|
|
25
|
+
"personNotFound",
|
|
26
|
+
"selfProfileNotAllowed",
|
|
27
|
+
"messagingNotAllowed",
|
|
28
|
+
"alreadyPending",
|
|
29
|
+
"alreadyConnected",
|
|
30
|
+
"emailRequired",
|
|
31
|
+
"requestNotAllowed",
|
|
32
|
+
"notPending",
|
|
33
|
+
"retrievingNotAllowed",
|
|
34
|
+
"connectionNotFound",
|
|
35
|
+
"searchingNotAllowed",
|
|
36
|
+
"companyNotFound",
|
|
37
|
+
"postNotFound",
|
|
38
|
+
"commentingNotAllowed",
|
|
39
|
+
"noPostingPermission",
|
|
40
|
+
"noSalesNavigator",
|
|
41
|
+
"conversationsNotSynced",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
LinkedApiErrorType = Literal[
|
|
45
|
+
"linkedApiTokenRequired",
|
|
46
|
+
"invalidLinkedApiToken",
|
|
47
|
+
"identificationTokenRequired",
|
|
48
|
+
"invalidIdentificationToken",
|
|
49
|
+
"subscriptionRequired",
|
|
50
|
+
"invalidRequestPayload",
|
|
51
|
+
"invalidWorkflow",
|
|
52
|
+
"plusPlanRequired",
|
|
53
|
+
"linkedinAccountSignedOut",
|
|
54
|
+
"languageNotSupported",
|
|
55
|
+
"workflowTimeout",
|
|
56
|
+
"httpError",
|
|
57
|
+
"tooManyRequests",
|
|
58
|
+
"accountNotFound",
|
|
59
|
+
"accountIdRequired",
|
|
60
|
+
"sessionNotFound",
|
|
61
|
+
"noAvailableSeats",
|
|
62
|
+
"dailyConnectionAttemptsExceeded",
|
|
63
|
+
]
|
|
64
|
+
LINKED_API_ERROR_TYPES: tuple[str, ...] = (
|
|
65
|
+
"linkedApiTokenRequired",
|
|
66
|
+
"invalidLinkedApiToken",
|
|
67
|
+
"identificationTokenRequired",
|
|
68
|
+
"invalidIdentificationToken",
|
|
69
|
+
"subscriptionRequired",
|
|
70
|
+
"invalidRequestPayload",
|
|
71
|
+
"invalidWorkflow",
|
|
72
|
+
"plusPlanRequired",
|
|
73
|
+
"linkedinAccountSignedOut",
|
|
74
|
+
"languageNotSupported",
|
|
75
|
+
"workflowTimeout",
|
|
76
|
+
"httpError",
|
|
77
|
+
"tooManyRequests",
|
|
78
|
+
"accountNotFound",
|
|
79
|
+
"accountIdRequired",
|
|
80
|
+
"sessionNotFound",
|
|
81
|
+
"noAvailableSeats",
|
|
82
|
+
"dailyConnectionAttemptsExceeded",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LinkedApiError(Exception):
|
|
87
|
+
type: str
|
|
88
|
+
message: str
|
|
89
|
+
details: Any | None
|
|
90
|
+
|
|
91
|
+
def __init__(self, type: str, message: str, details: Any | None = None) -> None:
|
|
92
|
+
super().__init__(message)
|
|
93
|
+
self.type = type
|
|
94
|
+
self.message = message
|
|
95
|
+
self.details = details
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def unknown_error(
|
|
99
|
+
cls, message: str = "Unknown error. Please contact support."
|
|
100
|
+
) -> LinkedApiError:
|
|
101
|
+
return cls("unknownError", message)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class LinkedApiWorkflowTimeoutError(LinkedApiError):
|
|
105
|
+
workflow_id: str
|
|
106
|
+
operation_name: str
|
|
107
|
+
|
|
108
|
+
def __init__(self, workflow_id: str, operation_name: str) -> None:
|
|
109
|
+
message = (
|
|
110
|
+
f"Workflow {workflow_id} timed out. Call {operation_name}.result() again to continue "
|
|
111
|
+
"checking the workflow."
|
|
112
|
+
)
|
|
113
|
+
super().__init__(
|
|
114
|
+
"workflowTimeout",
|
|
115
|
+
message,
|
|
116
|
+
{"workflowId": workflow_id, "operationName": operation_name},
|
|
117
|
+
)
|
|
118
|
+
self.workflow_id = workflow_id
|
|
119
|
+
self.operation_name = operation_name
|
linkedapi/http/base.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from linkedapi.types.responses import LinkedApiResponse
|
|
7
|
+
|
|
8
|
+
TResult = TypeVar("TResult")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HttpClient(ABC, Generic[TResult]):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def get(self, path: str) -> LinkedApiResponse[Any]:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def post(self, path: str, data: Any | None = None) -> LinkedApiResponse[Any]:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def delete(self, path: str) -> LinkedApiResponse[Any]:
|
|
22
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from linkedapi.config import LinkedApiConfig
|
|
8
|
+
from linkedapi.errors import LinkedApiError
|
|
9
|
+
from linkedapi.http.base import HttpClient
|
|
10
|
+
from linkedapi.types import LinkedApiResponse, serialize_value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LinkedApiHttpClient(HttpClient[Any]):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
config: LinkedApiConfig,
|
|
17
|
+
client: str | None = None,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
session: requests.Session | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.base_url = (base_url or config.base_url).rstrip("/")
|
|
22
|
+
self.session = session or requests.Session()
|
|
23
|
+
self.headers = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"linked-api-token": config.linked_api_token,
|
|
26
|
+
"identification-token": config.identification_token,
|
|
27
|
+
"client": client or config.client,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def get(self, path: str) -> LinkedApiResponse[Any]:
|
|
31
|
+
return self._request("GET", path)
|
|
32
|
+
|
|
33
|
+
def post(self, path: str, data: Any | None = None) -> LinkedApiResponse[Any]:
|
|
34
|
+
return self._request("POST", path, data)
|
|
35
|
+
|
|
36
|
+
def delete(self, path: str) -> LinkedApiResponse[Any]:
|
|
37
|
+
return self._request("DELETE", path)
|
|
38
|
+
|
|
39
|
+
def _request(self, method: str, path: str, data: Any | None = None) -> LinkedApiResponse[Any]:
|
|
40
|
+
try:
|
|
41
|
+
response = self.session.request(
|
|
42
|
+
method,
|
|
43
|
+
f"{self.base_url}{path}",
|
|
44
|
+
headers=self.headers,
|
|
45
|
+
json=serialize_value(data) if data is not None else None,
|
|
46
|
+
)
|
|
47
|
+
return self._handle_response(response)
|
|
48
|
+
except LinkedApiError:
|
|
49
|
+
raise
|
|
50
|
+
except requests.RequestException as error:
|
|
51
|
+
raise LinkedApiError(
|
|
52
|
+
"httpError", f"Request error: {error}", {"error": error}
|
|
53
|
+
) from error
|
|
54
|
+
|
|
55
|
+
def _handle_response(self, response: requests.Response) -> LinkedApiResponse[Any]:
|
|
56
|
+
if response.ok:
|
|
57
|
+
return LinkedApiResponse[Any].model_validate(response.json())
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
error_data = response.json()
|
|
61
|
+
error = error_data["error"]
|
|
62
|
+
raise LinkedApiError(error["type"], error["message"], error_data)
|
|
63
|
+
except LinkedApiError:
|
|
64
|
+
raise
|
|
65
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
66
|
+
raise LinkedApiError(
|
|
67
|
+
"httpError",
|
|
68
|
+
f"HTTP {response.status_code}: {response.reason}",
|
|
69
|
+
{
|
|
70
|
+
"status": response.status_code,
|
|
71
|
+
"statusText": response.reason,
|
|
72
|
+
"url": response.url,
|
|
73
|
+
},
|
|
74
|
+
) from error
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from linkedapi.mappers.array import ArrayWorkflowMapper
|
|
2
|
+
from linkedapi.mappers.base import BaseMapper, MappedResponse
|
|
3
|
+
from linkedapi.mappers.simple import SimpleWorkflowMapper
|
|
4
|
+
from linkedapi.mappers.then import ActionConfig, ResponseMapping, ThenWorkflowMapper
|
|
5
|
+
from linkedapi.mappers.void import VoidWorkflowMapper
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ActionConfig",
|
|
9
|
+
"ArrayWorkflowMapper",
|
|
10
|
+
"BaseMapper",
|
|
11
|
+
"MappedResponse",
|
|
12
|
+
"ResponseMapping",
|
|
13
|
+
"SimpleWorkflowMapper",
|
|
14
|
+
"ThenWorkflowMapper",
|
|
15
|
+
"VoidWorkflowMapper",
|
|
16
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Generic, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from linkedapi.mappers.base import (
|
|
8
|
+
BaseMapper,
|
|
9
|
+
MappedResponse,
|
|
10
|
+
as_action_dict,
|
|
11
|
+
collect_action_errors,
|
|
12
|
+
parse_action_error,
|
|
13
|
+
parse_result,
|
|
14
|
+
)
|
|
15
|
+
from linkedapi.types import WorkflowCompletion, WorkflowDefinition, serialize_model
|
|
16
|
+
|
|
17
|
+
TParams = TypeVar("TParams")
|
|
18
|
+
TItem = TypeVar("TItem")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ArrayWorkflowMapper(BaseMapper[TParams, list[TItem]], Generic[TParams, TItem]):
|
|
22
|
+
def __init__(self, base_action_type: str, item_model: type[BaseModel] | None = None) -> None:
|
|
23
|
+
self.base_action_type = base_action_type
|
|
24
|
+
self.item_model = item_model
|
|
25
|
+
|
|
26
|
+
def map_request(self, params: TParams | None = None) -> WorkflowDefinition:
|
|
27
|
+
return {"actionType": self.base_action_type, **serialize_model(params)}
|
|
28
|
+
|
|
29
|
+
def map_response(self, completion: WorkflowCompletion) -> MappedResponse[list[TItem]]:
|
|
30
|
+
if isinstance(completion, list):
|
|
31
|
+
data = [as_action_dict(action).get("data") for action in completion]
|
|
32
|
+
errors = collect_action_errors(
|
|
33
|
+
[as_action_dict(action).get("error") for action in completion]
|
|
34
|
+
)
|
|
35
|
+
return MappedResponse(
|
|
36
|
+
data=cast(list[TItem], parse_result(data, self.item_model)), errors=errors
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
action = as_action_dict(completion)
|
|
40
|
+
error = parse_action_error(action.get("error"))
|
|
41
|
+
if error is not None:
|
|
42
|
+
return MappedResponse(data=None, errors=[error])
|
|
43
|
+
|
|
44
|
+
single_data = action.get("data")
|
|
45
|
+
data_list = single_data if isinstance(single_data, list) else [single_data]
|
|
46
|
+
return MappedResponse(
|
|
47
|
+
data=cast(list[TItem], parse_result(data_list, self.item_model)),
|
|
48
|
+
errors=[],
|
|
49
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ValidationError
|
|
8
|
+
|
|
9
|
+
from linkedapi.errors import LinkedApiError
|
|
10
|
+
from linkedapi.types import LinkedApiActionError, WorkflowCompletion, WorkflowDefinition
|
|
11
|
+
|
|
12
|
+
TParams = TypeVar("TParams")
|
|
13
|
+
TResult = TypeVar("TResult")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class MappedResponse(Generic[TResult]):
|
|
18
|
+
data: TResult | None = None
|
|
19
|
+
errors: list[LinkedApiActionError] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseMapper(ABC, Generic[TParams, TResult]):
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def map_request(self, params: TParams | None = None) -> WorkflowDefinition:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def map_response(self, completion: WorkflowCompletion) -> MappedResponse[TResult]:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_action_error(value: Any) -> LinkedApiActionError | None:
|
|
33
|
+
if value is None:
|
|
34
|
+
return None
|
|
35
|
+
if isinstance(value, LinkedApiActionError):
|
|
36
|
+
return value
|
|
37
|
+
return LinkedApiActionError.model_validate(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect_action_errors(values: list[Any]) -> list[LinkedApiActionError]:
|
|
41
|
+
errors: list[LinkedApiActionError] = []
|
|
42
|
+
for value in values:
|
|
43
|
+
parsed = parse_action_error(value)
|
|
44
|
+
if parsed is not None:
|
|
45
|
+
errors.append(parsed)
|
|
46
|
+
return errors
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_result(data: Any, model: type[BaseModel] | None) -> Any:
|
|
50
|
+
if data is None or model is None:
|
|
51
|
+
return data
|
|
52
|
+
try:
|
|
53
|
+
if isinstance(data, list):
|
|
54
|
+
return [model.model_validate(item) for item in data]
|
|
55
|
+
return model.model_validate(data)
|
|
56
|
+
except ValidationError as error:
|
|
57
|
+
raise LinkedApiError(
|
|
58
|
+
"unknownError",
|
|
59
|
+
f"Failed to parse API response: {error}",
|
|
60
|
+
details=error.errors(),
|
|
61
|
+
) from error
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def as_action_dict(action: Any) -> dict[str, Any]:
|
|
65
|
+
if isinstance(action, BaseModel):
|
|
66
|
+
return action.model_dump(by_alias=True, exclude_none=True)
|
|
67
|
+
if isinstance(action, dict):
|
|
68
|
+
return action
|
|
69
|
+
msg = "Expected action object"
|
|
70
|
+
raise TypeError(msg)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Generic, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from linkedapi.mappers.base import (
|
|
8
|
+
BaseMapper,
|
|
9
|
+
MappedResponse,
|
|
10
|
+
as_action_dict,
|
|
11
|
+
collect_action_errors,
|
|
12
|
+
parse_action_error,
|
|
13
|
+
parse_result,
|
|
14
|
+
)
|
|
15
|
+
from linkedapi.types import WorkflowCompletion, WorkflowDefinition, serialize_model
|
|
16
|
+
|
|
17
|
+
TParams = TypeVar("TParams")
|
|
18
|
+
TResult = TypeVar("TResult")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SimpleWorkflowMapper(BaseMapper[TParams, TResult], Generic[TParams, TResult]):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
action_type: str,
|
|
25
|
+
default_params: dict[str, Any] | None = None,
|
|
26
|
+
result_model: type[BaseModel] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.action_type = action_type
|
|
29
|
+
self.default_params = default_params or {}
|
|
30
|
+
self.result_model = result_model
|
|
31
|
+
|
|
32
|
+
def map_request(self, params: TParams | None = None) -> WorkflowDefinition:
|
|
33
|
+
return {"actionType": self.action_type, **self.default_params, **serialize_model(params)}
|
|
34
|
+
|
|
35
|
+
def map_response(self, completion: WorkflowCompletion) -> MappedResponse[TResult]:
|
|
36
|
+
if isinstance(completion, list):
|
|
37
|
+
data = [
|
|
38
|
+
as_action_dict(action).get("data")
|
|
39
|
+
for action in completion
|
|
40
|
+
if as_action_dict(action).get("data")
|
|
41
|
+
]
|
|
42
|
+
errors = collect_action_errors(
|
|
43
|
+
[as_action_dict(action).get("error") for action in completion]
|
|
44
|
+
)
|
|
45
|
+
return MappedResponse(
|
|
46
|
+
data=cast(TResult, parse_result(data, self.result_model)), errors=errors
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
action = as_action_dict(completion)
|
|
50
|
+
error = parse_action_error(action.get("error"))
|
|
51
|
+
if error is not None:
|
|
52
|
+
return MappedResponse(data=None, errors=[error])
|
|
53
|
+
return MappedResponse(
|
|
54
|
+
data=cast(TResult, parse_result(action.get("data"), self.result_model)),
|
|
55
|
+
errors=[],
|
|
56
|
+
)
|