Today we are gonna implement an SDK in Python. The motivation being is that a close relative needed to integrate a web shop with the label printing service of GLS Parcel. GLS has an REST-like API, but they only publish sample code in a handful of languages that excludes Python. I thought this would be a perfect opportunity to demonstrate how to go from zero to hero in a situation that a service provider publishes an API, but no SDK in your favorite language.
📄 API Documentation
GLS provides a decent documentation for their API. They also provide sample code in Java, PHP and C#. Personally, I always wonder why a service provider would stop at publishing only sample code. It should not take too much effort to turn that sample code into a proper SDK and delight their users. Anyhow, reading through the docs it seems fairly straightforward. The API publishes a couple endpoints useful to create shipping labels. Those labels could then be fetched as a PDF to be printed on self adhesive paper.
A couple things to note that seem to be custom to the GLS API and not following RESTful specification or industry best practices:
- all endpoints use the POST verb, even ones that fetch data
- all model properties use upper camel case
- authentication is custom, included in the body of every request
- the label PDF is returned embedded in a JSON response encoded as an integer list of byte values
🗝️ Authentication
Authentication takes care of the “Who are you” question when it comes to interacting with APIs. The counterpart to authentication is authorization that answers the “What can you do” question. As I mentioned, GLS solves the authentication in a custom manner. Every API request need to include key-value pairs for the Username
and the Password
as part of the request body. I guess this explains why does every resource use the POST verb. GET requests cannot have a request body. A better practice would be to supply the authentication data as a request header. However, being on the client side of the API our hands are tied and we can only use what the server gives us.
At least the password is not submitted as plain text. Rather, an SHA-512 hash needs to be calculated and the byte values of the hash digest make up the password value as a list of integers. We can implement the mapping logic with a helper method:
import hashlib
def calculate_password(self, plaintext: str) -> list[int]:
"""
Calculates password for API authentication
"""
sha = hashlib.sha512()
sha.update(plaintext.encode("utf-8"))
return list(sha.digest())
⏳ Timestamps
There is no shipping without pickup and delivery dates. The GLS server expects each date and time in a custom format represented as timestamps, e.g.: midnight of September 19, 2022 for example is represented by /Date(1663538400000)/
. That is the unix timestamp multiplied by 1000. I guess they wanted to flexibility to represent sub-second times, but alas never needed it so far. Following the single responsibility principle - one of the five SOLID principles - we can implement two small helper methods. One that takes care of the date to timestamp conversion and another that wraps the timestamp with the /Date()/
marker:
def convert_to_timestamp(date: datetime) -> int:
"""
Converts date object to timestamp
"""
return int(datetime.timestamp(date)) * 1000
def convert_to_datefield(date: datetime) -> str:
"""
Converts date object to API format: /Date(timestamp)/
"""
return f"/Date({convert_to_timestamp(date)})/"
One benefit of following the single responsibility principle is unit testing becomes a breeze:
import unittest
from datetime import datetime
class Test(unittest.TestCase):
def test_convert_to_timestamp(self):
date = datetime(2022, 9, 19, 0, 0)
self.assertEqual(1663538400000, convert_to_timestamp(date))
def test_convert_to_datefield(self):
date = datetime(2022, 9, 19, 0, 0)
self.assertEqual("/Date(1663538400000)/", convert_to_datefield(date))
One thing to mention about testing is that we should include test cases for edge cases. For example, what if the input date is None
? Since we are using type hints, we can attribute a null
input as user error. In production code, though, it is good practice to handle the unexpected.
🏛️ Model Classes
To represent real world objects we need to implement some model classes. When it comes to shipping address and parcel would be two great examples. In python, a dictionary can be thought as a general purpose model implementation. However, when implementing an SDK it makes good sense to ensure that data is in the right format, so we would definitely add validation to the mix. Can be done manually, but why reinvent the wheel when there are great libraries out there, like pydantic that already solves this problem.
Address
The following class describes an address, taking advantage of pydantic’s drop in replacement for data classes. The reason I opted for pydantic vs vanilla dataclasses is that the latter does not have built in support for serialization, while the former does. As you can see, the property names of an address are straightforward and so are their types.
from pydantic.dataclasses import dataclass
from typing import Optional
@dataclass
class Address:
City: str
ContactEmail: Optional[str]
ContactName: Optional[str]
ContactPhone: Optional[str]
CountryIsoCode: str
HouseNumber: str
Name: str
Street: str
ZipCode: str
HouseNumberInfo: Optional[str]
Parcel
A parcel model captures all pieces of data that go on a printed label necessary for not only successful pickup, but a flawless delivery.
from pydantic.dataclasses import dataclass
from typing import Optional
from .address import Address
from .service import Service
@dataclass
class Parcel:
ClientNumber: int
ClientReference: Optional[str]
Count: int
DeliveryAddress: Address
PickupAddress: Address
PickupDate: str
ServiceList: list[Service]
Content: Optional[str] = ""
CODAmount: Optional[float] = 0
CODReference: Optional[str] = ""
There are some additional model classes here that I won’t include in this article, but you can find them in the GitHub repo.
🖥️ REST call
Once we have our model classes and can represent a parcel it is time to create that label. This operation will involve two network calls and persisting the downloaded PDF on disk:
- prepare label
- get printed label
- save PDF For the network I/O I will be using the popular requests library.
Prepare Label
To prepare a label, we have to send a POST request to the /PrepareLabels
endpoint and include at least one parcel in the body of the request:
def prepare_labels(self, parcels: list[Parcel]) -> PrepareLabelsResponse:
"""
Prepares labels for printing
"""
payload = self._request_payload()
payload["ParcelList"] = [asdict(p) for p in parcels]
response = requests.post(
f"{self.settings.api_root}/PrepareLabels",
data=json.dumps(payload),
headers=HEADERS,
timeout=self.settings.timeout_seconds,
)
return PrepareLabelsResponse.__pydantic_model__.parse_raw(response.text)
Get Printed Label
To download the PDF, we have to send a POST request to the /GetPrintedLabels
endpoint and include the parcel ids in the body of the request that we got from the prepare labels step before:
def get_printed_labels(
self,
parcel_ids: list[int],
printer_type: PrinterType = PrinterType.THERMO,
print_position: int = 1,
show_dialog: bool = False,
) -> PrintedLabelsResponse:
"""
Gets printed labels
"""
payload = self._request_payload()
payload["ParcelIdList"] = parcel_ids
payload["PrintPosition"] = print_position
payload["ShowPrintDialog"] = 1 if show_dialog else 0
payload["TypeOfPrinter"] = printer_type.value
response = requests.post(
f"{self.settings.api_root}/GetPrintedLabels",
data=json.dumps(payload),
headers=HEADERS,
timeout=self.settings.timeout_seconds,
)
return PrintedLabelsResponse.__pydantic_model__.parse_raw(response.text)
Save PDF
Once we fetched the labels PDF using the get_printed_labels
function, we can extract the PDF data from the response JSON and proceed with converting it to binary data and saving it to disk:
...
printer_type: PrinterType = PrinterType.THERMO
labels = get_printed_labels(parcel_ids, printer_type)
label_data = labels.Labels
save_pdf(pdf_path, label_data)
def save_pdf(pdf_path: str, byte_list: list[int]) -> None:
data = bytes(byte_list)
with open(pdf_path, "wb") as f:
f.write(data)
log.info(f"Saved parcel label to {pdf_path}")
Here is an example of how the resulting PDF would look like using the address of my alma mater as the pickup and delivery addresses:
📦 Publishing the SDK
The main goal of our SDK is to make it very easy for someone to start using it. The standard Python package repository is pypi.org. We need to create a couple config files that describe our Python package and how it should be built and published.
Using the built in setuptools
package, the old way of doing this was to create a setup.py
file in the root of the project. The new approach is to include a setup.cfg
file with the project description that effectively reduces the setup.py
to a minimum:
import setuptools
if __name__ == "__main__":
setuptools.setup()
The setup.cfg
file would include all the necessary metadata like author, description, version, license, dependencies, project URLs, etc. In our case it would look something like this. Finally, a project.toml
file describes how to build the project:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
Once the configuration files are in place, we can build the project using
python -m build
The build artifacts are created under the dist
folder and we can use twine
to publish it to the Python Package Repository:
twine upload -r pypi dist/*
Before targeting the live package index, it is good practice to first publish to the test index and double check whether everything looks good. Once you publish in the live index, the metadata can only be updated by publishing a new version.
twine upload -r testpypi dist/*
🏁 Conclusion
The goal of this article is to give you an idea how the client side of an API - in our case the MyGLS API - can be turned into an SDK for a delightful user experience. You can find the full source code in this GitHub repo and you can pip install mygls-rest-client
from the pypi package repo. Happy coding!