Orchestrating multiple APIs

Coordinate multiple APIs in one service - sequential calls, aggregation, fan-out and partial failures.

An orchestration service coordinates multiple API calls in one business task - it fetches data from an HR system, checks an access control database, updates a ticket tracker and combines everything into a single response.

Orchestration happens through self.invoke - each call runs another service, which may call an external API through an adapter or do local processing. The orchestration logic stays in one service and the API-specific code stays in the adapters.

Call APIs sequentially

When the second call needs data from the first, run them in order. The service below reads an employee from HR, then uses the employee's email to look up their access card:

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

# Zato
from zato.server.service import Service

class GetEmployeeWithAccessCard(Service):
    """ Combines HR data with access control data for one employee.
    """
    name = 'demo.rest.get-employee-with-access-card'

    input = 'employee_id'

    def handle(self) -> 'None':

        employee_id = self.request.input.employee_id

        # The first call returns the employee ..
        employee = self.invoke('hr.employee.get', employee_id=employee_id)

        # .. whose email the second call needs.
        access_card = self.invoke('access.card.get', email=employee.email)

        has_access_card = access_card is not None

        self.response.payload = {
            'employee': employee,
            'has_access_card': has_access_card,
            'access_card': access_card
        }

Expose the service on a channel at /api/employee/{employee_id} and call it:

curl http://localhost:11223/api/employee/EMP-001

The response combines data from two backend systems into one JSON object.

Aggregate data from multiple sources

When the calls are independent, one service gathers them all and the client makes one request instead of four:

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

# Zato
from zato.server.service import Service

class GetCustomerDashboard(Service):
    """ Builds a customer dashboard from four backend systems.
    """
    name = 'demo.rest.get-customer-dashboard'

    input = 'customer_id'

    def handle(self) -> 'None':

        customer_id = self.request.input.customer_id

        # Each call runs synchronously and returns its system's data
        profile = self.invoke('crm.customer.get', customer_id=customer_id)
        orders = self.invoke('orders.list', customer_id=customer_id)
        tickets = self.invoke('support.tickets.list', customer_id=customer_id)
        billing = self.invoke('billing.get', customer_id=customer_id)

        open_tickets = [item for item in tickets if item.status == 'open']
        open_ticket_count = len(open_tickets)

        customer = {
            'id': customer_id,
            'name': profile.name,
            'email': profile.email,
            'since': profile.created_at
        }

        summary = {
            'total_orders': len(orders),
            'open_tickets': open_ticket_count,
            'account_balance': billing.amount
        }

        self.response.payload = {
            'customer': customer,
            'summary': summary,
            'recent_orders': orders[:5],
            'recent_tickets': tickets[:3]
        }

Expose the service on a channel at /api/customer/{customer_id}/dashboard:

curl http://localhost:11223/api/customer/CUST-123/dashboard

Conditional orchestration

Business rules decide which downstream systems each request reaches:

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

# Zato
from zato.server.service import Service

# The Jira transition that marks a ticket as done
_jira_done_transition = 31

class ProcessTrainingCompletion(Service):
    """ Records a completed training in the systems the course names.
    """
    name = 'demo.rest.process-training-completion'

    input = 'course_id', 'user_id'

    def handle(self) -> 'None':

        course_id = self.request.input.course_id
        user_id = self.request.input.user_id

        course = self.invoke('lms.course.get', id=course_id)
        completion = self.invoke('lms.completion.get', course_id=course_id, user_id=user_id)

        # The course itself says which systems to notify
        notifications = course.notify_on_completion

        if 'HR' in notifications:
            self.invoke('hr.training.record', employee_id=completion.employee_id, course=course)
            self.logger.info('Recorded training in HR for %s', user_id)

        if 'Jira' in notifications:
            ticket_id = completion.access_request_ticket
            if ticket_id:
                self.invoke('jira.transition', ticket_id=ticket_id, transition_id=_jira_done_transition)
                self.logger.info('Transitioned Jira ticket %s', ticket_id)

        self.response.payload = {'status': 'processed'}

Fan-out pattern

When several systems receive the same data, fan the calls out from one source:

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

# Zato
from zato.server.service import Service

class SyncAllSystems(Service):
    """ Sends the active employee list to every downstream system.
    """
    name = 'demo.rest.sync-all-systems'

    def handle(self) -> 'None':

        # One source of truth ..
        employees = self.invoke('hr.employees.active')

        # .. and the same data goes to each target system.
        lms_result = self.invoke('lms.sync', employees=employees)
        access_result = self.invoke('access.sync', employees=employees)
        directory_result = self.invoke('directory.sync', employees=employees)

        self.response.payload = {
            'employees_processed': len(employees),
            'lms_synced': lms_result.count,
            'access_synced': access_result.count,
            'directory_synced': directory_result.count
        }

Handle partial failures

Decide per source whether a failure ends the request or only removes one part of the response - core data is required, enrichments are optional:

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

# stdlib
from traceback import format_exc

# Zato
from zato.server.service import Service

class GetEmployeeFullProfile(Service):
    """ Returns an employee's profile with optional enrichments.
    """
    name = 'demo.rest.get-employee-full-profile'

    input = 'employee_id'

    def handle(self) -> 'None':

        employee_id = self.request.input.employee_id

        # The profile is required - a failure here ends the request
        profile = self.invoke('hr.employee.get', employee_id=employee_id)

        result = {
            'employee_id': employee_id,
            'profile': profile
        }

        # Training data is an enrichment - the response works without it
        try:
            response = self.invoke('lms.courses', email=profile.email)
            result['training'] = response
        except Exception:
            self.logger.warning('Training data unavailable, e:`%s`', format_exc())
            result['training'] = None

        # Access history is an enrichment too
        try:
            response = self.invoke('access.history', employee_id=employee_id)
            result['access_history'] = response
        except Exception:
            self.logger.warning('Access history unavailable, e:`%s`', format_exc())
            result['access_history'] = None

        self.response.payload = result

To learn how failures map to the status codes your own callers receive, see error handling.

See also

PageWhat it covers
REST adapterThe adapter services orchestrations invoke
Error handlingTurning failures into the right status codes for callers
Data mappingTranslating each system's format before combining them
Calling REST APIsInvoking external APIs directly from services

Learn more