Learning the OAA Runner Framework: Part 5 — Connecting to a REST API¶

This is Part 5 of the Learning the OAA Runner Framework series. It builds on the patterns established in the CSV track (Parts 1–4).
What you’ll learn: Configure a connections block, understand how a custom fetch module
handles API key authentication and pagination, and map API response fields to OAA user,
group, and role objects. By the end you’ll run the integration against the live BongaWonga
App API and see users, groups, and roles load without errors.
Prerequisites¶
An API key for the BongaWonga App (
BW_APP_API_KEY). Get it fromhttps://app.bongawonga.com/profile. See Getting API credentials for all systems.
Setup¶
Create your working directory:
mkdir -p 05-rest-api/modules
cd 05-rest-api
Set your API key as an environment variable:
export BW_APP_API_KEY=your_api_key
Copy the following code into a new file at modules/app_bw.py:
import logging
import time
from oaa.modules.base_url_session import BaseURLSession
from oaa.modules.lazy_dicts import lazy_dict_table
from oaa.modules.params_or_env import params_or_env
logger = logging.getLogger(__name__)
class BongaWongaAPI:
def __init__(self, connection, source):
self.connection = connection
self.source = source
self.base_url = params_or_env(connection, source, 'base_url')
self.api_key = params_or_env(connection, source, 'BW_APP_API_KEY')
self._session = None
@property
def session(self):
if not self._session:
self._session = BaseURLSession(base_url=self.base_url)
self._session.headers.update({
'Authorization': f'Bearer {self.api_key}',
})
return self._session
def fetch_paginated(self, path, resource_key='data', per_page=100):
page = 1
while True:
params = {'page': page, 'per_page': per_page}
logger.debug('Fetching %s page=%d', path, page)
response = self.session.get(path, params=params, timeout=30)
if response.status_code == 429:
logger.warning('Rate limited, retrying in 5s')
time.sleep(5)
continue
response.raise_for_status()
data = response.json()
records = data.get(resource_key, [])
if not records:
break
yield from records
pagination = data.get('pagination', {})
if not pagination.get('has_next', False):
break
page += 1
def fetch(connection=None, source=None, **kwargs):
api = BongaWongaAPI(connection, source)
resource_path = source.params.get('resource', '')
resource_key = source.params.get('resource_key', 'data')
logger.debug('Fetching from resource: %s', resource_path)
records = api.fetch_paginated(resource_path, resource_key=resource_key)
return lazy_dict_table(records)
Copy the following into a new file at config.yaml:
version: v2
log_level: INFO
You’ll build it step by step below.
Step 1: Define the application¶
Open config.yaml and add the app block:
app:
name: BongaWonga App
application_type: Custom App
Same pattern as the CSV examples.
Step 2: Register the fetch module¶
This example uses a Python module to fetch data from the API. Tell oaa-runner where to find it:
module_directory: ./modules
Step 3: Define the connection¶
A connections block is required for any source that uses a custom Python fetch module.
It holds authentication credentials, base URL, and other params the module needs at runtime.
Open config.yaml and add the following connections block:
connections:
bw_app:
connection_type: custom
custom_module: app_bw
required_env:
- BW_APP_API_KEY
params:
base_url: https://app.bongawonga.com/api/v1
connection_type: custom— uses a Python module for data fetching.custom_module: app_bw— filename (without.py) insidemodule_directory.required_env— env var names checked at startup. oaa-runner exits early if any are missing, preventing a confusing error later in the pipeline.params— arbitrary key/value pairs passed to the module at runtime.base_urlis the API root. The module reads it viaparams_or_env().
Step 4: Understand the fetch module¶
modules/app_bw.py was created in Setup. It has two parts: a class that handles HTTP and
pagination, and a fetch() function that oaa-runner calls once per source.
The fetch() function:
def fetch(connection=None, source=None, **kwargs):
api = BongaWongaAPI(connection, source)
resource_path = source.params.get('resource', '')
results = api.fetch_paginated(resource_path)
return lazy_dict_table(results)
connection— the connection config block fromconfig.yaml(hasparams).source— the source config block (has its ownparamslikeresource).lazy_dict_table()— wraps the results in a lazy iterator for memory efficiency.
The BongaWongaAPI class handles:
API key auth — sets
Authorization: Bearer <key>once on the sessionPaginated requests with
?page=1&per_page=100, followinghas_nextto the last page429 backoff: waits 5 seconds and retries
Two oaa-runner utilities do the heavy lifting:
params_or_env(connection, source, key)— resolves a config value from env vars,connection.params, orsource.params, in that priority order. This is how the module readsBW_APP_API_KEYfrom the environment without hard-coding env var names. See How to use oaa-runner utilities.BaseURLSession(base_url=...)— arequests.Sessionsubclass that prepends a base URL to every request, so callers can pass/usersinstead of the full URL. See How to use oaa-runner utilities.
You don’t need to modify app_bw.py for this example.
Step 5: Add the users source¶
Add a sources block:
sources:
users:
destination: LocalUser
source_type: custom
connection: bw_app
params:
resource: /users
resource_key: data
field_mapping:
unique_id:
destination_field: unique_id
email:
destination_field: email
transforms:
- name: lowercase
source_type: custom— calls thefetch()function from the connection’s module.connection: bw_app— references the connection defined in Step 3.params.resource— passed tofetch()assource.params.get('resource').params.resource_key: data— the key in the API response that contains the records array. The BongaWonga API wraps every response in a consistent envelope:{ "data": [ {"unique_id": "u-1", "email": "alice@example.com", "status": "active"}, {"unique_id": "u-2", "email": "bob@example.com", "status": "inactive"} ], "pagination": { "page": 1, "per_page": 100, "total": 2, "has_next": false } }
resource_key: datatells oaa-runner (and thefetch_paginatedmethod) to extract the array atresponse["data"]. Thepagination.has_nextfield controls whether to fetch the next page.
Try it: Run with users only¶
Your config is complete enough to run. Test it now:
uv tool run --from oaa-runner oaa custom_app run --config-file=config.yaml --no-push
You should see users loading from the API. If you see an authentication error, verify that
BW_APP_API_KEY is set correctly in your shell.
Step 6: Add the remaining user field mappings¶
The BongaWonga API uses different field names than OAA expects in a few places:
API returns
display_name— OAA expectsnameAPI returns
last_login— OAA expectslast_login_atAPI returns
status(a string enum) — OAA expectsis_active(a boolean)
Add these mappings to the users source field_mapping block:
name:
destination_field: name
transforms:
- name: jinja_render
options:
template: "{{ row.display_name }}"
given_name:
destination_field: given_name
custom_type: STRING
transforms:
- name: jinja_render
options:
template: "{{ row.first_name }}"
family_name:
destination_field: family_name
custom_type: STRING
transforms:
- name: jinja_render
options:
template: "{{ row.last_name }}"
title:
destination_field: title
custom_type: STRING
department:
destination_field: department
custom_type: STRING
status:
destination_field: is_active
transforms:
- name: jinja_render
options:
template: "{{ 'true' if row.status == 'active' else 'false' }}"
- name: validate_boolean
created_at:
destination_field: created_at
transforms:
- name: date_convert
last_login:
destination_field: last_login_at
transforms:
- name: date_convert
Why jinja_render for field renames:
Use the OAA field name as the mapping key and read the actual API field via row.<field>.
For example, name key with {{ row.display_name }} reads the API’s display_name and
stores it as OAA name. The last_login entry inverts this: the key matches the API field
and destination_field: last_login_at routes it to the OAA field.
Why status needs a transform chain:
The API returns status as a 4-value enum (active, inactive, suspended,
pending_activation). OAA’s is_active expects a boolean. The jinja_render converts
the string to "true" or "false", then validate_boolean converts it to a proper boolean.
Why date_convert for date-only strings:
The BongaWonga App API returns dates as YYYY-MM-DD with no time component. date_convert
handles both date-only strings and full ISO 8601 datetimes.
Step 7: Add groups and roles sources¶
groups:
destination: LocalGroup
source_type: custom
connection: bw_app
params:
resource: /groups
resource_key: data
field_mapping:
unique_id:
destination_field: unique_id
name:
destination_field: name
is_admin:
destination_field: is_admin
custom_type: BOOLEAN
roles:
destination: LocalRole
source_type: custom
connection: bw_app
params:
resource: /roles
resource_key: data
field_mapping:
unique_id:
destination_field: unique_id
name:
destination_field: name
Each source reuses the same bw_app connection. fetch() is called once per source,
each time fetching the endpoint specified in that source’s params.resource.
Complete config¶
version: v2
log_level: INFO
app:
name: BongaWonga App
application_type: Custom App
module_directory: ./modules
connections:
bw_app:
connection_type: custom
custom_module: app_bw
required_env:
- BW_APP_API_KEY
params:
base_url: https://app.bongawonga.com/api/v1
sources:
users:
destination: LocalUser
source_type: custom
connection: bw_app
params:
resource: /users
resource_key: data
field_mapping:
unique_id:
destination_field: unique_id
email:
destination_field: email
transforms:
- name: lowercase
name:
destination_field: name
transforms:
- name: jinja_render
options:
template: "{{ row.display_name }}"
given_name:
destination_field: given_name
custom_type: STRING
transforms:
- name: jinja_render
options:
template: "{{ row.first_name }}"
family_name:
destination_field: family_name
custom_type: STRING
transforms:
- name: jinja_render
options:
template: "{{ row.last_name }}"
title:
destination_field: title
custom_type: STRING
department:
destination_field: department
custom_type: STRING
status:
destination_field: is_active
transforms:
- name: jinja_render
options:
template: "{{ 'true' if row.status == 'active' else 'false' }}"
- name: validate_boolean
created_at:
destination_field: created_at
transforms:
- name: date_convert
last_login:
destination_field: last_login_at
transforms:
- name: date_convert
groups:
destination: LocalGroup
source_type: custom
connection: bw_app
params:
resource: /groups
resource_key: data
field_mapping:
unique_id:
destination_field: unique_id
name:
destination_field: name
is_admin:
destination_field: is_admin
custom_type: BOOLEAN
roles:
destination: LocalRole
source_type: custom
connection: bw_app
params:
resource: /roles
resource_key: data
field_mapping:
unique_id:
destination_field: unique_id
name:
destination_field: name
Run the complete integration¶
Before running, make sure your BW_APP_API_KEY is set in your shell:
export BW_APP_API_KEY=your_api_key
uv tool run --from oaa-runner oaa custom_app run --config-file=config.yaml --no-push
If it worked, you’ll see:
INFO - Loading users from /users
INFO - Loading groups from /groups
INFO - Loading roles from /roles
...
status: true
report: []
Users, groups, and roles should all load successfully. User-to-group and user-to-role relationships are not yet wired — that happens in Part 6.
To push to Veza, set VEZA_URL and VEZA_API_KEY and remove --no-push.
What’s next¶
This example loads one API into one destination type. The next example runs the same BongaWonga APIs three ways: as a Custom App, as an HRIS, and as an IdP — each using its own config file, its own destination type semantics, and its own connection module.