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:
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:
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
| Page | What it covers |
|---|---|
| REST adapter | The adapter services orchestrations invoke |
| Error handling | Turning failures into the right status codes for callers |
| Data mapping | Translating each system's format before combining them |
| Calling REST APIs | Invoking external APIs directly from services |