Declaring input and output

Declare what a service accepts and returns - parsed before handle runs, validated and documented in OpenAPI.

A service can declare what it accepts and what it returns, and there are two forms to do it in - a list of field names, or a data model. Either way, a declaration means the input is parsed before handle runs, required fields are enforced, optional ones read as None when not sent, and the declaration feeds the OpenAPI documentation generated for the service.

Declarations themselves are optional - without one, self.request.input holds the incoming message as it arrived, as the requests and responses page shows.

Names in a tuple

The simplest declaration is a tuple of field names. Each name is its own string in the tuple - one string is always one field, never a comma-separated list of them - and a single field needs no tuple at all:

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

# Zato
from zato.server.service import Service

class GetProfile(Service):

    input = 'customer_id', 'tier'
    output = 'display_name', 'is_active'

    def handle(self):

        customer_id = self.request.input.customer_id
        tier = self.request.input.tier

        self.response.payload.display_name = f'Customer {customer_id}, tier {tier}'
        self.response.payload.is_active = True
class GetStatus(Service):

    input = 'customer_id'

Optional fields - the dash prefix

A leading dash makes a field optional. The minus sign is part of the declaration only, never of the name itself - -priority is read as priority:

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

# Zato
from zato.server.service import Service

class GetProfile(Service):

    input = 'customer_id', '-priority'

    def handle(self):

        customer_id = self.request.input.customer_id

        # None when the caller did not send it
        priority = self.request.input.priority

        if priority is None:
            self.logger.info(f'No priority given for {customer_id}')
        else:
            self.logger.info(f'{customer_id} has priority {priority}')

A field without a dash is required. A request missing one is rejected before handle runs, with an error naming the field, e.g. Missing required input element: customer_id. The dash is the whole mechanism - there is no separate is_required attribute to set anywhere.

What a name says about its type

The name of a field decides what type it arrives as:

  • Names starting with by_, has_, is_, may_, needs_ or should_ arrive as booleans
  • Names ending in _count or _timeout arrive as integers
  • Every other name arrives as a string
# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class UpdateSubscription(Service):

    input = 'customer_id', 'is_active', 'retry_count'

    def handle(self):

        # A bool, from the is_ prefix
        is_active = self.request.input.is_active

        # An int, from the _count suffix, ready for arithmetic
        retry_count = self.request.input.retry_count
        next_retry = retry_count + 1

        self.logger.info(f'Active: {is_active}, next retry: {next_retry}')

When a field needs a type these conventions do not cover - an integer that is not a count, a date, a nested structure - declare a model instead. There is no force_type or other per-field override for names alone - a type the conventions cannot express is what models are for.

Declaring output

Output takes the same form, and declaring it makes the response include the declared names only. Assigning a name that was not declared raises an error at the very line that assigns it, so typos never reach the wire:

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

# Zato
from zato.server.service import Service

class GetBalance(Service):

    output = 'account_id', 'balance'

    def handle(self):

        self.response.payload.account_id = 'A-1001'
        self.response.payload.balance = 250

A dict assigned as a whole is filtered to the declared names too, so an internal dict passes through without leaking fields. The full treatment of building responses - free-form payloads, list responses, nested shapes - is on the requests and responses page.

Data models

The second form is a data model - a dataclass whose fields have real types, defaults and structure. self.request.input is then an instance of the model:

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

# stdlib
from dataclasses import dataclass

# Zato
from zato.common.typing_ import optional
from zato.server.service import Model, Service

@dataclass(init=False)
class CreateOrderRequest(Model):
    customer_id: str
    quantity: int
    notes: optional[str] = None

@dataclass(init=False)
class CreateOrderResponse(Model):
    order_id: str
    is_confirmed: bool

class CreateOrder(Service):

    input = CreateOrderRequest
    output = CreateOrderResponse

    def handle(self):

        request = self.request.input # This is a CreateOrderRequest instance

        response = CreateOrderResponse()
        response.order_id = f'order-{request.customer_id}-{request.quantity}'
        response.is_confirmed = True

        self.response.payload = response

With a model, quantity above is an int because the field says so, not because of its name, and a request missing it is rejected with a response that names the field, so the caller knows exactly what to fix. Nested models, lists of models and everything else models can do is on the data models page.

Which form to use

Names in a tuple are enough when the fields are strings, booleans and counts that the name conventions cover, which is most lookup and status services. A model is the right form when fields have types of their own, defaults, or structure - and once a request has more than a handful of fields, a model also gives the IDE completion and type checking that plain names cannot.

The two forms mix freely across services - one service can declare names while another in the same project declares models.

Learn more