Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ae498a0
Add Freightcom Rest shipping integration plugin
jacobshilitz Aug 13, 2025
1e5157f
fix: include metadata.json in freightcom_rest package distribution
jacobshilitz Aug 13, 2025
755b5b2
fix: correct unit price value calculation in Freightcom shipment crea…
jacobshilitz Aug 13, 2025
85b393b
fix: handle empty response in Freightcom shipment creation
jacobshilitz Aug 13, 2025
db771b7
fix: update duty tax recipient type and commodity value formatting in…
jacobshilitz Aug 13, 2025
d16b8b8
fix: update duty tax recipient mapping in Freightcom shipment creation
jacobshilitz Aug 18, 2025
a4022b5
fix: enhance error message formatting in Freightcom error handling
jacobshilitz Nov 24, 2025
0e5c807
fix: improve error message detail extraction in Freightcom error hand…
jacobshilitz Nov 24, 2025
8190aa9
feat: update Freightcom schemas to support customs data and paperless…
jacobshilitz Nov 24, 2025
cd2058c
fix: refine customs handling for CA to US shipments in rate and shipm…
jacobshilitz Nov 24, 2025
9bfbab9
fix: streamline customs data handling in rate request for CA to US sh…
jacobshilitz Nov 24, 2025
4466d62
fix: remove redundant packaging type check in customs processing
jacobshilitz Nov 24, 2025
cc94416
feat: add support for DDP shipments and customs payment method handling
jacobshilitz Nov 25, 2025
655d2fe
feat: add error level mapping support for upstream sync
jacobshilitz Dec 24, 2025
6c9736f
feat: add is_rate_guaranteed flag to rate meta
jacobshilitz Dec 29, 2025
9411a40
feat: implement USMCA customs support for Freightcom API v2
jacobshilitz Dec 29, 2025
e29cccd
feat: add is_customs_rate_guaranteed metadata to shipment response
jacobshilitz Dec 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions plugins/freightcom_rest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# karrio.freightcom_rest

This package is a Freightcom Rest extension of the [karrio](https://pypi.org/project/karrio) multi carrier shipping SDK.

## Requirements

`Python 3.11+`

## Installation

```bash
pip install karrio.freightcom_rest
```

## Usage

```python
import karrio.sdk as karrio
from karrio.mappers.freightcom_rest.settings import Settings


# Initialize a carrier gateway
freightcom_rest = karrio.gateway["freightcom_rest"].create(
Settings(
...
)
)
```

Check the [Karrio Mutli-carrier SDK docs](https://docs.karrio.io) for Shipping API requests
13 changes: 13 additions & 0 deletions plugins/freightcom_rest/generate
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
SCHEMAS=./schemas
LIB_MODULES=./karrio/schemas/freightcom_rest
find "${LIB_MODULES}" -name "*.py" -exec rm -r {} \;
touch "${LIB_MODULES}/__init__.py"

kcli codegen generate "${SCHEMAS}/error_response.json" "${LIB_MODULES}/error_response.py" --nice-property-names
kcli codegen generate "${SCHEMAS}/rate_request.json" "${LIB_MODULES}/rate_request.py" --nice-property-names
kcli codegen generate "${SCHEMAS}/rate_response.json" "${LIB_MODULES}/rate_response.py" --nice-property-names
kcli codegen generate "${SCHEMAS}/shipment_request.json" "${LIB_MODULES}/shipment_request.py" --nice-property-names
kcli codegen generate "${SCHEMAS}/shipment_response.json" "${LIB_MODULES}/shipment_response.py" --nice-property-names
kcli codegen generate "${SCHEMAS}/pickup_request.json" "${LIB_MODULES}/pickup_request.py" --nice-property-names
kcli codegen generate "${SCHEMAS}/tracking_response.json" "${LIB_MODULES}/tracking_response.py" --nice-property-names

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from karrio.mappers.freightcom_rest.mapper import Mapper
from karrio.mappers.freightcom_rest.proxy import Proxy
from karrio.mappers.freightcom_rest.settings import Settings
54 changes: 54 additions & 0 deletions plugins/freightcom_rest/karrio/mappers/freightcom_rest/mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Karrio Freightcom Rest client mapper."""

import typing
import karrio.lib as lib
import karrio.api.mapper as mapper
import karrio.core.models as models
import karrio.providers.freightcom_rest as provider
import karrio.mappers.freightcom_rest.settings as provider_settings


class Mapper(mapper.Mapper):
settings: provider_settings.Settings

def create_rate_request(
self, payload: models.RateRequest
) -> lib.Serializable:
return provider.rate_request(payload, self.settings)

# def create_tracking_request(
# self, payload: models.TrackingRequest
# ) -> lib.Serializable:
# return provider.tracking_request(payload, self.settings)

def create_shipment_request(
self, payload: models.ShipmentRequest
) -> lib.Serializable:
return provider.shipment_request(payload, self.settings)

def create_cancel_shipment_request(
self, payload: models.ShipmentCancelRequest
) -> lib.Serializable[str]:
return provider.shipment_cancel_request(payload, self.settings)


def parse_cancel_shipment_response(
self, response: lib.Deserializable[str]
) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
return provider.parse_shipment_cancel_response(response, self.settings)

def parse_rate_response(
self, response: lib.Deserializable[str]
) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
return provider.parse_rate_response(response, self.settings)

def parse_shipment_response(
self, response: lib.Deserializable[str]
) -> typing.Tuple[models.ShipmentDetails, typing.List[models.Message]]:
return provider.parse_shipment_response(response, self.settings)

# def parse_tracking_response(
# self, response: lib.Deserializable[str]
# ) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
# return provider.parse_tracking_response(response, self.settings)
#
124 changes: 124 additions & 0 deletions plugins/freightcom_rest/karrio/mappers/freightcom_rest/proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Karrio Freightcom Rest client proxy."""

import time
import karrio.lib as lib
import karrio.api.proxy as proxy
import karrio.mappers.freightcom_rest.settings as provider_settings

# IMPLEMENTATION INSTRUCTIONS:
# 1. Import the schema types specific to your carrier API
# 2. Uncomment and adapt the request examples below to work with your carrier API
# 3. Replace the stub responses with actual API calls once you've tested with the example data
# 4. Update URLs, headers, and authentication methods as required by your carrier API
MAX_RETRIES = 10
POLL_INTERVAL = 2 # seconds


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

def get_rates(self, request: lib.Serializable) -> lib.Deserializable:
# Step 1: Submit rate request and get quote ID
response = self._send_request(
path="/rate", request=lib.Serializable(request.value, lib.to_json)
)

rate_id = lib.to_dict(response).get('request_id')
if not rate_id:
return lib.Deserializable(response, lib.to_dict)

# Step 2: Poll for rate results
for _ in range(MAX_RETRIES):
status_res = self._send_request(
path=f"/rate/{rate_id}",
method="GET"
)

status = lib.to_dict(status_res).get('status', {}).get('done', False)

if status: # Quote is complete
return lib.Deserializable(status_res, lib.to_dict)

time.sleep(POLL_INTERVAL)

# If we exceed max retries
return lib.Deserializable({
'message': 'Rate calculation timed out'
}, lib.to_dict)

def create_shipment(self, request: lib.Serializable) -> lib.Deserializable:

response = self._send_request(
path="/shipment", request=lib.Serializable(request.value, lib.to_json)
)
response_dict = lib.failsafe(lambda: lib.to_dict(response)) or {}
shipment_id = response_dict.get('id')

if not shipment_id:
return lib.Deserializable(response if response else "{}", lib.to_dict)

# Step 2: retry because api return empty bytes if done to fast
time.sleep(1)
for _ in range(MAX_RETRIES):

shipment_response = self._send_request(path=f"/shipment/{shipment_id}", method="GET")
shipment_res = lib.failsafe(lambda :lib.to_dict(shipment_response)) or lib.decode(shipment_response)

if shipment_res: # is complete
return lib.Deserializable(shipment_res, lib.to_dict, request._ctx)

time.sleep(POLL_INTERVAL)

# If we exceed max retries
return lib.Deserializable({
'message': 'timed out creating shipment, shipment maybe created'
}, lib.to_dict)


# def get_tracking(self, request: lib.Serializable) -> lib.Deserializable[str]:
# responses = lib.run_asynchronously(
# lambda data: (
# data["shipment_id"],
# self._send_request(path=f"/shipment/{data['shipment_id']}/tracking-events"),
# ),
# [_ for _ in request.serialize() if _.get("shipment_id")],
# )
#
# print(lib.to_dict(responses))
# return lib.Deserializable(
# responses,
# lambda __: [(_[0], lib.to_dict(_[1])) for _ in __],
# )



def _get_payments_methods(self) -> lib.Deserializable[str]:
response = self._send_request(
path="/finance/payment-methods",
method="GET"
)
return lib.Deserializable(response, lib.to_dict)

def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable:
response = self._send_request(
path=f"/shipment/{request.serialize()}", method="DELETE"
)
return lib.Deserializable(response if any(response) else "{}", lib.to_dict)

def _send_request(
self, path: str, request: lib.Serializable = None, method: str = "POST"
) -> str:

data: dict = dict(data=request.serialize()) if request is not None else dict()
return lib.request(
**{
"url": f"{self.settings.server_url}{path}",
"trace": self.trace_as("json"),
"method": method,
"headers": {
"Content-Type": "application/json",
"Authorization": self.settings.api_key,
},
**data,
}
)
20 changes: 20 additions & 0 deletions plugins/freightcom_rest/karrio/mappers/freightcom_rest/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Karrio Freightcom Rest client settings."""

import attr
import karrio.providers.freightcom_rest.utils as provider_utils


@attr.s(auto_attribs=True)
class Settings(provider_utils.Settings):
"""Freightcom Rest connection settings."""

# Add carrier specific API connection properties here
api_key: str

# generic properties
id: str = None
test_mode: bool = False
carrier_id: str = "freightcom_rest"
account_country_code: str = None
metadata: dict = {}
config: dict = {}
29 changes: 29 additions & 0 deletions plugins/freightcom_rest/karrio/plugins/freightcom_rest/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from karrio.core.metadata import PluginMetadata

from karrio.mappers.freightcom_rest.mapper import Mapper
from karrio.mappers.freightcom_rest.proxy import Proxy
from karrio.mappers.freightcom_rest.settings import Settings
import karrio.providers.freightcom_rest.units as units
import karrio.providers.freightcom_rest.utils as utils


# This METADATA object is used by Karrio to discover and register this plugin
# when loaded through Python entrypoints or local plugin directories.
# The entrypoint is defined in pyproject.toml under [project.entry-points."karrio.plugins"]
METADATA = PluginMetadata(
id="freightcom_rest",
label="Freightcom Rest",
description="Freightcom Rest shipping integration for Karrio",
# Integrations
Mapper=Mapper,
Proxy=Proxy,
Settings=Settings,
# Data Units
is_hub=True,
options=units.ShippingOption,
services=units.ShippingService,
connection_configs=utils.ConnectionConfig,
# Extra info
website="https://www.freightcom.com/",
documentation="https://developer.freightcom.com/",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Karrio Freightcom Rest provider imports."""
from karrio.providers.freightcom_rest.utils import Settings
from karrio.providers.freightcom_rest.rate import (
parse_rate_response,
rate_request,
)
from karrio.providers.freightcom_rest.shipment import (
parse_shipment_cancel_response,
parse_shipment_response,
shipment_cancel_request,
shipment_request,
)
# from karrio.providers.freightcom_rest.tracking import (
# parse_tracking_response,
# tracking_request,
# )
45 changes: 45 additions & 0 deletions plugins/freightcom_rest/karrio/providers/freightcom_rest/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Karrio Freightcom Rest error parser."""

import typing
import karrio.lib as lib
import karrio.core.models as models
import karrio.providers.freightcom_rest.utils as provider_utils


def parse_error_response(
response: dict,
settings: provider_utils.Settings,
**kwargs,
) -> typing.List[models.Message]:
responses = response if isinstance(response, list) else [response]

errors = [
*[_ for _ in responses if _.get("message")],
]

return [
models.Message(
carrier_id=settings.carrier_id,
carrier_name=settings.carrier_name,
message=(
error.get("message") + ": " + "; ".join(f"{k.replace('details.', '')}: {v}" for k, v in (error.get("details", {}) or error.get("data", {})).items())
if (error.get("details", {}) or error.get("data", {}))
else error.get("message")
),
level=_get_level(error),
details={
**kwargs,
**(error.get('data', {}))
},
)
for error in errors
]


def _get_level(error: dict, default_level: str = "error") -> str:
"""Map Freightcom error response to standardized level.

Freightcom API v2 does not provide a level field in error responses.
All error responses default to "error" level.
"""
return default_level
Loading