• Kees Hink
    Kees Hink

Writing a Python "Production" API for Printify

For a project of ours, we developed a Python API for the Printify "Production" API client. In this blog post, we discuss some of the technical challenges.

What the customer asked for

Our customer is in the printing business. For a while now, we’ve been working on an order management system that allows creating print jobs from that order management system.

Printify is a service that allows creating orders for print jobs. Our customer wants to use the Printify “Production API” client to get their customers’ orders in our order management system.

Our task was to build an API for this client.

Approach

Building an API is relatively straightforward. We use rest_framework and its serializers.

For example, consider this snippet:

class PrintifyOrderSerializer(serializers.Serializer):
    """Printify's "Production Order" format

    https://developers.printify.com/print-providers/v1/#production-api
    """

    id = serializers.CharField(max_length=24)
    sample = serializers.BooleanField(required=False)
    reprint = serializers.BooleanField(required=False)
    xqc = serializers.BooleanField(required=False)
    tags = serializers.ListField(
        child=serializers.CharField(max_length=24),
        required=False,
    )
    address_to = ToAddressSerializer(required=True)
    address_from = FromAddressSerializer(required=True)
    shipping = ShippingSerializer(
        required=True, error_messages={"required": "shipping information is required"}
    )
    items = serializers.ListField(
        required=True,
        min_length=1,
        child=ItemSerializer(),
        error_messages={"min_length": "No items to print"},
    )

As you see, it’s super easy to validate fields against any requirement. This is why rest_framework is so powerful.

The details on how to handle the various calls on our API are in the “business logic” domain, so we won’t discuss them here.

Automated API validation with Allure framework

It was a pleasant surprise that Printify uses the Allure framework. This framework allows automated testing of our API.

By going to the Print Provider portal, an automated test can be requested which tests, among others:

  • That authorization is required
  • That certain fields are required in request data
  • That the correct status codes are returned
The output from the API checker tool

Challenges

Building APIs is one of our specialties, so most of this was easy sailing. There was, however, one thing that challenged us a bit, so we’d like to share it.

Converting nested serializer errors to flat list

The main technical challenge was returning validation errors in the way that the Printify Production API client wants to receive them.

A rest_framework serializer will return validation errors in this format:

{
    "list_of_things": {
        0: {
            "print_files": ["too long", "invalid characters", ...]
            ...
        },
        ...
    ...
    }
}

But the client expects this:

[
    {'list_of_things.print_files': "too long, invalid characters"}
    ...
]

To put it in words, the serializer errors are nested, but the client expects a flat list.

To convert the errors from one form into the other, we define a custom formatter:

class PrintifyErrorFormatter(object):
    """Convert serializer errors into the format desired by Printify.

    Why is this a separate class? We might also define these methods on the
    serializer class that now subclasses this. The answer is tests: Having a
    separate formatter class makes for easier testing: A serializer doesn't
    easily allow setting `.errors` on it, whereas a non-serializer instance
    does.

    It could be argued, however, that this serializer, in order to be a drop-in
    replacement should just set its `errors`. This way, the view code would
    only need `serializer.errors` just like in the case of a normal serializer.
    This would be a better separation of concerns. Doing so is left as an
    exercise to the reader.
    """

    @classmethod
    def flatten_errors(cls, serializer_errors: dict, field_name=()) -> list:
        """Convert a multi-level dict (serializer errors) to flat list.

        `field_name` is the field name we will pass to Printify. For nested
        fields, this should also include parent fields, separated by a dot.
        So an error in {"address_to": {"address1": ...}} will be returned to
        Printify as [{"address_to.address1": ...}]
        """
        flattened_errors = []

        for key, value in serializer_errors.items():
            local_field_name = field_name + (key,)
            if isinstance(key, int):
                # We're on one of the things in a list.
                # Go one level deeper. Don't change the field_name.
                flattened_errors += cls.flatten_errors(
                    serializer_errors=value, field_name=field_name
                )
            elif isinstance(value, dict):
                # There's another field error nested below.
                # Go one level deeper, show it in the field_name.
                flattened_errors += cls.flatten_errors(
                    serializer_errors=value, field_name=local_field_name
                )
            elif isinstance(value, list):
                # We have a list of actual errors for the (nested) field. Add them.
                new_errors = {".".join(local_field_name): value}
                flattened_errors.append(new_errors)
            else:
                raise RuntimeError(
                    "Programming error. Are you sure you input an error dict?"
                )
        return flattened_errors

    @classmethod
    def merge_errors(cls, flattened_errors):
        """Merge errors for the same field.

        Apparently, according to Printify, there can be at most 1 error per
        field. So we merge the error list per field to a single string.
        """
        merged_errors = []
        for error in flattened_errors:
            for key, value in error.items():
                merged_errors.append({key: ", ".join(value)})

        return merged_errors

    @property
    def printify_errors(self):
        """Returns serializer errors in a flattened structure."""
        default_errors = self.errors
        flattened_errors = self.flatten_errors(default_errors)
        merged_flattened_errors = self.merge_errors(flattened_errors)
        return merged_flattened_errors

This allows us to get the serializer errors, formatted for Printify, as follows:

class SomeSerializer(serializers.Serializer, PrintifyErrorFormatter)
    pass

serializer = SomeSerializer()
if not serializer.is_valid():
    raise serializers.ValidationError(
        {
            # Printify expects the "status" field to contain "failed" on validation errors
            "status": "failed",
            "errors": serializer.printify_errors,
        }
    )

Summary

Django’s rest_framework is an extremely powerful tool for building APIs.

The Allure framework is really helpful to test an APIs input validation and responses.

rest_framework’s serializer validation errors can be easily reformatted to match any expected format.

We love code