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 ..
        response = conn.get()

        # .. 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(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(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(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.

Correlation IDs

Every call is made under the calling service's correlation ID, self.cid, without you passing it. It is what the connection's audit log and the server's own log file record for the request, so a call made from a service is traceable back to the request that service was handling.

To make a call under a different ID, such as one taken from an incoming request's header, pass it as cid:

response = conn.get(params=params, cid=request_id)

Older code that passes self.cid as the first positional argument, conn.get(self.cid, params=params), continues to work unchanged.

The response object

Every call returns the same response object, whatever the HTTP method:

AttributeWhat it holds
response.status_codeThe HTTP status code the endpoint answered with, e.g. 200 or 404
response.okTrue for any 2xx status
response.dataThe body parsed into Python objects when it is JSON, or into a data model if you asked for one
response.textThe body as text, for XML, CSV, HTML and anything else that is not JSON
response.contentThe body as bytes, for binary content
response.headersThe response headers as a dictionary, case-insensitive - response.headers['Content-Type'] and response.headers['content-type'] are the same header

Check the response status

The response includes 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()

        # The status code determines 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()

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()

# 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 into a model instance by passing the model class in the call:

response = conn.get(model=CustomerDetails)

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

Paginated responses

An API that returns a long list in pages tells you how to get the next one - most often as a cursor or a page number in the body, which goes into params of the next call. The loop below collects every page of orders:

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

# Zato
from zato.server.service import Service

class GetAllOrders(Service):
    name = 'demo.rest.get-all-orders'

    def handle(self) -> 'None':

        conn = self.rest['Orders API']
        orders = []
        params = {'page': 1}

        while True:
            response = conn.get(params=params)
            orders.extend(response.data['items'])

            # The endpoint says which page comes next, and no page means this was the last one
            next_page = response.data['next_page']
            if not next_page:
                break

            params = {'page': next_page}

        self.response.payload = orders

Some APIs give the next page as a whole URL in a Link header instead, as <https://api.example.com/orders?page=2>; rel="next". You take the query string from that URL - here, page=2 - and pass it in params as in the loop above.

All HTTP methods

The connection object supports these HTTP methods:

conn.get(params)     # GET
conn.post(payload)   # POST
conn.put(payload)    # PUT
conn.patch(payload)  # PATCH
conn.delete(params)  # DELETE
conn.options()       # 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(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 for the connections these services invoke, see outgoing connections, and for retries and guaranteed delivery, Delivery retries and dead-letter queues.

See also

PageWhat it covers
Outgoing connectionsPools, timeouts and security for external APIs
Delivery retries and dead-letter queuesRetries, queues and dead-letter queues for requests that could not be delivered
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