Building Your First OAA Integration¶
A complete walkthrough of building an OAA integration from scratch. This tutorial uses BongaWonga as an example. It is a fictional SaaS platform that exposes user, role, and group data via REST API.
By the end of this tutorial, you’ll have a working integration that:
Fetches users and their attributes from an API
Extracts roles from user data
Fetches groups and enriches them with member information
Links users to roles and groups
Pushes everything to Veza in OAA format
TL;DR¶
This is a comprehensive tutorial. Expect 30-60 minutes to complete. By the end, you’ll understand the full lifecycle of building an OAA integration.
Prerequisites¶
Before starting, you’ll need:
Python 3.9+ installed
uv package manager installed (see How to Install uv)
Basic understanding of REST APIs and HTTP
Access to BongaWonga API documentation (provided in this tutorial)
A code editor (VS Code, PyCharm, etc.)
What is an OAA Integration?¶
An OAA (Open Authorization API) integration extracts identity and access data from a system (like BongaWonga) and transforms it into a standardized format that Veza can understand and process.
The transformation pipeline:
BongaWonga API → Fetch Data → Transform Fields → Build Relationships → Push to Veza
Part 1: Project Setup¶
Let’s start by creating the project structure.
Create Directory Structure¶
mkdir -p bongawonga/modules
cd bongawonga
Your directory should look like this:
bongawonga/
├── config.yaml # Main configuration file
├── .env # Credentials (never commit)
├── .gitignore # Exclude .env and __pycache__
└── modules/
├── app_bw.py # API client
└── app_bw_hooks.py # Transformation hooks
Create .gitignore¶
Create a .gitignore file to prevent committing secrets:
cat > .gitignore << 'EOF'
# Environment variables and credentials
.env
.env.local
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
.Python
venv/
# IDE
.vscode/
.idea/
*.swp
# Logs
logs/
*.log
# Output
output.json
EOF
Create Environment Variables File¶
Create a .env file with placeholder values (you’ll fill these in later):
cat > .env << 'EOF'
# BongaWonga App API Configuration
BW_APP_CLIENT_ID=your_auth0_client_id_here
BW_APP_CLIENT_SECRET=your_auth0_client_secret_here
# Veza Configuration (optional for local testing)
VEZA_URL=https://your-veza-instance.vezacloud.com
VEZA_API_KEY=your_veza_api_key_here
EOF
Important: Never commit .env to version control!
Create Minimal config.yaml¶
Create a basic configuration file:
cat > config.yaml << 'EOF'
version: v2
app:
name: BongaWonga App
application_type: Custom App
module_directory: ./modules
logic_modules:
- app_bw_hooks
EOF
This minimal config:
Uses oaa-runner v2 format
Names the app “BongaWonga App”
Points to our modules directory
Imports our hooks module (which we’ll create next)
Now let’s create the API client!
Part 2: Define Your First Source - Users¶
Now we’ll add the users source to config.yaml. Update your config file:
version: v2
app:
name: BongaWonga App
application_type: Custom App
module_directory: ./modules
logic_modules:
- app_bw_hooks
# Data sources
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
first_name:
destination_field: first_name
custom_type: STRING
last_name:
destination_field: last_name
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
# Connection to BongaWonga API
connections:
bw_app:
connection_type: custom
custom_module: app_bw
required_env:
- BW_APP_CLIENT_ID
- BW_APP_CLIENT_SECRET
params:
base_url: https://app.bongawonga.com/api/v1
dataset_id: "1"
auth0_token_url: https://bongawonga.us.auth0.com/oauth/token
auth0_audience: bongawonga-enterprises
This config:
Defines a custom connection to BongaWonga using Auth0 OAuth2
Adds a
userssource that maps toLocalUser(user identities in Veza)Maps API fields to OAA fields (BongaWonga uses snake_case field names)
Includes a date conversion transform and lowercase normalization for email
Part 3: Create the API Client Module¶
The API client handles all communication with BongaWonga. We’ll build it incrementally.
Understanding the BongaWonga API¶
BongaWonga provides endpoints for users, roles, and groups:
Endpoint |
Purpose |
Authentication |
|---|---|---|
|
Get access token |
Client ID + Secret + Audience |
|
Fetch paginated users |
Bearer token |
|
Fetch paginated roles |
Bearer token |
|
Fetch paginated groups |
Bearer token |
|
Fetch group members |
Bearer token |
Authentication: Auth0 OAuth 2.0 Client Credentials flow
POST https://bongawonga.us.auth0.com/oauth/token
{
"grant_type": "client_credentials",
"client_id": "...",
"client_secret": "...",
"audience": "bongawonga-enterprises"
}
Response:
{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 3600
}
Pagination: Page + Per Page with has_next flag
GET /users?page=1&per_page=100
GET /users?page=2&per_page=100 # Next page
# Pagination response includes: {"pagination": {"has_next": true/false}}
Create modules/app_bw.py¶
This module handles all API interaction. Create the file:
cat > modules/app_bw.py << 'EOF'
"""BongaWonga API Client
Handles Auth0 authentication, pagination, and data fetching from BongaWonga API.
"""
import logging
import time
import requests
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:
"""Client for BongaWonga API"""
def __init__(self, connection, source):
"""
Initialize the API client.
Args:
connection: Connection config from config.yaml
source: Source config with parameters
"""
self.connection = connection
self.source = source
self.base_url = params_or_env(connection, source, 'base_url')
self.dataset_id = params_or_env(connection, source, 'dataset_id', default='1')
self.client_id = params_or_env(connection, source, 'BW_APP_CLIENT_ID')
self.client_secret = params_or_env(connection, source, 'BW_APP_CLIENT_SECRET')
self.token_url = params_or_env(connection, source, 'auth0_token_url')
self.audience = params_or_env(connection, source, 'auth0_audience', default='')
self._session = None
self._access_token = None
self._token_expires_at = 0.0
def _get_access_token(self):
"""Get Auth0 OAuth2 access token using client credentials."""
payload = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
}
if self.audience:
payload['audience'] = self.audience
logger.info('Requesting access token from %s', self.token_url)
response = requests.post(self.token_url, data=payload, timeout=30)
response.raise_for_status()
data = response.json()
self._access_token = data['access_token']
expires_in = data.get('expires_in', 3600)
self._token_expires_at = time.time() + expires_in - 60
logger.info('Obtained access token (expires in %ss)', expires_in)
@property
def session(self):
"""Lazy initialization of HTTP session with auth headers"""
if not self._access_token or time.time() >= self._token_expires_at:
self._get_access_token()
if not self._session:
api_url = f"{self.base_url}/{self.dataset_id}"
self._session = BaseURLSession(base_url=api_url)
self._session.headers.update({
'Authorization': f'Bearer {self._access_token}',
})
return self._session
def fetch_paginated(self, path, resource_key='data', per_page=100):
"""
Fetch paginated data from API.
Generator yields records one at a time for memory efficiency.
Args:
path (str): API endpoint path (e.g., '/users')
resource_key (str): JSON key containing records (default: 'data')
per_page (int): Records per page (default: 100)
Yields:
dict: Individual record from API
"""
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
if response.status_code == 401:
logger.warning('Received 401, re-authenticating')
self._access_token = None
self._get_access_token()
self._session.headers.update({
'Authorization': f'Bearer {self._access_token}',
})
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):
"""
Main entry point called by oaa-runner.
Args:
connection: Connection config from config.yaml
source: Source config
**kwargs: Additional arguments
Returns:
PETL table: Table of records
"""
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)
EOF
This module:
Implements Auth0 OAuth2 client credentials authentication
Provides
fetch_paginated()for memory-efficient data fetching with page-based paginationHandles rate limiting (429 responses) and re-authentication (401 responses)
Exports a
fetch()function that oaa-runner callsUses
lazy_dict_tablefor memory efficiency
Test Running Your Integration¶
Now test your integration:
uv tool run --from oaa-runner \
oaa app run --config-file=config.yaml --save-json
What this does:
Loads your config.yaml
Imports your modules
Connects to BongaWonga API via Auth0
Fetches users
Applies field mappings
Generates output.json with OAA-formatted data
Expected output in logs:
INFO: Requesting access token from https://bongawonga.us.auth0.com/oauth/token
INFO: Obtained access token (expires in 3600s)
DEBUG: Fetching from resource: /users
INFO: Success
Check the generated output.json:
# View the structure
jq '.' output.json | head -50
# Count users
jq '.local_users | length' output.json
# View first user
jq '.local_users[0]' output.json
You should see user records with the fields you mapped!
Part 4: Field Mapping¶
Field mapping is where source data becomes OAA-compatible data. Let’s deepen our mappings.
OAA Required Fields¶
For LocalUser, Veza requires:
unique_id (string) - Unique identifier for the user (required, must be unique)
name (string) - Display name (optional but highly recommended)
email (string) - Email address (optional)
Everything else is optional and can be custom fields.
Update Field Mappings¶
Let’s expand our users source with more sophisticated mappings:
sources:
users:
destination: LocalUser
source_type: custom
connection: bw_app
params:
resource: /users
resource_key: data
field_mapping:
# Required fields
unique_id:
destination_field: unique_id
email:
destination_field: email
transforms:
- name: lowercase
# Standard fields
first_name:
destination_field: first_name
custom_type: STRING
last_name:
destination_field: last_name
custom_type: STRING
# Combine first + last name into display name
name:
destination_field: name
transforms:
- name: jinja_render
options:
template: "{{ row.first_name }} {{ row.last_name }}"
# Boolean fields - convert status to is_active
status:
destination_field: is_active
transforms:
- name: jinja_render
options:
template: "{{ 'true' if row.status == 'active' else 'false' }}"
- name: validate_boolean
# Date fields
created_at:
destination_field: created_at
transforms:
- name: date_convert
hire_date:
destination_field: hire_date
transforms:
- name: date_convert
# Custom fields
department:
destination_field: department
custom_type: STRING
title:
destination_field: title
custom_type: STRING
manager_id:
destination_field: manager_id
custom_type: STRING
phone:
destination_field: phone
custom_type: STRING
Understanding the Transforms¶
jinja_render: Creates a new field using a Jinja2 template. Here we combine first and last names.
validate_boolean: Converts various truthy/falsy values (yes/no, true/false, 1/0, active/inactive) to actual booleans.
date_convert: Parses dates and converts to ISO 8601 format compatible with Veza.
Test the Updated Mapping¶
Run the integration again:
uv tool run --from oaa-runner \
oaa app run --config-file=config.yaml --save-json
Verify the new fields:
# View first user with all fields
jq '.local_users[0]' output.json
# Check that name field was created properly
jq '.local_users[] | {id: .unique_id, name: .name}' output.json | head -20
Excellent! Now let’s add roles.
Part 5: Add a Derived Source - Roles¶
Roles are often embedded in user data. Rather than making a separate API call, we can “derive” roles from the users source. This is more efficient.
Add Roles Source to config.yaml¶
Add this after your users source definition:
roles:
destination: LocalRole
source_type: source
source: users
field_mapping:
name:
destination_field: name
unique_id:
destination_field: unique_id
record_transforms_non_stream:
- map_roles_to_users
This config:
Uses
source_type: sourceto indicate it derives from another sourceReferences
source: users- uses users as inputMaps role fields to OAA fields (using BongaWonga snake_case naming)
Declares a record transform hook to link roles to users
Create Transformation Hooks¶
Now create modules/app_bw_hooks.py:
cat > modules/app_bw_hooks.py << 'EOF'
"""BongaWonga Integration Hooks
Hooks for data transformation, enrichment, and relationship building.
"""
import logging
from oaa.hooks.decorators import hook, OAAHookEvent, record_transform_non_stream
logger = logging.getLogger(__name__)
@hook(event=OAAHookEvent.PRE_TRANSFORM, target='roles')
def extract_roles_from_users(event, source=None, provider=None, **kwargs):
"""
Extract role records from user data.
Users may have roles embedded in their data. This hook flattens them into individual role records.
"""
def extract_role(row):
"""Extract a role record from a user row"""
if hasattr(row, 'role_name') and row.role_name:
yield (row.role_name, row.role_name)
source._stream = source.stream.rowmapmany(
extract_role,
header=['name', 'unique_id']
)
logger.debug(f"Extracted roles from {source.source} users")
@record_transform_non_stream(event=OAAHookEvent.RECORD_TRANSFORM, target='roles')
def map_roles_to_users(record=None, provider=None, **kwargs):
"""Link roles to users via the Provider object"""
try:
user = provider.local_users.get(record.unique_id)
if not user:
logger.warning(f"User {record.unique_id} not found for role {record.name}")
return
user.add_role(record.name, apply_to_application=True)
logger.debug(f"Added role {record.name} to user {record.unique_id}")
except Exception as e:
logger.error(f"Error mapping role {record.name}: {e}")
EOF
This file contains:
extract_roles_from_users(): A PRE_TRANSFORM hook that converts user records into role records using BongaWonga field names
map_roles_to_users(): A RECORD_TRANSFORM hook that links users to their roles
Test with Roles¶
Run the integration:
uv tool run --from oaa-runner \
oaa app run --config-file=config.yaml --save-json
Inspect the roles and relationships:
# Count roles
jq '.local_roles | length' output.json
# View a role
jq '.local_roles[0]' output.json
# Check user-role relationships
jq '.relationships[] | select(.src_type=="LocalUser" and .dst_type=="LocalRole") | length' output.json
Great! Now let’s add teams/groups.
Part 6: Add Groups Source¶
Groups (teams) are fetched from a separate API endpoint and represent collections of users.
Add Groups Source to config.yaml¶
Add this after your roles source:
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
description:
destination_field: description
custom_type: STRING
owner_id:
destination_field: owner_id
custom_type: STRING
status:
destination_field: is_active
transforms:
- name: jinja_render
options:
template: "{{ 'true' if row.status == 'active' else 'false' }}"
- name: validate_boolean
record_transforms_non_stream:
- map_group_members
This defines:
A custom source that fetches from
/groups(BongaWonga naming)Field mappings to convert group data to LocalGroup
A record transform to link users to groups
Add Group Membership Hook¶
Add this function to modules/app_bw_hooks.py:
@hook(event=OAAHookEvent.PRE_TRANSFORM, target='groups')
def fetch_group_members(event, source=None, **kwargs):
"""
Enrich group data with membership information.
For each group, fetch the list of member user IDs from the API.
"""
from modules.app_bw import BongaWongaAPI
from oaa.settings import config
api = BongaWongaAPI(
config.connections[source.connection],
source
)
def get_members(row):
"""Fetch members for a group"""
try:
path = f"/groups/{row.unique_id}/members"
response = api.session.get(path, timeout=30)
data = response.json()
members = data.get('data', [])
member_ids = [m['unique_id'] for m in members]
logger.debug(f"Group {row.unique_id} has {len(member_ids)} members")
return member_ids
except Exception as e:
logger.warning(f"Could not fetch members for group {row.unique_id}: {e}")
return []
source._stream = source.stream.addfield('members', get_members)
logger.debug("Enriched groups with membership data")
@record_transform_non_stream(event=OAAHookEvent.RECORD_TRANSFORM, target='groups')
def map_group_members(record=None, provider=None, **kwargs):
"""Link users to groups via the Provider object"""
try:
group = provider.local_groups.get(record.unique_id)
if not group:
logger.warning(f"Group {record.unique_id} not found in provider")
return
# Add group owner
if hasattr(record, 'owner_id') and record.owner_id:
owner = provider.local_users.get(record.owner_id)
if owner:
owner.add_group(record.unique_id)
logger.debug(f"Added owner {record.owner_id} to group {record.unique_id}")
# Add all group members
members = getattr(record, 'members', [])
for member_id in members:
user = provider.local_users.get(member_id)
if user:
user.add_group(record.unique_id)
logger.debug(f"Added {len(members)} members to group {record.unique_id}")
except Exception as e:
logger.error(f"Error mapping group members for {record.unique_id}: {e}")
Add these functions to your app_bw_hooks.py file.
Test with Groups¶
Run the integration:
uv tool run --from oaa-runner \
oaa app run --config-file=config.yaml --save-json
Verify groups and group memberships:
# Count groups
jq '.local_groups | length' output.json
# View a group
jq '.local_groups[0]' output.json
# Check user-group relationships
jq '.relationships[] | select(.src_type=="LocalUser" and .dst_type=="LocalGroup")' output.json | head -20
Perfect! Now let’s test everything end-to-end with better debugging.
Part 7: Run and Debug¶
Let’s run the complete integration with debugging tools.
Run the Complete Integration¶
uv tool run --from oaa-runner \
oaa app run \
--config-file=config.yaml \
--save-json \
--debug
Flags explained:
--config-file=config.yaml: Your configuration file--save-json: Saves output to a local JSON file (useful for inspection)--debug: Enable debug logging for detailed output
Inspect the Output¶
Use jq to query the output:
# Summary of all entities
jq '{
users: (.local_users | length),
roles: (.local_roles | length),
groups: (.local_groups | length),
relationships: (.relationships | length)
}' output.json
# View all users (paginated)
jq '.local_users | length' output.json
jq '.local_users[] | {id: .unique_id, name: .name, email: .email}' output.json | head -20
# View all roles
jq '.local_roles | length' output.json
jq '.local_roles[]' output.json
# View all groups
jq '.local_groups | length' output.json
jq '.local_groups[]' output.json
# Count relationships by type
jq '.relationships | group_by(.src_type) | map({type: .[0].src_type, count: length})' output.json
# View relationships for a specific user
jq '.relationships[] | select(.src_id=="user123")' output.json
Use Priority Push for Faster Testing¶
When testing against a real Veza instance, use --priority-push to get faster feedback:
uv tool run --from oaa-runner \
oaa app run \
--config-file=config.yaml \
--save-json \
--priority-push
This prioritizes your push in Veza’s processing queue, so you see results faster during development.
Common Errors and Fixes¶
Error |
Cause |
Fix |
|---|---|---|
|
Module not in |
Add to logic_modules list in config.yaml |
|
Field mapping references non-existent field |
Check API response field names with jq |
|
Mapping missing required unique_id field |
Ensure unique_id is mapped in field_mapping |
|
Can’t reach API endpoint |
Check base_url in config.yaml connections block |
|
Bad credentials |
Verify BW_APP_CLIENT_ID and BW_APP_CLIENT_SECRET in .env |
|
Field missing from source data |
Check source API actually returns this field |
Part 8: Production Readiness¶
Before deploying to production, make these improvements.
Add Error Handling¶
Update your API client to handle errors gracefully:
# In modules/app_bw.py, enhance the session property with error handling:
@property
def session(self):
"""Lazy initialization of HTTP session with auth headers and error handling"""
if not self._access_token or time.time() >= self._token_expires_at:
self._get_access_token()
if not self._session:
api_url = f"{self.base_url}/{self.dataset_id}"
self._session = BaseURLSession(base_url=api_url)
self._session.headers.update({
'Authorization': f'Bearer {self._access_token}',
})
return self._session
Handle Rate Limits¶
The API client already handles 429 (too many requests) with exponential backoff. For production, you might want to make this more sophisticated:
def get(self, path, params=None, retries=3):
"""GET with configurable retry logic"""
for attempt in range(retries):
try:
response = self.session.get(path, params=params, timeout=30)
if response.status_code == 429:
wait_time = (2 ** attempt) * 5 # Exponential backoff
logger.warning(f"Rate limited, waiting {wait_time}s (attempt {attempt+1}/{retries})")
time.sleep(wait_time)
continue
response.raise_for_status()
return response
except Exception as e:
if attempt == retries - 1:
raise
logger.warning(f"Attempt {attempt+1} failed: {e}")
time.sleep(2 ** attempt)
raise Exception(f"Failed to fetch {path} after {retries} attempts")
Use lazy_dict_table for Memory Efficiency¶
Your code already uses lazy_dict_table, which is excellent. This ensures large datasets don’t cause memory issues.
Add Structured Logging¶
Use logging effectively to track what’s happening:
import logging
# Configure logging with timestamps and levels
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Use appropriate log levels:
logger.debug("Detailed diagnostic information")
logger.info("General informational messages")
logger.warning("Warning messages (unexpected but not errors)")
logger.error("Error messages")
Validate Required Fields¶
Add validation to ensure critical fields are present:
def validate_record(record, required_fields):
"""Validate that required fields are present"""
for field in required_fields:
if not getattr(record, field, None):
logger.error(f"Missing required field '{field}' in record: {record}")
return False
return True
# In your PRE_TRANSFORM hooks:
@hook(event=OAAHookEvent.PRE_TRANSFORM, target='users')
def validate_users(event, source=None, **kwargs):
required = ['unique_id', 'email']
def filter_valid(row):
return validate_record(row, required)
source._stream = source.stream.select(filter_valid)
Complete Code Reference¶
Here’s the complete code for reference.
config.yaml (Complete)¶
version: v2
app:
name: BongaWonga App
application_type: Custom App
module_directory: ./modules
logic_modules:
- app_bw_hooks
connections:
bw_app:
connection_type: custom
custom_module: app_bw
required_env:
- BW_APP_CLIENT_ID
- BW_APP_CLIENT_SECRET
params:
base_url: https://app.bongawonga.com/api/v1
dataset_id: "1"
auth0_token_url: https://bongawonga.us.auth0.com/oauth/token
auth0_audience: bongawonga-enterprises
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
first_name:
destination_field: first_name
custom_type: STRING
last_name:
destination_field: last_name
custom_type: STRING
name:
destination_field: name
transforms:
- name: jinja_render
options:
template: "{{ row.first_name }} {{ row.last_name }}"
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
hire_date:
destination_field: hire_date
transforms:
- name: date_convert
department:
destination_field: department
custom_type: STRING
title:
destination_field: title
custom_type: STRING
manager_id:
destination_field: manager_id
custom_type: STRING
phone:
destination_field: phone
custom_type: STRING
roles:
destination: LocalRole
source_type: source
source: users
field_mapping:
name:
destination_field: name
unique_id:
destination_field: unique_id
record_transforms_non_stream:
- map_roles_to_users
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
description:
destination_field: description
custom_type: STRING
owner_id:
destination_field: owner_id
custom_type: STRING
status:
destination_field: is_active
transforms:
- name: jinja_render
options:
template: "{{ 'true' if row.status == 'active' else 'false' }}"
- name: validate_boolean
record_transforms_non_stream:
- map_group_members
modules/app_bw.py (Complete)¶
See Part 3 above for the complete implementation.
modules/app_bw_hooks.py (Complete)¶
"""BongaWonga Integration Hooks
Hooks for data transformation, enrichment, and relationship building.
"""
import logging
from oaa.hooks.decorators import hook, OAAHookEvent, record_transform_non_stream
logger = logging.getLogger(__name__)
@hook(event=OAAHookEvent.PRE_TRANSFORM, target='roles')
def extract_roles_from_users(event, source=None, provider=None, **kwargs):
"""
Extract role records from user data.
Users may have roles. This hook flattens them into individual role records.
"""
def extract_role(row):
"""Extract a role record from a user row"""
if hasattr(row, 'role_name') and row.role_name:
yield (row.role_name, row.role_name)
source._stream = source.stream.rowmapmany(
extract_role,
header=['name', 'unique_id']
)
logger.debug(f"Extracted roles from users")
@record_transform_non_stream(event=OAAHookEvent.RECORD_TRANSFORM, target='roles')
def map_roles_to_users(record=None, provider=None, **kwargs):
"""Link roles to users via the Provider object"""
try:
user = provider.local_users.get(record.unique_id)
if not user:
logger.warning(f"User {record.unique_id} not found for role {record.name}")
return
user.add_role(record.name, apply_to_application=True)
logger.debug(f"Added role {record.name} to user {record.unique_id}")
except Exception as e:
logger.error(f"Error mapping role {record.name}: {e}")
@hook(event=OAAHookEvent.PRE_TRANSFORM, target='groups')
def fetch_group_members(event, source=None, **kwargs):
"""Enrich group data with membership information"""
from modules.app_bw import BongaWongaAPI
from oaa.settings import config
api = BongaWongaAPI(
config.connections[source.connection],
source
)
def get_members(row):
"""Fetch members for a group"""
try:
path = f"/groups/{row.unique_id}/members"
response = api.session.get(path, timeout=30)
data = response.json()
members = data.get('data', [])
member_ids = [m['unique_id'] for m in members]
logger.debug(f"Group {row.unique_id} has {len(member_ids)} members")
return member_ids
except Exception as e:
logger.warning(f"Could not fetch members for group {row.unique_id}: {e}")
return []
source._stream = source.stream.addfield('members', get_members)
logger.debug("Enriched groups with membership data")
@record_transform_non_stream(event=OAAHookEvent.RECORD_TRANSFORM, target='groups')
def map_group_members(record=None, provider=None, **kwargs):
"""Link users to groups via the Provider object"""
try:
group = provider.local_groups.get(record.unique_id)
if not group:
logger.warning(f"Group {record.unique_id} not found in provider")
return
# Add group owner
if hasattr(record, 'owner_id') and record.owner_id:
owner = provider.local_users.get(record.owner_id)
if owner:
owner.add_group(record.unique_id)
logger.debug(f"Added owner {record.owner_id} to group {record.unique_id}")
# Add group members
members = getattr(record, 'members', [])
for member_id in members:
user = provider.local_users.get(member_id)
if user:
user.add_group(record.unique_id)
logger.debug(f"Added {len(members)} members to group {record.unique_id}")
except Exception as e:
logger.error(f"Error mapping group members for {record.unique_id}: {e}")
Next Steps¶
Congratulations! You’ve built a complete OAA integration. Here’s what to do next:
Explore Advanced Patterns¶
Chained Sources — Derive data from other sources without extra API calls
Async Enrichment — Fetch additional data in parallel using async workers
Mock Modules — Create mock data for testing without API credentials
Custom Transforms — Build Python functions for complex transformations
Deploy to Production¶
Set up proper error handling and logging
Configure rate limit backoff
Use environment variables for credentials
Test with real data from your system
Set up monitoring and alerting
Appendix: Common Patterns¶
Pattern: Handling Multiple Roles Per User¶
If users can have multiple roles, use rowmapmany:
def extract_roles(row):
"""Yield one role record per role in user's role list"""
for role in row.roles: # Assuming roles is a list
yield (role['name'], row.userId)
source._stream = source.stream.rowmapmany(
extract_roles,
header=['roleName', 'userId']
)
Pattern: Conditional Field Mapping¶
Use Jinja transforms for conditional logic:
displayName:
destination_field: name
transforms:
- name: jinja_render
options:
template: |
{% if row.userFirstName and row.userLastName %}
{{ row.userFirstName }} {{ row.userLastName }}
{% elif row.userFirstName %}
{{ row.userFirstName }}
{% else %}
{{ row.userEmail }}
{% endif %}
Pattern: Rate-Limited Parallel Requests¶
For fetching multiple resources per record, use asyncio:
import asyncio
from aiohttp import ClientSession
async def fetch_all_async(items, fetch_func):
"""Fetch multiple items in parallel with concurrency limit"""
async with ClientSession() as session:
tasks = [fetch_func(session, item) for item in items]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
This completes the tutorial! You now understand the full lifecycle of building an OAA integration.