Calling external REST APIs

Invoke any REST API from Python services - JSON parsed into Python objects, with headers, parameters and status checks.

Python services call external REST APIs through outgoing connections. You create a connection once in the Dashboard and every service invokes it by name - Zato attaches the credentials, pools the network connections and parses JSON responses into Python objects.

Call an API

The self.rest dictionary returns the connection whose name you pass - the name must match the Dashboard entry exactly. The service below reads a customer from an external API and returns it to its own caller:

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class GetCustomer(Service):
    """ Reads a customer from an external API.
    """
    name = 'demo.rest.get-customer'

    def handle(self) -> 'None':

        # The connection configured in the Dashboard ..
        conn = self.rest['Customer API']

        # .. one GET call, with self.cid as the correlation ID for tracing ..
        response = conn.get(self.cid)

        # .. and the JSON response, already parsed into a Python object.
        self.response.payload = response.data

To expose this service, create a REST channel pointing to /api/customer and call it:

curl http://localhost:11223/api/customer

The service calls the external Customer API and returns the customer it received.

Send data with POST

Pass a Python dict as the second argument and Zato serializes it to JSON:

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class CreateOrder(Service):
    name = 'demo.rest.create-order'

    def handle(self) -> 'None':

        # The order to create in the external system ..
        payload = {
            'customer_id': '12345',
            'items': [
                {'sku': 'ABC-001', 'quantity': 2},
                {'sku': 'XYZ-999', 'quantity': 1}
            ]
        }

        # .. the connection serializes the dict to JSON on the way out ..
        conn = self.rest['Orders API']
        response = conn.post(self.cid, payload)

        # .. and the endpoint's answer goes back to our own caller.
        self.response.payload = response.data

If the service is exposed on /api/orders, you invoke it with:

curl -X POST http://localhost:11223/api/orders \
  -d '{"customer_id": "12345", "items": [{"sku": "ABC-001", "quantity": 2}]}'

Path and query parameters

If the outgoing connection's URL path contains placeholders like /customers/{customer_id}/orders, Zato substitutes matching keys from params into the path. Remaining keys become query string parameters:

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class GetCustomerOrders(Service):
    name = 'demo.rest.get-customer-orders'

    def handle(self) -> 'None':

        # customer_id fills the {customer_id} placeholder in the path,
        # status becomes ?status=pending in the query string
        params = {
            'customer_id': '12345',
            'status': 'pending'
        }

        conn = self.rest['Orders API']
        response = conn.get(self.cid, params=params)

        self.response.payload = response.data

A path parameter always fills exactly one path segment - a slash inside a value is sent encoded as %2F, so values like abc/def or ../admin never change which resource the path names. A path with placeholders also means params is required - invoking such a connection without the placeholder's value raises an error.

Custom headers

Pass a headers dict to include headers in the outgoing request:

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class CallWithHeaders(Service):
    name = 'demo.rest.call-with-headers'

    def handle(self) -> 'None':

        headers = {
            'X-Request-ID': self.cid,
            'X-Client-Version': '2.0',
            'Accept-Language': 'en-US'
        }

        conn = self.rest['Customer API']
        response = conn.get(self.cid, headers=headers)

        self.response.payload = response.data

Sending self.cid as a request ID gives you distributed tracing - the same correlation ID appears in your logs and in the external system's logs.

Check the response status

The response carries the endpoint's HTTP status code - check it before using the data:

# -*- coding: utf-8 -*-

# stdlib
from http import HTTPStatus

# Zato
from zato.server.service import Service

class CheckResponseStatus(Service):
    name = 'demo.rest.check-status'

    def handle(self) -> 'None':

        conn = self.rest['Customer API']
        response = conn.get(self.cid)

        # The status code decides what our own caller receives
        if response.status_code == HTTPStatus.OK:
            self.response.payload = response.data

        elif response.status_code == HTTPStatus.NOT_FOUND:
            self.response.payload = {'error': 'Not found'}
            self.response.status_code = HTTPStatus.NOT_FOUND

        else:
            raise Exception(f'API error: {response.status_code}')

When a service raises an exception, the caller receives HTTP 500. To return a different status, set self.response.status_code explicitly, as the 404 case above does.

For a plain success check, response.ok is True for any 2xx status:

response = conn.get(self.cid)

if response.ok:
    self.response.payload = response.data

To learn how to handle failures in both directions, see error handling.

XML and other non-JSON responses

response.data holds the parsed object when the response is JSON. For everything else, the raw body is always available:

response = conn.get(self.cid)

# The body as text - use this for XML, CSV, HTML and other text formats
body = response.text

# The body as bytes - use this for binary content
raw = response.content

What is parsed is decided by the connection's data format setting together with the response's own Content-Type header:

  • With the data format set to JSON, every response is parsed as JSON - and a response that is not valid JSON, e.g. an XML error page, raises an exception rather than degrading to text
  • With the data format left blank, response.data is the response's text, unless the endpoint itself declares application/json, in which case it is parsed anyway

For an endpoint that serves XML, leave the connection's data format blank and read response.text.

Deserialize into a data model

When your service declares data models, a response can be deserialized straight into one by passing the model class in the call:

response = conn.get(self.cid, model=CustomerDetails)

# response.data is now a CustomerDetails instance
customer = response.data
self.logger.info(customer.name)

All HTTP methods

The connection object supports these HTTP methods:

conn.get(self.cid, params)      # GET
conn.post(self.cid, payload)    # POST
conn.put(self.cid, payload)     # PUT
conn.patch(self.cid, payload)   # PATCH
conn.delete(self.cid, params)   # DELETE
conn.options(self.cid)          # OPTIONS

There is also conn.ping() for connectivity checks, and self.out.rest['Customer API'] is another name for the same connection that self.rest['Customer API'] returns - both forms work with every method on this page.

An inactive connection - one whose Active checkbox is unticked in the Dashboard, or whose is_active is false in enmasse - raises an Inactive exception when invoked, which is how traffic to an endpoint is switched off without deleting its configuration.

A full example

The service below combines a payload, path and query parameters and custom headers in one call:

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class SetBillingInfo(Service):
    """ Updates billing information in an external system.
    """
    name = 'demo.rest.set-billing-info'

    def handle(self) -> 'None':

        # The request body, serialized to JSON ..
        payload = {'billing':'395.7', 'currency':'EUR'}

        # .. cust_id fills the path placeholder, the rest becomes the query string ..
        params = {'cust_id':'39175', 'phone_no':'271637517', 'priority':'normal'}

        # .. headers the endpoint expects ..
        headers = {'X-App-Name': 'Zato', 'X-Environment':'Production'}

        # .. and one POST call sends all of it.
        conn = self.rest['Set Billing Info']
        response = conn.post(self.cid, payload, params, headers=headers)

The response object gives you access to the body, headers and other metadata that the endpoint returns. For declarative API calls with response mapping and no boilerplate, see the REST adapter.

To configure timeouts, retries and guaranteed delivery for the connections these services invoke, see outgoing connections.

See also

PageWhat it covers
Outgoing connectionsPools, timeouts, retries and guaranteed delivery for external APIs
REST adapterDeclarative API calls with response mapping and no boilerplate
Error handlingHandling the errors external APIs return to you
OrchestrationCalling multiple APIs in one service and combining their responses

Learn more