Skip to content
This repository was archived by the owner on Jun 28, 2022. It is now read-only.

Commit 31403b5

Browse files
jdrakens-circle-ci
andauthored
LEXIO-37868 Initial CRMA REST API client (#1)
* init * conn * readme * cci * Version bumped to 0.2.0 * interface * readme Co-authored-by: ns-circle-ci <[email protected]>
1 parent 4384e57 commit 31403b5

File tree

18 files changed

+2000
-25
lines changed

18 files changed

+2000
-25
lines changed

.circleci/config.yml

Lines changed: 365 additions & 0 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
**/*.egg-info
99
**/*.pyc
1010
**/dist
11+
not a tty
1112

1213
# Pycharm
1314
.idea

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ repos:
6767
entry: black
6868
language: python
6969
types: [file, python]
70-
additional_dependencies: [black==21.9b0]
70+
additional_dependencies: [black==22.3.0]
7171

7272
- id: mypy
7373
name: Type check Python (mypy)

README.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ CRM Analytics REST API Client
55

66
Features:
77

8-
- <!-- list of features -->
8+
- Execute SAQL queries
9+
- List dataset versions
910

1011
Table of Contents:
1112

@@ -25,7 +26,46 @@ poetry add crma-api-client
2526

2627
## Guide
2728

28-
<!-- Subsections explaining how to use the package -->
29+
First, you need to create a new client instance. To do that, you either need to have credentials for an OAuth app or an existing access token handy:
30+
31+
```python
32+
from crma_api_client.client import ConnectionInfo, CRMAAPIClient
33+
34+
# Generate connection info if you don't already have an access token
35+
conn = await ConnectionInfo.generate(
36+
client_id="abc123",
37+
client_secret="***",
38+
username="[email protected]",
39+
password="***"
40+
)
41+
42+
# If you already have an instance URL and access token, you can instantiate directly
43+
conn = ConnectionInfo(instance_url="https://company.my.salesforce.com", access_token="XYZ123")
44+
45+
# Create the client, passing in the connection object
46+
client = CRMAAPIClient(conn)
47+
```
48+
49+
Next, you can use methods on the client to make requests:
50+
51+
```python
52+
response = await client.list_dataset_versions("Sample_Superstore_xls_Orders")
53+
version = response.versions[0]
54+
query = "\n".join(
55+
[
56+
f"""q = load "{version.dataset.id}/{version.id}";""",
57+
"""q = group q by 'Category';""",
58+
"""q = foreach q generate q.'Category' as 'Category', sum(q.'Sales') as 'Sales';""",
59+
"""q = order q by 'Category' asc;""",
60+
]
61+
)
62+
response = await client.query(query)
63+
assert response.results.records == [
64+
{"Category": "Furniture", "Sales": 741999.7953},
65+
{"Category": "Office Supplies", "Sales": 719047.032},
66+
{"Category": "Technology", "Sales": 836154.033},
67+
]
68+
```
2969

3070
## Development
3171

crma_api_client/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
"""CRM Analytics REST API Client"""
22

3-
__version__ = "0.1.0"
3+
from .client import CRMAAPIClient
4+
5+
__version__ = "0.2.0"

crma_api_client/client.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Contains the CRMA API client"""
2+
3+
import logging
4+
from typing import Any, Dict, Optional
5+
from uuid import uuid4
6+
7+
import backoff
8+
import httpx
9+
from pydantic import BaseModel
10+
11+
from crma_api_client.resources.dataset import DatasetVersionsResponse
12+
from crma_api_client.resources.query import QueryLanguage, QueryResponse
13+
from .encoder import json_dumps_common
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class ConnectionInfo(BaseModel):
19+
"""Model with info for making API requests to a Salesforce instance"""
20+
21+
instance_url: str
22+
access_token: str
23+
token_type: str = "Bearer"
24+
25+
@property
26+
def authorization(self) -> str:
27+
"""Returns authorization header value"""
28+
return f"{self.token_type} {self.access_token}"
29+
30+
@classmethod
31+
async def generate(
32+
cls,
33+
client_id: str,
34+
client_secret: str,
35+
username: str,
36+
password: str,
37+
grant_type: str = "password",
38+
) -> "ConnectionInfo":
39+
"""Create a connection info object by generating a fresh token
40+
41+
See https://developer.salesforce.com/docs/atlas.en-us.bi_dev_guide_rest.meta/bi_dev_guide_rest/bi_rest_authentication.htm
42+
43+
Args:
44+
client_id: OAuth app client ID
45+
client_secret: OAuth app client secret
46+
username: Username for the user calling the API
47+
password: Password for the user calling the API
48+
grant_type: OAuth grant type
49+
50+
Returns:
51+
new ConnectionInfo object
52+
53+
"""
54+
async with httpx.AsyncClient() as client:
55+
response = await client.post(
56+
"https://login.salesforce.com/services/oauth2/token",
57+
data={
58+
"client_id": client_id,
59+
"client_secret": client_secret,
60+
"username": username,
61+
"password": password,
62+
"grant_type": grant_type,
63+
},
64+
headers={"Accept": "application/json"},
65+
)
66+
return cls.parse_obj(response.json())
67+
68+
69+
class CRMAAPIClient:
70+
"""CRM Analytics REST API client"""
71+
72+
def __init__(
73+
self,
74+
conn: ConnectionInfo,
75+
version: str = "v54.0",
76+
timeout: float = 60.0,
77+
connect_timeout: float = 5.0,
78+
logger: logging.Logger = logger,
79+
) -> None:
80+
"""Initialize the CRMAAPIClient
81+
82+
Args:
83+
conn: Object containing for making API requests to a Salesforce instance.
84+
version: CRMA REST API version
85+
timeout: Default timeout for requests and non-connect operations, in seconds
86+
connect_timeout: Default timeout for establishing an HTTP connection, in
87+
seconds
88+
logger: Custom logger instance to use instead of the stdlib
89+
90+
"""
91+
self.logger = logger
92+
self._client = httpx.AsyncClient(
93+
base_url=conn.instance_url.rstrip("/") + f"/services/data/{version}",
94+
headers={"Authorization": conn.authorization},
95+
timeout=httpx.Timeout(timeout, connect=connect_timeout),
96+
)
97+
98+
async def _get_headers(
99+
self,
100+
) -> Dict[str, str]:
101+
"""Get a headers dict from the calling context
102+
103+
Returns:
104+
Dict containing headers to include in requests
105+
106+
"""
107+
headers = {
108+
"content-type": "application/json",
109+
"accept": "application/json",
110+
}
111+
112+
return headers
113+
114+
@backoff.on_exception(
115+
backoff.expo,
116+
httpx.HTTPStatusError,
117+
max_tries=3,
118+
giveup=lambda e: e.response.status_code != 502,
119+
)
120+
async def request(
121+
self,
122+
path: str,
123+
method: str,
124+
json_data: Optional[Any] = None,
125+
params: Optional[Dict[str, Any]] = None,
126+
**kwargs: Any,
127+
) -> httpx.Response:
128+
"""Generic method to send a JSON request to the service
129+
130+
Args:
131+
path: Path to the API resource. This path will be appended to the base url.
132+
method: HTTP method
133+
json_data: Request payload (for POST/PUT/PATCH requests)
134+
params: Request query params
135+
136+
Returns:
137+
response object
138+
139+
"""
140+
path = "/" + path.strip("/")
141+
if json_data:
142+
json_data = json_dumps_common(json_data).encode()
143+
headers = await self._get_headers()
144+
self.logger.debug(f"Service request starting path={path} method={method}")
145+
response = await self._client.request(
146+
method.upper(),
147+
path,
148+
headers=headers,
149+
content=json_data,
150+
params=params,
151+
**kwargs,
152+
)
153+
self.logger.debug(
154+
f"Service request completed status_code={response.status_code}"
155+
)
156+
response.raise_for_status()
157+
return response
158+
159+
async def list_dataset_versions(self, identifier: str) -> DatasetVersionsResponse:
160+
"""List the versions for a dataset
161+
162+
Args:
163+
identifier: Dataset name or ID
164+
165+
Returns:
166+
DatasetVersionsResponse object
167+
168+
"""
169+
response = await self.request(f"/wave/datasets/{identifier}/versions", "GET")
170+
return DatasetVersionsResponse.parse_obj(response.json())
171+
172+
async def query(
173+
self,
174+
query: str,
175+
query_language: QueryLanguage = QueryLanguage.saql,
176+
name: Optional[str] = None,
177+
timezone: Optional[str] = None,
178+
) -> QueryResponse:
179+
"""Execute a query
180+
181+
Args:
182+
query: Query string
183+
query_language: Query language. One of: SAQL (default), SQL
184+
name: Query name. Defaults to a UUID
185+
timezone: Timezone for the query
186+
187+
Returns:
188+
QueryResponse object
189+
190+
"""
191+
json_data = {
192+
"query": query,
193+
"name": name or str(uuid4()),
194+
"queryLanguage": query_language.value,
195+
}
196+
if timezone:
197+
json_data["timezone"] = timezone
198+
199+
response = await self.request("/wave/query", "POST", json_data=json_data)
200+
201+
return QueryResponse.parse_obj(response.json())

crma_api_client/encoder.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Contains JSON encoders"""
2+
3+
from datetime import date, datetime
4+
from enum import Enum
5+
import functools
6+
import json
7+
from typing import Any
8+
9+
10+
class CommonEncoder(json.JSONEncoder):
11+
"""Custom JSON encoder to serialize commonly-used objects"""
12+
13+
def _encode(self, obj: Any) -> Any:
14+
"""Method to handle serialization of custom objects
15+
16+
Defaults to stringifying the object.
17+
18+
Args:
19+
obj: Node in the data structure to serialize
20+
21+
Returns:
22+
transformed data structure to include in final JSON output
23+
24+
"""
25+
if isinstance(obj, (str, bool, int, float)):
26+
return obj
27+
elif isinstance(obj, bytes):
28+
return obj.decode()
29+
elif isinstance(obj, datetime):
30+
return obj.isoformat()
31+
elif isinstance(obj, date):
32+
return str(obj)
33+
elif isinstance(obj, set):
34+
return {self._encode(key): True for key in obj}
35+
elif isinstance(obj, Enum):
36+
return obj.value
37+
38+
for method in ("to_dict", "dict"):
39+
to_dict_method = getattr(obj, method, None)
40+
if callable(to_dict_method):
41+
obj = to_dict_method()
42+
break
43+
44+
if isinstance(obj, dict):
45+
return {
46+
self._encode(key): self._encode(value) for key, value in obj.items()
47+
}
48+
elif isinstance(obj, (list, tuple)):
49+
return [self._encode(value) for value in obj]
50+
51+
return str(obj)
52+
53+
def encode(self, obj: Any) -> str:
54+
"""Method to handle serialization of objects
55+
56+
Proxies to custom internal method.
57+
58+
Args:
59+
obj: Node in the data structure to serialize
60+
61+
Returns:
62+
serialized JSON output
63+
64+
"""
65+
return super().encode(self._encode(obj))
66+
67+
68+
#: Function to serialize a data structure using the CommonEncoder
69+
json_dumps_common = functools.partial(json.dumps, cls=CommonEncoder)

crma_api_client/example.py

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)