feat(superoffice): POC API handshake & auth flow [2ff88f42]

Establishes the initial structure for the SuperOffice connector. Implements the complete, iterative authentication process, culminating in a successful refresh token exchange. Documents the process and the final blocker (API authorization) in the integration plan, awaiting IT action to change the application type to 'Server to server'.
This commit is contained in:
2026-02-06 13:52:44 +00:00
parent e6a3a24750
commit ff62024ef7
10 changed files with 310 additions and 24 deletions

View File

@@ -1,10 +0,0 @@
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

View File

@@ -0,0 +1 @@
# This file makes the directory a Python package

View File

@@ -0,0 +1,121 @@
import os
import requests
import logging
import time
logger = logging.getLogger(__name__)
class AuthHandler:
"""
Handles authentication against the SuperOffice API using an initial Authorization Code
exchange and subsequent Refresh Token flow.
"""
def __init__(self):
self.client_id = os.getenv("SO_SOD")
self.client_secret = os.getenv("SO_CLIENT_SECRET")
self.refresh_token = os.getenv("SO_REFRESH_TOKEN") # Will be set after initial code exchange
# Token endpoint for both initial code exchange and refresh token flow
self.token_endpoint = "https://sod.superoffice.com/login/common/oauth/tokens"
self._access_token = None
self._token_expiry_time = 0
if not self.client_id or not self.client_secret:
raise ValueError("SO_SOD and SO_CLIENT_SECRET must be set in the environment.")
def _is_token_valid(self):
"""Check if the current access token is still valid."""
return self._access_token and time.time() < self._token_expiry_time
def exchange_code_for_tokens(self, auth_code: str, redirect_uri: str):
"""
Exchanges an Authorization Code for an access token and a refresh token.
This is a one-time operation.
"""
logger.info("Exchanging Authorization Code for tokens...")
payload = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': auth_code,
'redirect_uri': redirect_uri
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
try:
response = requests.post(self.token_endpoint, data=payload, headers=headers)
response.raise_for_status()
token_data = response.json()
self._access_token = token_data['access_token']
self.refresh_token = token_data['refresh_token'] # Store the refresh token
self._token_expiry_time = time.time() + token_data['expires_in'] - 60
logger.info("Successfully exchanged code for tokens.")
return token_data
except requests.exceptions.HTTPError as http_err:
logger.error(f"HTTP error during code exchange: {http_err} - {response.text}")
raise
except requests.exceptions.RequestException as req_err:
logger.error(f"Request error during code exchange: {req_err}")
raise
except KeyError:
logger.error(f"Unexpected response format from token endpoint during code exchange: {response.text}")
raise ValueError("Could not parse tokens from code exchange response.")
def get_access_token(self):
"""
Retrieves an access token. If a valid token exists, it's returned from cache.
Otherwise, a new one is requested using the refresh token.
"""
if self._is_token_valid():
logger.info("Returning cached access token.")
return self._access_token
if not self.refresh_token:
raise ValueError("Refresh token is not available. Please perform initial code exchange.")
logger.info(f"Requesting new access token using refresh token from {self.token_endpoint}...")
payload = {
'grant_type': 'refresh_token',
'client_id': self.client_id,
'client_secret': self.client_secret,
'refresh_token': self.refresh_token
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
try:
response = requests.post(self.token_endpoint, data=payload, headers=headers)
response.raise_for_status()
token_data = response.json()
self._access_token = token_data['access_token']
# Update refresh token if it's renewed (optional, but good practice)
if 'refresh_token' in token_data:
self.refresh_token = token_data['refresh_token']
logger.info("Refresh token was renewed.")
self._token_expiry_time = time.time() + token_data['expires_in'] - 60
logger.info("Successfully obtained new access token via refresh token.")
return self._access_token
except requests.exceptions.HTTPError as http_err:
logger.error(f"HTTP error occurred during refresh token use: {http_err} - {response.text}")
raise
except requests.exceptions.RequestException as req_err:
logger.error(f"Request error occurred during refresh token use: {req_err}")
raise
except KeyError:
logger.error(f"Unexpected response format from token endpoint: {response.text}")
raise ValueError("Could not parse access token from refresh token response.")

View File

@@ -0,0 +1,23 @@
"""
This module will be responsible for communicating with the Company Explorer API.
- Fetching companies that need enrichment.
- Pushing enriched data back.
"""
class ExplorerClient:
def __init__(self, api_base_url, api_user, api_password):
self.api_base_url = api_base_url
self.auth = (api_user, api_password)
# TODO: Initialize requests session
def get_companies_to_sync(self):
"""
Fetches a list of companies from the Company Explorer that are ready to be synced to SuperOffice.
"""
pass
def get_company_details(self, company_id):
"""
Fetches detailed information for a single company.
"""
pass

View File

@@ -2,20 +2,71 @@ import os
import logging
from dotenv import load_dotenv
# Setup basic logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Import the new classes
from .auth_handler import AuthHandler
from .superoffice_client import SuperOfficeClient
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Load environment variables
load_dotenv()
def main():
logger.info("Starting SuperOffice Connector...")
"""
Main function to initialize and run the SuperOffice connector.
"""
logger.info("Starting SuperOffice Connector Proof of Concept...")
# Load environment variables from the root .env file
dotenv_path = os.path.join(os.path.dirname(__file__), '..', '.env')
logger.info(f"Attempting to load .env file from: {dotenv_path}")
# TODO: Implement authentication logic here using Gemini CLI
# TODO: Implement Polling / Sync logic here
logger.info("Connector stopped.")
if os.path.exists(dotenv_path):
load_dotenv(dotenv_path=dotenv_path)
logger.info(".env file loaded successfully.")
else:
logger.error(f".env file not found at the expected location: {dotenv_path}")
logger.error("Please ensure the .env file exists in the project root directory.")
return
try:
# 1. Initialize AuthHandler
auth_handler = AuthHandler()
# Define the tenant ID
TENANT_ID = "Cust26720" # As provided in the initial task description
# 2. Get a fresh access token (this will also update the refresh token if needed)
full_access_token = auth_handler.get_access_token()
logger.info(f"Full Access Token for curl: {full_access_token}")
# 3. Initialize SuperOfficeClient with the tenant ID
so_client = SuperOfficeClient(auth_handler, TENANT_ID)
# 3. Perform test connection
logger.info("--- Performing Connection Test ---")
connection_result = so_client.test_connection()
logger.info("--- Connection Test Finished ---")
if connection_result:
logger.info("POC Succeeded: Connection to SuperOffice API was successful.")
else:
logger.error("POC Failed: Could not establish a connection to SuperOffice API.")
except ValueError as ve:
logger.error(f"Configuration Error: {ve}")
logger.error("POC Failed due to missing configuration.")
except Exception as e:
logger.error(f"An unexpected error occurred during the POC execution: {e}", exc_info=True)
logger.error("POC Failed due to an unexpected error.")
logger.info("SuperOffice Connector POC finished.")
if __name__ == "__main__":
main()
main()

View File

@@ -1,4 +1,2 @@
requests
pydantic
python-dotenv
tenacity

View File

@@ -0,0 +1,55 @@
import requests
import logging
from .auth_handler import AuthHandler
logger = logging.getLogger(__name__)
class SuperOfficeClient:
"""
A client for interacting with the SuperOffice API.
"""
def __init__(self, auth_handler: AuthHandler, tenant_id: str):
# Base URL for the SuperOffice REST API, including tenant_id
self.base_url = f"https://sod.superoffice.com/{tenant_id}/api"
self.auth_handler = auth_handler
self.tenant_id = tenant_id
self.session = requests.Session()
def _get_auth_headers(self):
"""Returns the authorization headers with a valid token and context."""
access_token = self.auth_handler.get_access_token()
return {
'Authorization': f'Bearer {access_token}',
'Accept': 'application/json',
'X-SuperOffice-Context': f'TenantId={self.tenant_id}' # Crucial for multi-tenant environments
}
def test_connection(self):
"""
Performs a simple API call to test the connection and authentication.
Fetches the current user principal.
"""
endpoint = "/v1/User/currentPrincipal"
test_url = f"{self.base_url}{endpoint}"
logger.info(f"Attempting to test connection to: {test_url}")
try:
headers = self._get_auth_headers()
response = self.session.get(test_url, headers=headers)
response.raise_for_status()
user_data = response.json()
logger.info("Successfully connected to SuperOffice API.")
logger.info(f"Authenticated as: {user_data.get('Name', 'N/A')} ({user_data.get('Associate', 'N/A')})")
return user_data
except requests.exceptions.HTTPError as http_err:
logger.error(f"HTTP error during connection test: {http_err} - {http_err.response.text}")
return None
except requests.exceptions.RequestException as req_err:
logger.error(f"Request error during connection test: {req_err}")
return None
except Exception as e:
logger.error(f"An unexpected error occurred during connection test: {e}")
return None

View File

@@ -0,0 +1,22 @@
"""
This module contains the core logic for the synchronization process.
- Fetches data from Company Explorer.
- Fetches data from SuperOffice.
- Performs deduplication checks.
- Creates or updates companies and contacts in SuperOffice.
"""
class SyncEngine:
def __init__(self, superoffice_client, explorer_client):
self.so_client = superoffice_client
self.ex_client = explorer_client
def run_sync(self):
"""
Executes a full synchronization run.
"""
# 1. Get companies from Explorer
# 2. For each company, check if it exists in SuperOffice
# 3. If not, create it
# 4. If yes, update it (or skip)
pass