Skip to content

Commit 9f6165a

Browse files
committed
[SAC-27536] Frontapp App libary changes
1 parent e50cc1d commit 9f6165a

File tree

6 files changed

+177
-19
lines changed

6 files changed

+177
-19
lines changed

.circleci/config.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ version: 2
22
jobs:
33
build:
44
docker:
5-
- image: 218546966473.dkr.ecr.us-east-1.amazonaws.com/circle-ci:tap-tester-v4
5+
- image: 218546966473.dkr.ecr.us-east-1.amazonaws.com/circle-ci:stitch-tap-tester
6+
67
steps:
78
- checkout
89
- run:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,6 @@ tags
102102
singer-check-tap-data
103103
state.json
104104
catalog.json
105+
output.json
106+
convert_json_csv.py
107+
notes_frontapp.txt

example.config.json

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

setup.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
url="http://singer.io",
1111
classifiers=["Programming Language :: Python :: 3 :: Only"],
1212
install_requires=[
13-
"singer-python==5.4.0",
14-
"pendulum",
15-
"ratelimit",
16-
"backoff==1.3.2",
17-
"requests==2.31.0",
13+
"singer-python==6.1.1",
14+
"pendulum==3.1.0",
15+
"ratelimit==2.2.1",
16+
"backoff==2.2.1",
17+
"requests==2.32.3",
1818
],
1919
entry_points="""
2020
[console_scripts]
@@ -25,4 +25,4 @@
2525
"schemas": ["tap_frontapp/schemas/*.json"]
2626
},
2727
include_package_data=True
28-
)
28+
)

tap_frontapp/__init__.py

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,40 +23,117 @@
2323
# call to the api but with the odd frontapp structure, we won't do that
2424
# here we never use atx in here since the schema is from file but we
2525
# would use it if we pulled schema from the API def discover(atx):
26+
27+
2628
def discover():
2729
catalog = Catalog([])
30+
31+
# Build initial catalog from schema files
2832
for tap_stream_id in schemas.STATIC_SCHEMA_STREAM_IDS:
29-
#print("tap stream id=",tap_stream_id)
33+
LOGGER.info("tap stream id=%s", tap_stream_id)
3034
schema = Schema.from_dict(schemas.load_schema(tap_stream_id))
3135
metadata = []
36+
37+
# Stream-level metadata select the stream
38+
metadata.append({
39+
"metadata": {
40+
"selected": True # Make sure to select every stream
41+
},
42+
"breadcrumb": []
43+
})
44+
45+
# Field level metadata with inclusion type
3246
for field_name in schema.properties.keys():
33-
#print("field name=",field_name)
3447
if field_name in schemas.PK_FIELDS[tap_stream_id]:
3548
inclusion = 'automatic'
3649
else:
3750
inclusion = 'available'
3851
metadata.append({
39-
'metadata': {
40-
'inclusion': inclusion
52+
"metadata": {
53+
"inclusion": inclusion
4154
},
42-
'breadcrumb': ['properties', field_name]
55+
"breadcrumb": ['properties', field_name]
4356
})
57+
4458
catalog.streams.append(CatalogEntry(
4559
stream=tap_stream_id,
4660
tap_stream_id=tap_stream_id,
4761
key_properties=schemas.PK_FIELDS[tap_stream_id],
4862
schema=schema,
4963
metadata=metadata
5064
))
51-
return catalog
65+
66+
# Creating a dict to change before converting
67+
catalog_dict = catalog.to_dict()
68+
69+
required_streams = [
70+
"accounts_table",
71+
"channels_table",
72+
"inboxes_table",
73+
"tags_table",
74+
"teammates_table",
75+
"teams_table"
76+
]
77+
78+
# We verify this to ensure all mandatory streams are available even if schema files are missing
79+
present_streams = {stream['tap_stream_id'] for stream in catalog_dict['streams']}
80+
81+
# Ensure all required streams are included even if schema is missing
82+
for stream_name in required_streams:
83+
if stream_name not in present_streams:
84+
85+
LOGGER.info("Adding missing required stream: %s", stream_name)
86+
87+
# Create a minimal stream entry that will be visible in the output
88+
catalog_dict['streams'].append({
89+
"stream": stream_name,
90+
"tap_stream_id": stream_name,
91+
"schema": {
92+
"type": ['null', 'object'],
93+
"properties": {},
94+
"additionalProperties": False
95+
},
96+
"key_properties": [],
97+
"metadata": [
98+
{
99+
"metadata": {
100+
"selected": True
101+
},
102+
"breadcrumb": []
103+
}
104+
]
105+
})
106+
107+
# This ensure singer tools recognize all streams in the catalog dictionary
108+
for stream in catalog_dict['streams']:
109+
has_selection = False
110+
for metadata_item in stream.get('metadata', []):
111+
if metadata_item.get('breadcrumb') == [] and metadata_item.get('metadata', {}).get('selected') is True:
112+
has_selection = True
113+
break
114+
115+
if not has_selection:
116+
LOGGER.info("Adding selection metadata to stream: %s", stream['tap_stream_id'])
117+
stream.setdefault('metadata', []).insert(0, {
118+
"metadata": {
119+
"selected": True
120+
},
121+
"breadcrumb": []
122+
})
123+
124+
# Convert back to a Catalog object
125+
modified_catalog = Catalog.from_dict(catalog_dict)
126+
127+
128+
return modified_catalog
52129

53130

54131
def get_abs_path(path: str):
55132
"""Returns absolute path for URL."""
56133
return os.path.join(os.path.dirname(os.path.realpath(__file__)), path)
57134

58135

59-
# this is already defined in schemas.py though w/o dependencies. do we keep this for the sync?
136+
# this is already defined in schemas.py though w/o dependencies
60137
def load_schema(tap_stream_id):
61138
path = "schemas/{}.json".format(tap_stream_id)
62139
schema = utils.load_json(get_abs_path(path))
@@ -90,4 +167,4 @@ def main():
90167
sync(atx)
91168

92169
if __name__ == "__main__":
93-
main()
170+
main()

tests/unittests/test_client.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import unittest
2+
from unittest.mock import patch
3+
import requests
4+
from tap_frontapp.http import Client, RateLimitException, MetricsRateLimitException
5+
from requests.exceptions import Timeout, ConnectionError
6+
import json
7+
8+
9+
class MockResponse:
10+
"""Mock response object to simulate API calls."""
11+
def __init__(self, status_code, headers=None, json_data=None, raise_for_status=False):
12+
self.status_code = status_code
13+
self.headers = headers or {}
14+
self._json_data = json_data or {}
15+
self._raise_for_status = raise_for_status
16+
self.content = json.dumps(self._json_data)
17+
self.text = json.dumps(self._json_data)
18+
19+
def json(self):
20+
return self._json_data
21+
22+
def raise_for_status(self):
23+
if self._raise_for_status:
24+
raise requests.HTTPError("Mocker Error")
25+
return self.status_code
26+
27+
28+
def get_mock_response(status_code=200, raise_for_status=False, json_data=None, headers=None):
29+
return MockResponse(status_code, headers=headers, json_data=json_data, raise_for_status=raise_for_status)
30+
31+
32+
class TestFrontAppClient(unittest.TestCase):
33+
34+
@patch("requests.request")
35+
def test_successful_request(self, mock_request):
36+
mock_request.return_value = get_mock_response(
37+
status_code=200,
38+
headers={"X-Ratelimit-Remaining": "100", "X-Ratelimit-Reset": "1000"},
39+
json_data={"metrics": [{"id": "m1"}]}
40+
)
41+
client = Client(config={"token": "test-token"})
42+
response = client.get_report_metrics("https://api2.frontapp.com/analytics/reports/xyz")
43+
self.assertEqual(response, [{"id": "m1"}])
44+
45+
@patch("requests.request")
46+
def test_rate_limit_429(self, mock_request):
47+
mock_request.return_value = get_mock_response(
48+
status_code=429,
49+
headers={"X-Ratelimit-Remaining": "0", "X-Ratelimit-Reset": "999"},
50+
raise_for_status=True
51+
)
52+
client = Client(config={"token": "test-token"})
53+
with self.assertRaises(RateLimitException):
54+
client.get_report_metrics("https://api2.frontapp.com/analytics/reports/xyz")
55+
56+
@patch("requests.request")
57+
def test_metrics_rate_limit_423(self, mock_request):
58+
mock_request.return_value = get_mock_response(
59+
status_code=423,
60+
headers={"X-Ratelimit-Remaining": "10", "X-Ratelimit-Reset": "999"},
61+
raise_for_status=True
62+
)
63+
client = Client(config={"token": "test-token"})
64+
with self.assertRaises(MetricsRateLimitException):
65+
client.get_report_metrics("https://api2.frontapp.com/analytics/reports/xyz")
66+
67+
@patch("requests.request", side_effect=Timeout)
68+
def test_timeout_handling(self, mock_request):
69+
client = Client(config={"token": "test-token"})
70+
with self.assertRaises(Timeout):
71+
client.get_report_metrics("https://api2.frontapp.com/analytics/reports/xyz")
72+
73+
@patch("requests.request", side_effect=ConnectionError)
74+
def test_connection_error_handling(self, mock_request):
75+
client = Client(config={"token": "test-token"})
76+
with self.assertRaises(ConnectionError):
77+
client.get_report_metrics("https://api2.frontapp.com/analytics/reports/xyz")
78+
79+
80+
if __name__ == "__main__":
81+
unittest.main()

0 commit comments

Comments
 (0)