Skip to content

Commit

Permalink
feat(FedEx): Consolidate FedEx REST API and SmartPost integrations wi…
Browse files Browse the repository at this point in the history
…th live tests
  • Loading branch information
danh91 committed Feb 25, 2024
1 parent 7577ea1 commit fc847b4
Show file tree
Hide file tree
Showing 26 changed files with 6,741 additions and 122 deletions.
6 changes: 4 additions & 2 deletions modules/connectors/fedex/karrio/mappers/fedex/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

from karrio.core.metadata import Metadata

from karrio.mappers.fedex.mapper import Mapper
Expand All @@ -15,5 +14,8 @@
Proxy=Proxy,
Settings=Settings,
# Data Units
is_hub=False
is_hub=False,
services=units.ShippingService,
options=units.ShippingOption,
connection_configs=units.ConnectionConfig,
)
117 changes: 81 additions & 36 deletions modules/connectors/fedex/karrio/mappers/fedex/proxy.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,65 @@
import gzip
import urllib.parse
import karrio.lib as lib
import karrio.api.proxy as proxy
from karrio.mappers.fedex.settings import Settings
import karrio.providers.fedex.utils as provider_utils
import karrio.mappers.fedex.settings as provider_settings


class Proxy(proxy.Proxy):
settings: Settings
settings: provider_settings.Settings

def get_rates(self, request: lib.Serializable) -> lib.Deserializable:
response = self._send_request("/rate", request)
response = lib.request(
url=f"{self.settings.server_url}/rate/v1/rates/quotes",
data=lib.to_json(request.serialize()),
trace=self.trace_as("json"),
method="POST",
headers={
"x-locale": "en_US",
"content-type": "application/json",
"authorization": f"Bearer {self.settings.access_token}",
},
decoder=provider_utils.parse_response,
on_error=lambda b: provider_utils.parse_response(b.read()),
)

return lib.Deserializable(response, lib.to_dict, request.ctx)

def get_tracking(self, request: lib.Serializable) -> lib.Deserializable:
response = self._send_request("/track/v1/trackingnumbers", request)
response = lib.request(
url=f"{self.settings.server_url}/track/v1/trackingnumbers",
data=lib.to_json(request.serialize()),
trace=self.trace_as("json"),
method="POST",
headers={
"x-locale": "en_US",
"content-type": "application/json",
"authorization": f"Bearer {self.settings.track_access_token}",
},
decoder=provider_utils.parse_response,
on_error=lambda b: provider_utils.parse_response(b.read()),
)

return lib.Deserializable(response, lib.to_dict)

def create_shipment(self, request: lib.Serializable) -> lib.Deserializable:
requests = request.serialize()
responses = [self._send_request("/ship", lib.Serializable(requests[0]))]
responses = [
lib.request(
url=f"{self.settings.server_url}/ship/v1/shipments",
data=lib.to_json(requests[0]),
trace=self.trace_as("json"),
method="POST",
headers={
"x-locale": "en_US",
"content-type": "application/json",
"authorization": f"Bearer {self.settings.access_token}",
},
decoder=provider_utils.parse_response,
on_error=lambda b: provider_utils.parse_response(b.read()),
)
]
master_id = (
lib.to_dict(responses[0])
.get("output", {})
Expand All @@ -29,13 +69,22 @@ def create_shipment(self, request: lib.Serializable) -> lib.Deserializable:

if len(requests) > 1 and master_id is not None:
responses += lib.run_asynchronously(
lambda _: self._send_request(
"/ship",
lib.Serializable(
request.replace(
"[MASTER_ID_TYPE]", master_id.TrackingIdType
).replace("[MASTER_TRACKING_ID]", master_id.TrackingNumber),
lambda _: lib.request(
url=f"{self.settings.server_url}/ship/v1/shipments",
data=(
lib.to_json(_)
.replace("[MASTER_ID_TYPE]", master_id.TrackingIdType)
.replace("[MASTER_TRACKING_ID]", master_id.TrackingNumber)
),
trace=self.trace_as("json"),
method="POST",
headers={
"x-locale": "en_US",
"content-type": "application/json",
"authorization": f"Bearer {self.settings.access_token}",
},
decoder=provider_utils.parse_response,
on_error=lambda b: provider_utils.parse_response(b.read()),
),
requests[1:],
)
Expand All @@ -46,20 +95,37 @@ def create_shipment(self, request: lib.Serializable) -> lib.Deserializable:
)

def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable:
response = self._send_request("/ship", request)
response = lib.request(
url=f"{self.settings.server_url}/ship/v1/shipments/cancel",
data=lib.to_json(request.serialize()),
trace=self.trace_as("json"),
method="PUT",
headers={
"x-locale": "en_US",
"content-type": "application/json",
"authorization": f"Bearer {self.settings.access_token}",
},
decoder=provider_utils.parse_response,
on_error=lambda b: provider_utils.parse_response(b.read()),
)

return lib.Deserializable(response, lib.to_dict)

def upload_document(self, requests: lib.Serializable) -> lib.Deserializable:
response = lib.run_asynchronously(
lambda _: self._send_request(
lambda _: lib.request(
url=(
"https://documentapitest.prod.fedex.com/sandbox/documents/v1/etds/upload"
if self.settings.test_mode
else "https://documentapi.prod.fedex.com/documents/v1/etds/upload"
),
request=lib.Serializable(_, urllib.parse.urlencode),
headers={"content-Type": "multipart/form-data"},
data=urllib.parse.urlencode(_),
trace=self.trace_as("json"),
method="POST",
headers={
"content-Type": "multipart/form-data",
"authorization": f"Bearer {self.settings.access_token}",
},
),
requests.serialize(),
)
Expand All @@ -68,24 +134,3 @@ def upload_document(self, requests: lib.Serializable) -> lib.Deserializable:
response,
lambda __: [lib.to_dict(_) for _ in __],
)

def _send_request(
self,
path: str = "/",
request: lib.Serializable = None,
method: str = "POST",
headers: dict = None,
url: str = None,
) -> str:
return lib.request(
url=url or f"{self.settings.server_url}{path}",
trace=self.trace_as("json"),
method=method,
headers={
"content-Type": "application/json",
"authorization": f"Bearer {self.settings.access_token}",
"x-locale": self.settings.connection_config.locale.state or "en_US",
**(headers or {}),
},
**({"data": lib.to_json(request.serialize())} if request else {}),
)
6 changes: 4 additions & 2 deletions modules/connectors/fedex/karrio/mappers/fedex/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
class Settings(provider_utils.Settings):
"""FedEx connection settings."""

api_key: str # type:ignore
secret_key: str # type:ignore
api_key: str = None
secret_key: str = None
account_number: str = None
track_api_key: str = None
track_secret_key: str = None

cache: lib.Cache = jstruct.JStruct[lib.Cache, False, dict(default=lib.Cache())]
account_country_code: str = None
Expand Down
5 changes: 4 additions & 1 deletion modules/connectors/fedex/karrio/providers/fedex/rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,10 @@ def rate_request(
fedex.SmartPostInfoDetailType(
ancillaryEndorsement=None,
hubId=hub_id,
indicia=options.fedex_smart_post_allowed_indicia.state,
indicia=(
options.fedex_smart_post_allowed_indicia.state
or "PARCEL_SELECT"
),
specialServices=None,
)
if hub_id is not None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,10 @@ def shipment_request(
fedex.SmartPostInfoDetailType(
ancillaryEndorsement=None,
hubId=hub_id,
indicia=options.fedex_smart_post_allowed_indicia.state,
indicia=(
options.fedex_smart_post_allowed_indicia.state
or "PARCEL_SELECT"
),
specialServices=None,
)
if hub_id is not None
Expand Down
64 changes: 58 additions & 6 deletions modules/connectors/fedex/karrio/providers/fedex/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import gzip
import jstruct
import datetime
import urllib.parse
Expand All @@ -9,9 +10,11 @@
class Settings(core.Settings):
"""FedEx connection settings."""

api_key: str
secret_key: str
api_key: str = None
secret_key: str = None
account_number: str = None
track_api_key: str = None
track_secret_key: str = None

cache: lib.Cache = jstruct.JStruct[lib.Cache]
account_country_code: str = None
Expand Down Expand Up @@ -49,6 +52,11 @@ def access_token(self):
"""Retrieve the access_token using the api_key|secret_key pair
or collect it from the cache if an unexpired access_token exist.
"""
if not all([self.api_key, self.secret_key, self.account_number]):
raise Exception(
"The api_key, secret_key and account_number are required for Rate, Ship and Other API requests."
)

cache_key = f"{self.carrier_name}|{self.api_key}|{self.secret_key}"
now = datetime.datetime.now() + datetime.timedelta(minutes=30)

Expand All @@ -59,13 +67,52 @@ def access_token(self):
if token is not None and expiry is not None and expiry > now:
return token

self.cache.set(cache_key, lambda: login(self))
self.cache.set(
cache_key,
lambda: login(
self,
client_id=self.api_key,
client_secret=self.secret_key,
),
)
new_auth = self.cache.get(cache_key)

return new_auth["access_token"]

@property
def track_access_token(self):
"""Retrieve the access_token using the track_api_key|track_secret_key pair
or collect it from the cache if an unexpired access_token exist.
"""
if not all([self.track_api_key, self.track_secret_key]):
raise Exception(
"The track_api_key and track_secret_key are required for Track API requests."
)

cache_key = f"{self.carrier_name}|{self.track_api_key}|{self.track_secret_key}"
now = datetime.datetime.now() + datetime.timedelta(minutes=30)

auth = self.cache.get(cache_key) or {}
token = auth.get("access_token")
expiry = lib.to_date(auth.get("expiry"), current_format="%Y-%m-%d %H:%M:%S")

if token is not None and expiry is not None and expiry > now:
return token

self.cache.set(
cache_key,
lambda: login(
self,
client_id=self.track_api_key,
client_secret=self.track_secret_key,
),
)
new_auth = self.cache.get(cache_key)

return new_auth["access_token"]


def login(settings: Settings):
def login(settings: Settings, client_id: str = None, client_secret: str = None):
import karrio.providers.fedex.error as error

result = lib.request(
Expand All @@ -77,8 +124,8 @@ def login(settings: Settings):
data=urllib.parse.urlencode(
dict(
grant_type="client_credentials",
client_id=settings.api_key,
client_secret=settings.secret_key,
client_id=client_id,
client_secret=client_secret,
)
),
)
Expand All @@ -94,3 +141,8 @@ def login(settings: Settings):
)

return {**response, "expiry": lib.fdatetime(expiry)}


def parse_response(binary_string):
content = lib.failsafe(lambda: gzip.decompress(binary_string))
return lib.decode(content)
14 changes: 13 additions & 1 deletion modules/connectors/fedex/tests/fedex/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,32 @@
expiry = datetime.datetime.now() + datetime.timedelta(days=1)
api_key = "api_key"
secret_key = "secret_key"
track_api_key = "api_key"
track_secret_key = "secret_key"

cached_auth = {
f"fedex|{api_key}|{secret_key}": dict(
access_token="access_token",
token_type="bearer",
expires_in=3599,
scope="CXS-TP",
expiry=expiry.strftime("%Y-%m-%d %H:%M:%S"),
)
),
f"fedex|{track_api_key}|{track_secret_key}": dict(
access_token="access_token",
token_type="bearer",
expires_in=3599,
scope="CXS-TP",
expiry=expiry.strftime("%Y-%m-%d %H:%M:%S"),
),
}

gateway = karrio.gateway["fedex"].create(
dict(
api_key=api_key,
secret_key=secret_key,
track_api_key=track_api_key,
track_secret_key=track_secret_key,
account_number="2349857",
cache=lib.Cache(**cached_auth),
)
Expand Down
2 changes: 1 addition & 1 deletion modules/connectors/fedex/tests/fedex/test_rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_get_rate(self):

self.assertEqual(
mock.call_args[1]["url"],
f"{gateway.settings.server_url}/rate",
f"{gateway.settings.server_url}/rate/v1/rates/quotes",
)

def test_parse_rate_response(self):
Expand Down
4 changes: 2 additions & 2 deletions modules/connectors/fedex/tests/fedex/test_shipment.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def test_create_shipment(self):

self.assertEqual(
mock.call_args[1]["url"],
f"{gateway.settings.server_url}/ship",
f"{gateway.settings.server_url}/ship/v1/shipments",
)

def test_cancel_shipment(self):
Expand All @@ -52,7 +52,7 @@ def test_cancel_shipment(self):

self.assertEqual(
mock.call_args[1]["url"],
f"{gateway.settings.server_url}/ship",
f"{gateway.settings.server_url}/ship/v1/shipments/cancel",
)

def test_parse_shipment_response(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ def rate_request(
SmartPostDetail=(
fedex.SmartPostShipmentDetail(
ProcessingOptionsRequested=None,
Indicia=None,
indicia=(
options.fedex_smart_post_allowed_indicia.state
or "PARCEL_SELECT"
),
AncillaryEndorsement=None,
SpecialServices=None,
HubId=hub_id,
Expand Down
Loading

0 comments on commit fc847b4

Please sign in to comment.