Where to keep API configuration

Keep configuration outside code in .ini files that reload with no restarts.

Your services read configuration from .ini files, instantly from memory. This is where you store API endpoints, mapping tables, business rules and any other values that do not belong in code.

Configuration files go in the config/user-conf directory of your project on the host machine. When you deploy with Docker, this directory is mounted into the container, so Zato can read the files at startup. Each file becomes accessible through self.config in your services, using the file name (without extension) as the first key.

For instance, if you create a file called airports.ini, in your service it is accessible as self.config.airports, and sections work as its keys, accessed with dot notation, so [api] with timeout = 30 becomes self.config.airports.api.timeout.

The files are edited in the dashboard on the Config tables screen or with any editor of your own, and the configuration overview shows where each kind of value belongs.

Create a configuration file

On your host machine, create an .ini file in your project's config/user-conf directory. For example, config/user-conf/airports.ini:

[api]
base_url = https://api.airport-ops.example.com
timeout = 30
max_retries = 3

[security]
token_sec_def_name = Airport.Ops.Token

[terminals]
domestic = T1
international = T2
cargo = T3

[[gate_prefixes]]
T1 = A
T2 = B
T3 = C

The file uses a simple structure:

  • Sections are defined with single brackets [section_name]
  • Nested sections use additional brackets - double [[subsection]], triple [[[deeper]]], and so on for arbitrary nesting depth
  • Key-value pairs are written as key = value
  • Comments start with #

Access configuration in services

In your service, access the configuration through self.config using dot notation. The first key is the file name (without .ini), followed by section names and keys:

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

# Zato
from zato.server.service import Service

class GetFlightInfo(Service):

    def handle(self):

        # Access simple values
        base_url = self.config.airports.api.base_url
        timeout = self.config.airports.api.timeout

        # Access nested sections
        t1_prefix = self.config.airports.terminals.gate_prefixes.T1

        self.logger.info(f'Using API at {base_url} with timeout {timeout}')
        self.logger.info(f'Terminal 1 gate prefix: {t1_prefix}')

You can nest sections as deep as you need. Each level of nesting in the .ini file becomes another level of dot access in your code.

Changes apply immediately

Configuration files are watched the same way services are - saving an .ini file in the mounted project reloads its values in RAM within a couple of seconds, with no restarts and no redeployments. A service reading self.config always sees the current contents of the file, which makes configuration files the right place for values that change while an environment runs, such as mapping tables and feature toggles. Hot deployment describes the mechanism.

Where the files can live

The config/user-conf directory of your project is the default place, and two more are available:

  • Any directory listed in the Zato_User_Conf_Dir environment variable, which accepts multiple colon-separated paths
  • Additional projects pointed to by Zato_Project_Root_* variables, each with its own config/user-conf

All of them are loaded into the same self.config object and watched for changes, and deploying projects covers the variables in detail.

Use sections as dictionaries

Each configuration section also behaves like a Python dictionary. You can iterate over keys, look up values with square brackets, check for key existence, and use all the standard dict methods:

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

# Zato
from zato.server.service import Service

class ListTerminals(Service):

    def handle(self):

        terminals = self.config.airports.terminals

        # Iterate over all keys
        for terminal_name in terminals:
            terminal_code = terminals[terminal_name]
            self.logger.info(f'Terminal {terminal_name} -> {terminal_code}')

        # Check if a key exists
        if 'cargo' in terminals:
            cargo_code = terminals['cargo']
            self.logger.info(f'Cargo terminal: {cargo_code}')

        # Get with default
        vip_terminal = terminals.get('vip', 'T1')

        # Get all keys
        all_terminal_names = list(terminals.keys())

        # Get all values
        all_terminal_codes = list(terminals.values())

        # Get all items
        for name, code in terminals.items():
            self.logger.info(f'{name} = {code}')

This means you can use configuration sections both for direct access when you know the key names, and for dynamic lookups when you need to iterate or search.

Value types

Zato automatically converts configuration values to appropriate Python types.

For instance, let's say you have this config:

[api]
base_url = https://api.airport-ops.example.com
timeout = 30
max_retries = 3
debug_mode = False

[terminals]
active_gates = A1, A2, A3, B1, B2
gate_capacity = {'A1': 200, 'A2': 150, 'B1': 300}

Here's how it would be converted:

  • Integers - timeout = 30 becomes Python int 30
  • Booleans - debug_mode = False becomes Python bool False
  • Lists - active_gates = A1, A2, A3, B1, B2 becomes Python list ['A1', 'A2', 'A3', 'B1', 'B2']
  • Dicts - gate_capacity = {'A1': 200, 'A2': 150, 'B1': 300} becomes Python dict
  • Strings - base_url = https://api.airport-ops.example.com remains a Python str

Nested sections

Nested sections become nested objects. Each level of brackets adds another level of dot access, so you can organize complex configuration hierarchically and access it naturally in code.

[terminal_t1]

[[gates]]

[[[gate_a1]]]
capacity = 200
airlines = FI, AF

[[[gate_a2]]]
capacity = 150
airlines = BA

In your service:

  • self.config.airports.terminal_t1 - the [terminal_t1] section
  • self.config.airports.terminal_t1.gates - the [[gates]] subsection
  • self.config.airports.terminal_t1.gates.gate_a1 - the [[[gate_a1]]] subsection
  • self.config.airports.terminal_t1.gates.gate_a1.capacity - the value 200 (as int)
  • self.config.airports.terminal_t1.gates.gate_a1.airlines - the list ['FI', 'AF']

Mapping tables

A common pattern is storing mapping tables - translating codes from external systems to your internal values:

[mappings]

[[aircraft_types]]
738 = Boeing 737-800
763 = Boeing 767-300
388 = Airbus A380-800
320 = Airbus A320
321 = Airbus A321

[[delay_reasons]]
WX = Weather
TC = Technical
OP = Operational
SC = Security
CR = Crew

[[airline_codes]]
FI = Icelandair
AF = Air France
BA = British Airways

Use these mappings in your service:

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

# Zato
from zato.server.service import Service

class TranslateFlightData(Service):

    def handle(self):

        # Get the mapping tables
        aircraft_types = self.config.airports.mappings.aircraft_types
        delay_reasons = self.config.airports.mappings.delay_reasons

        # Translate codes
        aircraft_code = '738'
        aircraft_name = aircraft_types[aircraft_code]

        delay_code = 'WX'
        delay_description = delay_reasons[delay_code]

        self.logger.info(f'Aircraft: {aircraft_name}')
        self.logger.info(f'Delay reason: {delay_description}')

Translate and validate codes with config tables

Plain dict access covers a mapping you own. When each party you integrate with has its own codes for the same things, and when a value has to be checked before it is accepted, the same .ini files give you two methods on top of the dot access:

# Is this value one we accept?
if self.config.statuses.validate(status):
    ...

# What does this party's value mean here?
status = self.config.partners.translate(source='ALPINE_TELECOM', code='NON_PAYMENT', codes='statuses')

A file with a [codes] section is a list of the values you accept, a file with any other sections is a mapping table with one section per source. See Config tables for the full picture, including examples for healthcare and telecommunications.

Environment variables for secrets

For sensitive values like API keys, passwords, and tokens, don't put them directly in the config file. Instead, reference environment variables using the $ prefix:

[credentials]
api_key = $Airport_API_Key
client_secret = $Airport_Client_Secret

[database]
password = $Airport_DB_Password

When Zato loads the configuration, it automatically replaces $Variable_Name with the value from the corresponding environment variable. If the variable isn't set, a warning is logged.

In your service, you access these values exactly the same way as any other config value:

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

# Zato
from zato.server.service import Service

class ConnectToAirportAPI(Service):

    def handle(self):

        # These come from environment variables, but you access them the same way
        api_key = self.config.airports.credentials.api_key
        client_secret = self.config.airports.credentials.client_secret

        # Use them in your API calls, logging no more than a hint of the key
        key_hint = api_key[:4]
        self.logger.info(f'Connecting with API key: {key_hint}...')

DevOps and container deployments

When deploying with Docker, you pass environment variables to the container and mount your configuration directory. The DevOps deployment tutorial covers this in detail.

The typical setup:

  1. Mount the config directory from your host to /opt/hot-deploy/yourproject/config/user-conf inside the container
  2. Pass environment variables either directly with -e flags or through an env.ini file mounted at /opt/hot-deploy/enmasse/env.ini

Example from a deployment script:

docker run \
    --mount type=bind,source=$host_root_dir/config/user-conf,target=$target/myproject/config/user-conf \
    --mount type=bind,source=$host_root_dir/config/auto-generated/env.ini,target=$target/enmasse/env.ini \
    $package_address

The env.ini file contains the environment variables that your config files reference:

[env]
Airport_API_Key=your-api-key-here
Airport_Client_Secret=your-secret-here
Airport_DB_Password=your-password-here

This separation means your configuration structure is in version control, but the actual secrets are injected at deployment time from your secrets management system.

Override single values per environment

Beyond the $Variable_Name references inside a file, any single key can be overridden from the outside, without touching the file at all, with a variable named after the file, section and key:

Zato_Config_User_Config_<file_name>_<section>_<key>

Dots and dashes in the names become double underscores. For instance, to override timeout in the [api] section of airports.ini for one environment only:

export Zato_Config_User_Config_airports__ini_api_timeout=60

The file in git stays the same everywhere and the environments differ only in the variables they set.

Real-world example

Here's a more complete example showing how configuration fits into a real service that integrates with an airport operations API:

[api]
base_url = https://api.airport-ops.example.com
timeout = 30
token_sec_def_name = Airport.Ops.Token

[defaults]
days_to_check = 7
max_results = 100

[terminals]
domestic = T1
international = T2

[[gate_ranges]]
T1 = A1-A20
T2 = B1-B40

[credentials]
api_key = $Airport_API_Key
# -*- coding: utf-8 -*-

# stdlib
from dataclasses import dataclass

# Zato
from zato.server.service import Model, Service

@dataclass(init=False)
class FlightSearchRequest(Model):
    terminal: str
    date: str

@dataclass(init=False)
class Flight(Model):
    flight_id: str
    gate: str
    status: str

class SearchFlights(Service):

    input = FlightSearchRequest

    def handle(self):
        request:'FlightSearchRequest' = self.request.input

        # Get configuration
        config = self.config.airports

        # Build API request
        base_url = config.api.base_url
        timeout = config.api.timeout
        max_results = config.defaults.max_results

        # Translate terminal name to code
        terminal_code = config.terminals[request.terminal]

        # Get gate range for this terminal
        gate_range = config.terminals.gate_ranges[terminal_code]

        self.logger.info(f'Searching flights in terminal {terminal_code} (gates {gate_range})')

        # Make the API call
        conn = self.rest['Airport Ops API']
        api_response = conn.get(self.cid, params={
            'terminal': terminal_code,
            'date': request.date,
            'limit': max_results
        })
        flights_data = api_response.data

        # Process results
        flights = []
        for item in flights_data:
            flight = Flight()
            flight.flight_id = item['FlightID']
            flight.gate = item['Gate']
            flight.status = item['Status']
            flights.append(flight)

        self.response.payload = {'flights': flights}

See also

FeatureWhat it does
Config tablesTranslate each party's codes into yours and validate accepted values
Config tables in the dashboardEdit, check and save the files on one screen
Hot deploymentThe mechanism that reloads the files with no restarts

Learn more