# Building Connectors in SuiteX iPaaS

**Version**: 1.1  
**Date**: 2026-03-30  
**Author**: Engineering (extracted from Salesforce connector implementation)  
**Reference implementation**: `SalesforceService` + `ConnectorOAuth2` Salesforce routing

---

## 1. Connector Overview

A **connector** in SuiteX iPaaS is a configured link between SuiteX and an external API. It stores authentication credentials, base URL, throttle settings, and application type. Once authenticated, connectors are used inside flows as the target of API nodes.

The Salesforce connector, used as the reference implementation throughout this document, demonstrates:

- **OAuth 2.0 Web Server Flow** (authorization code, with optional PKCE) as the auth mechanism
- **Dynamic instance URLs** — Salesforce returns a per-org URL at OAuth callback time that must be stored and used for all subsequent requests
- **SOQL query execution** with automatic multi-page pagination
- **REST CRUD** operations (GET, POST, PATCH) against Salesforce objects
- **Automatic token refresh** triggered by `INVALID_SESSION_ID` errors
- **Rate limiting** via the shared `ThrottleManager`
- **Error code mapping** from Salesforce-specific codes to iPaaS error patterns

---

## 2. Architecture Pattern

### Strategy Pattern

Every connector operates through the **Strategy Pattern**. The auth mechanism (OAuth2, OAuth1, Basic, JWT, Password, Token) selects a strategy class. Within that strategy, the `application` code (e.g. `salesforce_oauth2`) routes to a connector-specific service for any API-specific behaviour.

```
Flow executes API node
       │
       ▼
AuthContext::makeRequest($input)
       │
       ▼
ConnectorStrategy subclass (e.g. ConnectorOAuth2)
  │  makeRequest() — connector-specific pre-processing hook
  │       └─ if Salesforce → SalesforceService::prepareRequest()
  │                               sets base_url_override from instance_url
  ▼
ConnectorStrategy::request() — shared HTTP execution engine
  │  builds headers, calls IpaasHelper::executeCurl()
  │  handles 401/unauthorized → auto token refresh → retry
  │  handles 204 → structured success response
  ▼
Response returned to flow node
```

### Key components

| Component | Location | Responsibility |
|---|---|---|
| `ConnectorStrategyInterface` | `Strategy/ConnectorStrategyInterface.php` | Contract: `authenticate`, `refreshToken`, `makeRequest` |
| `ConnectorStrategy` (abstract) | `Abstracts/ConnectorStrategy.php` | Shared HTTP engine, header building, error/retry logic |
| `ConnectorOAuth2` | `Strategy/ConnectorOAuth2.php` | OAuth2 token lifecycle + application-specific routing hook in `makeRequest()` |
| `AuthContext` | `Strategy/AuthContext.php` | Selects and delegates to the correct strategy at runtime |
| `SalesforceService` | `App/Services/Ipaas/Connectors/SalesforceService.php` | All Salesforce-specific logic (URL building, response formatting, error mapping) |
| `CreateConfigConnector` | `Domain/Ipaas/Connectors/Actions/CreateConfigConnector.php` | OAuth callback handling, token + instance URL extraction and persistence |
| `ConnectorConfig` | `Domain/Ipaas/Connectors/Models/ConnectorConfig.php` | Eloquent model for auth credentials; uses `HasEncryptedFields` trait |
| `Connector` | `Domain/Ipaas/Connectors/Models/Connector.php` | Eloquent model for the connector record itself |
| `ConnectorSeeder` | `database/seeders/tenants/ConnectorSeeder.php` | Seeds application type master data into `connector_config_types` |
| Connector JSON files | `Domain/Ipaas/Connectors/Data/{code}.json` | Default form values for the connector creation UI; **one file required per application code** |
| `ConnectorService` | `App/Services/Ipaas/Connectors/ConnectorService.php` | Reads JSON data files; `getDefaultValues($code)` returns false if the file is missing, triggering the "No default values found" error |
| `IpaasHelper` | `App/Helpers/IpaasHelper.php` | `executeCurl()`, `isUnauthorizedError()`, header utilities |
| `ThrottleManager` | `App/Services/Ipaas/ThrottleManager.php` | Rate limiting per connector |

### Data flow

```
OAuth callback
  Salesforce → callback URL → CreateConfigConnector::callback()
     ├─ Extracts access_token, refresh_token, instance_url from token response
     ├─ Saves encrypted tokens to connector_config
     └─ Saves instance_url (plaintext) to connector_config.instance_url

API request (flow execution)
  ApiNode → AuthContext::makeRequest()
     ├─ ConnectorOAuth2::makeRequest()
     │    └─ SalesforceService::isSalesforceConnector() → true
     │         └─ SalesforceService::prepareRequest()
     │              └─ sets input['base_url_override'] = instance_url
     └─ ConnectorStrategy::request()
          ├─ Builds full URL: base_url_override + relativeURL
          ├─ IpaasHelper::setHeaders() adds Authorization: Bearer {access_token}
          ├─ IpaasHelper::executeCurl() fires HTTP request
          ├─ 401 / INVALID_SESSION_ID → refreshToken() → retry
          └─ 204 No Content → ['success' => true, 'httpStatusCode' => 204]
```

---

## 3. Implementation Breakdown

### Step 1 — Database migration

Add any new columns required by the connector to `connector_config`.

```php
// database/migrations/tenants/YYYY_MM_DD_HHMMSS_add_{field}_to_connector_config.php
Schema::table('connector_config', function (Blueprint $table) {
    $table->string('instance_url', 255)->nullable();
});
```

**Rules:**
- Use `Schema::table()`, never `Schema::connection('tenant_connection')` — existing tenant migrations do not use a connection prefix.
- Never use `->after()` — it is not supported by SQLite (used in tests).
- All new columns must be `nullable()` to avoid breaking existing rows.

### Step 2 — Update `ConnectorConfig::$fillable`

Add any new columns to the `$fillable` array so Eloquent can mass-assign them.

```php
// src/Domain/Ipaas/Connectors/Models/ConnectorConfig.php
protected $fillable = [
    // ... existing fields ...
    'instance_url',
];
```

### Step 3 — Seed application types

Add the new application type(s) to `ConnectorSeeder`. **Always include a hardcoded `"id"` value**, incrementing from the last entry in the file. The `id` is referenced directly by the JSON data file (Step 4) and must be stable across all environments.

```php
// database/seeders/tenants/ConnectorSeeder.php
// Check the last entry in the $records array and increment its id by 1 for each new entry
[
    "id" => 31,
    "category" => "application",
    "code" => "my_system_oauth2",
    "name" => "My System (OAuth 2.0)",
    "created_at" => null,
    "updated_at" => null
],
```

The seeder's `foreach` loop calls `where('code', ...)->first()` and only inserts if absent, preventing duplicates on re-seed. Current highest assigned ID: **30** (`salesforce_sandbox_oauth2`).

### Step 4 — Create the connector JSON data file

**This is required.** The connector creation form reads default values from a JSON file. Without it, selecting the application type in the UI shows the error: `"No default values found for this connector type"`.

Create `src/Domain/Ipaas/Connectors/Data/{application_code}.json` — one file per application code. The filename must exactly match the `code` value seeded in Step 3.

```json
{
    "name": "My System Connector",
    "connector_type": "my_system_oauth2",
    "base_url": "https://login.mysystem.com",
    "auth_type": "11",
    "auth_url": "",
    "config": {
        "client_id": "",
        "client_secret": "",
        "redirect_url": "",
        "scope": "read write offline_access",
        "token_secret": "",
        "send_credentials_via": "5",
        "send_token_via": "8",
        "auth_endpoint": "https://login.mysystem.com/oauth2/authorize",
        "access_token_url": "https://login.mysystem.com/oauth2/token",
        "revoke_token_url": "",
        "resource_url": "https://login.mysystem.com/oauth2/userinfo",
        "valid_domains": "",
        "headers": [{"key": "", "value": ""}],
        "requestBody": [{"key": "", "value": ""}],
        "consumer_key": "",
        "consumer_secret": "",
        "access_token": "",
        "signature_method": "",
        "realm": "",
        "include_body_hash": "",
        "add_empty_params": "",
        "encode_params": ""
    },
    "throttle_max_requests": 100,
    "throttle_per_seconds": 20,
    "media_type": "14",
    "grant_type": "1",
    "auth_scope": "",
    "application": "31"
}
```

**Key fields:**

| Field | Value | Notes |
|---|---|---|
| `connector_type` | `my_system_oauth2` | Must match filename and seeder `code` |
| `application` | `"31"` | Must match the hardcoded `id` in the seeder (Step 3); stored as a string |
| `auth_type` | `"11"` | ID for OAuth 2.0 in `connector_config_types` |
| `grant_type` | `"1"` | ID for Authorization Code in `connector_config_types` |
| `media_type` | `"14"` | ID for JSON in `connector_config_types` |
| `auth_endpoint` | system-specific | Pre-populates the form; user can override |
| `access_token_url` | system-specific | Pre-populates the form; user can override |
| `scope` | system-specific | Pre-populates required OAuth scopes |
| `base_url` | login/auth host | Used until a dynamic instance URL overrides it |
| `throttle_max_requests` | system-specific | Salesforce = 100; generic = 20 |

If the system has two variants (production/sandbox), create **two separate JSON files**, one per application code, each with its own `application` ID matching the seeder.

**Reference files:** `src/Domain/Ipaas/Connectors/Data/salesforce_oauth2.json`, `salesforce_sandbox_oauth2.json`

### Step 5 — Create the connector service class

Create `src/App/Services/Ipaas/Connectors/{SystemName}Service.php`. This class is the only file that contains system-specific logic. It must not extend any strategy class.

**Required methods** (see Section 7 for the template — `{SystemName}Service.php`):

| Method | Signature | Purpose |
|---|---|---|
| `isSalesforceConnector` | `static isSalesforceConnector(string $code): bool` | Guards routing in `makeRequest()` and callback |
| `prepareRequest` | `prepareRequest(Connector $connector, array $input): array` | Mutates `$input` before the HTTP call (e.g. sets `base_url_override`) |
| `extractInstanceUrl` | `extractInstanceUrl(array $oauthValues): ?string` | Parses the instance URL from the token response |
| `mapErrorCode` | `mapErrorCode(array $error): string` | Maps API-specific error codes to iPaaS patterns |
| `formatQueryResponse` | `formatQueryResponse(array $response): array` | Normalises API response for downstream flow nodes |
| `handlePaginationToken` | `handlePaginationToken(array $response): ?string` | Returns next page URL or null |
| `buildQueryUrl` | `buildQueryUrl(string $baseUrl, string $query): string` | Constructs a query endpoint URL |
| `applySalesforceHeaders` | `applySalesforceHeaders(array $headers, string $token): array` | Utility for replacing Authorization headers (not called by `prepareRequest`) |

**Critical constraints in `prepareRequest`:**
- Do **not** add an `Authorization` header — `ConnectorStrategy::request()` does this automatically via `IpaasHelper::setHeaders()` when `$this->setToken = true`.
- Always use null-safe access for the config relationship: `$connector->config?->instance_url`.
- Strip trailing slashes from any base URL before storing in `base_url_override`.

### Step 6 — Hook into the strategy's `makeRequest()`

The auth strategy's `makeRequest()` method is the single hook point where application-specific pre-processing is injected. For OAuth2 connectors, edit `ConnectorOAuth2::makeRequest()`:

```php
public function makeRequest($input)
{
    try {
        $this->setToken = true;
        $connector = $input['connector'];
        $applicationCode = $connector->app?->code ?? '';

        if (MySystemService::isMySystemConnector($applicationCode)) {
            $sfService = new MySystemService();
            $input = $sfService->prepareRequest($connector, $input);
        }

        return $this->request($input);
    } catch (Exception $e) {
        throw $e;
    }
}
```

Use `static isSalesforceConnector()` (or equivalent) on the service — not `in_array()` inline — so the application code list has a single source of truth (`SALESFORCE_CODES` constant in the service).

### Step 7 — Handle the OAuth callback

`CreateConfigConnector::callback()` is where tokens are persisted after the user completes the OAuth flow. Two things are required for connectors with dynamic base URLs:

**A — Skip `HttpBasicAuthOptionProvider` if the external system does not support it:**

```php
$providerOptions = [];
if (!MySystemService::isMySystemConnector($appCode)) {
    $providerOptions['optionProvider'] = new HttpBasicAuthOptionProvider();
}
$provider = new GenericProvider([...], $providerOptions);
```

Salesforce does not accept HTTP Basic auth on its token endpoint. Omitting `HttpBasicAuthOptionProvider` for Salesforce was a required fix.

**B — Extract and persist extra values from the token response:**

```php
if (MySystemService::isMySystemConnector($appCode)) {
    $values = $accessToken->getValues();
    $record->instance_url = $values['instance_url'] ?? null;
}
$record->save();
```

`AccessToken::getValues()` returns all non-standard fields included in the token JSON response. Salesforce includes `instance_url` here.

### Step 8 — Extend `IpaasHelper::isUnauthorizedError()` if needed

The token refresh cycle in `ConnectorStrategy::request()` is triggered by `IpaasHelper::isUnauthorizedError()`. If the external system uses a non-standard error code to indicate an expired session, extend the method:

```php
if (str_contains(strtolower($key), 'code')) {
    $lowerValue = strtolower($value);
    return $lowerValue === 'unauthorized' || $lowerValue === 'invalid_session_id';
}
```

Salesforce returns `{"errorCode": "INVALID_SESSION_ID"}` instead of HTTP 401, so this extension was required to trigger automatic token refresh.

### Step 9 — Write tests

Three test files are expected per connector:

| File | Framework | What it covers |
|---|---|---|
| `tests/Unit/Services/Ipaas/Connectors/{System}ServiceTest.php` | Pest | All public methods on the service class, plus regression tests for any `IpaasHelper` or `ConnectorStrategy` extensions |
| `tests/Unit/Domain/Ipaas/Connectors/Strategy/ConnectorOAuth2{System}Test.php` | Pest | `makeRequest()` routing, `isValidTokenConfig()` grant types, `getNewAccessToken()` decrypt guard |
| `tests/Feature/Http/Controllers/Ipaas/{System}ConnectorTest.php` | Pest | OAuth callback token extraction, `HttpBasicAuthOptionProvider` conditional, PKCE case verification, HTTP 204 handling |

**Test infrastructure rule:** Any `connector_config` inline table definition in test files must include the `instance_url` column to stay in sync with the migration.

---

## 4. Reusable Patterns & Components

### `ConnectorStrategy::request()` — shared HTTP engine

**Never bypass this.** All HTTP requests must route through `ConnectorStrategy::request()`, which provides:
- Header construction (Content-Type, Accept, Authorization via `IpaasHelper::setHeaders`)
- cURL execution via `IpaasHelper::executeCurl()`
- 401 detection and automatic token refresh (max 3 retries)
- Custom 4xx error detection via `IpaasHelper::isUnauthorizedError()`
- HTTP 204 structured response: `['success' => true, 'httpStatusCode' => 204]`
- Timeout handling: per-request override → connector config timeout → cURL default

### `base_url_override` input key

Set `$input['base_url_override']` in `prepareRequest()` to override the connector's `base_url` for that specific request without mutating the model. `ConnectorStrategy::request()` picks this up automatically:

```php
$baseUrl = $input['base_url_override'] ?? $this->connector->base_url;
$this->requestURL = $baseUrl . $input['relativeURL'];
```

This is the correct mechanism for connectors with dynamic host URLs (Salesforce, Snowflake async polling, etc.).

### `HasEncryptedFields` trait

Applied to `ConnectorConfig`. Any field listed in `ConnectorConfig::$encryptedFields` is automatically encrypted on write and decrypted on read via accessors. Always use `getDecryptedRefreshToken()` and `getDecryptedClientSecret()` rather than reading the raw property — raw reads return ciphertext.

```php
// WRONG — sends encrypted bytes to Salesforce
'refresh_token' => $tokenData->refresh_token,

// CORRECT — sends the actual token string
'refresh_token' => $tokenData->getDecryptedRefreshToken(),
```

### `IpaasHelper::isUnauthorizedError()`

Recursively inspects an error response array for keys containing `"code"` or `"errorid"`. Extend this method (not inline `in_array` checks) whenever a new connector uses a non-standard session expiry signal.

### `ThrottleManager`

Retrieved per-connector via `$connector->getThrottler()`. No configuration is needed in the connector service — the `Connector` model wires this up automatically from `throttle_max_requests` and `throttle_per_seconds`.

### Naming convention for application codes

All application codes follow the pattern `{system}_{auth_type}`:

| System | Production code | Sandbox/variant code |
|---|---|---|
| Salesforce | `salesforce_oauth2` | `salesforce_sandbox_oauth2` |
| NetSuite | `netsuite_oauth2` | `netsuite_oauth1` |
| Snowflake | (JWT, not application-coded) | — |

The service class defines these as a single `const` array:

```php
public const MY_SYSTEM_CODES = ['my_system_oauth2', 'my_system_sandbox_oauth2'];

public static function isMySystemConnector(string $code): bool
{
    return in_array($code, self::MY_SYSTEM_CODES, true);
}
```

This constant is referenced from **every** place that needs to identify the connector: `makeRequest()`, `callback()`, and any future additions.

---

## 5. Required Inputs for Future Connectors

### Auth configuration fields (stored in `connector_config`)

| Field | Required for | Notes |
|---|---|---|
| `client_id` | OAuth2 | From external system's Connected App |
| `client_secret` | OAuth2 | Encrypted via `HasEncryptedFields` |
| `auth_endpoint` | OAuth2 Authorization Code | e.g. `https://login.salesforce.com/services/oauth2/authorize` |
| `access_token_url` | OAuth2 | e.g. `https://login.salesforce.com/services/oauth2/token` |
| `redirect_url` | OAuth2 | Must match `https://app.suitex.com/tenant/ipaas/connector/oauth/callback` |
| `resource_url` | OAuth2 | Required by `league/oauth2-client` GenericProvider; set to userinfo endpoint |
| `grant_type` | OAuth2 | FK to `connector_config_types` (category: `grant_types`) |
| `refresh_token` | OAuth2 Authorization Code | Encrypted; required for token refresh cycle |
| `access_token` | All | Encrypted; set after successful auth |
| `expires` | All | Unix timestamp; triggers refresh when in the past |
| `instance_url` | Dynamic base URL systems | Plaintext; populated from OAuth callback |

### Connector record fields (stored in `connectors`)

| Field | Notes |
|---|---|
| `base_url` | The system's login or API root URL; may be overridden per-request by `instance_url` |
| `application` | FK to `connector_config_types` (category: `application`); drives routing in `makeRequest()` |
| `auth_type` | FK to `connector_config_types` (category: `auth_types`); selects strategy class |
| `throttle_max_requests` | Default: 100 |
| `throttle_per_seconds` | Default: 20 |

### Request input contract (`$input` array in `makeRequest`)

| Key | Type | Required | Notes |
|---|---|---|---|
| `connector` | `Connector` | Yes | Loaded Eloquent model with `config` and `app` relations |
| `httpMethod` | `string` | Yes | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| `relativeURL` | `string` | Yes | Appended to `base_url` or `base_url_override` |
| `requestBody` | `string\|array` | No | JSON string or array; serialised by the base strategy |
| `contentType` | `string` | No | `raw`, `formdata`, `graphql`, etc. |
| `requestParams` | `array` | No | Query string params appended to URL |
| `headers` | `array` | No | Additional headers in `["Key: Value"]` format |
| `payload` | `mixed` | No | Used for GraphQL variable injection |
| `base_url_override` | `string` | No | Set by connector service to override `connector->base_url` |
| `timeout` | `int` | No | Per-request timeout in seconds (max 180) |
| `isPreview` | `bool` | No | Short-circuits retry on 401 after first attempt |

---

## 6. Edge Cases & Lessons Learned

### HttpBasicAuthOptionProvider breaks Salesforce token exchange

The `league/oauth2-client` library defaults to sending client credentials in the `Authorization: Basic` header. Salesforce's token endpoint rejects this; it expects credentials in the POST body. The fix is to conditionally omit `HttpBasicAuthOptionProvider` for Salesforce.

**Pattern**: Always check whether the target system's token endpoint supports HTTP Basic auth before passing the option provider. When in doubt, check the system's OAuth documentation for the token endpoint's accepted credential formats.

### `INVALID_SESSION_ID` does not come as HTTP 401

Salesforce returns a 200 status with a body containing `[{"errorCode": "INVALID_SESSION_ID"}]` for expired tokens rather than a bare 401. The shared retry logic in `ConnectorStrategy::request()` only triggers on HTTP 401 by default. The `IpaasHelper::isUnauthorizedError()` extension is the correct entry point to handle this pattern without modifying the base strategy.

**Rule**: Any new connector that uses a non-401 signal for expired sessions must extend `isUnauthorizedError()`, not add inline checks in the strategy.

### Dynamic instance URLs cannot be derived from the base_url

Salesforce serves API requests from an org-specific subdomain (`na50.salesforce.com`, `cs42.sandbox.salesforce.com`). This URL is only known after the OAuth callback. It cannot be derived from the `connector.base_url` field, which only holds the login URL. The instance URL must be extracted from the token response during the OAuth callback and stored in `connector_config.instance_url`.

**Pattern**: For any system where the API host differs from the auth host, store the runtime-provided API host in `connector_config` and reference it via `base_url_override` in every request.

### Accessing `$tokenData->refresh_token` directly sends ciphertext

`ConnectorConfig` uses the `HasEncryptedFields` trait. Accessing `->refresh_token` directly returns the encrypted value. The fix is to always call `->getDecryptedRefreshToken()` for both the guard check (`empty(...)`) and the value sent to the OAuth provider.

**Rule**: Never read an encrypted field raw. Always use the corresponding `getDecrypted*()` accessor.

### PKCE grant type case mismatch

The stored grant type code is `authorizationcodewithpkce` (all lowercase, no separator). A pre-existing bug used `authorizationcodepkce` in `CreateConfigConnector::callback()`, causing the PKCE exchange to silently fall through the switch statement. The fix is a string comparison that exactly matches the seeded code.

**Rule**: Always verify that `switch/match` cases against `connector_config_types.code` values exactly match the seeded strings — these are not constants and are easy to mistype.

### Migration `->after()` is not SQLite-compatible

Using `->after('column_name')` in a migration causes failures in the SQLite in-memory databases used by tests. Omit `->after()` entirely. Column ordering does not affect application behaviour.

### Seeder IDs must be hardcoded and sequential

Every entry in `ConnectorSeeder` uses a hardcoded `"id"` value. The `application` field in the connector JSON data file is a plain string that must match this ID exactly. If you omit the `"id"` from the seeder entry, the row will get an auto-incremented ID that varies by environment, making it impossible to write a JSON data file with the correct `application` value.

**Rule**: Always check the last assigned ID in the seeder, increment by 1 per new entry, include `"id"` in every new record, and set the matching `"application"` value in the JSON file to that same number (as a string). Never omit the ID or let the database auto-assign it.

### `ConnectorOAuth2::makeRequest()` is the correct hook, not `authenticate()`

The spec suggested extracting `instance_url` in `ConnectorOAuth2::authenticate()`. That method handles the client credentials and password flows only. The authorization code flow (used by Salesforce) is completed in `CreateConfigConnector::callback()`. Always trace the actual auth flow before deciding where to add callback-phase logic.

### `formatQueryResponse()` must guard against absent `records` key

Not all API responses include a `records` key. Iterating without a guard will throw a PHP warning. Always use `$response['records'] ?? []`.

---

## 7. Standardized Connector Template

### File structure

```
database/
  migrations/
    tenants/
      YYYY_MM_DD_HHMMSS_add_{field}_to_connector_config.php   ← if new DB columns needed

database/
  seeders/
    tenants/
      ConnectorSeeder.php                                       ← append application type entries (with hardcoded id)

src/
  Domain/
    Ipaas/
      Connectors/
        Data/
          {application_code}.json                              ← NEW: required for connector form defaults
          {application_code_variant}.json                      ← one file per application code (e.g. sandbox)
        Models/
          ConnectorConfig.php                                   ← add new columns to $fillable
        Strategy/
          ConnectorOAuth2.php                                   ← add routing in makeRequest()
        Actions/
          CreateConfigConnector.php                             ← add callback extraction logic
      Abstracts/
        ConnectorStrategy.php                                   ← extend isUnauthorizedError if needed

src/
  App/
    Services/
      Ipaas/
        Connectors/
          {SystemName}Service.php                               ← new file: all system-specific logic

tests/
  Unit/
    Services/
      Ipaas/
        Connectors/
          {SystemName}ServiceTest.php                           ← Pest unit tests for service
  Unit/
    Domain/
      Ipaas/
        Connectors/
          Strategy/
            ConnectorOAuth2{SystemName}Test.php                 ← Pest: routing, PKCE, decrypt guard
  Feature/
    Http/
      Controllers/
        Ipaas/
          {SystemName}ConnectorTest.php                         ← Pest: callback, instance URL, 204
```

### `{SystemName}Service.php` template

```php
<?php

namespace App\Services\Ipaas\Connectors;

use Domain\Ipaas\Connectors\Models\Connector;

class {SystemName}Service
{
    public const API_VERSION = 'v1';
    public const {SYSTEM}_CODES = ['{system}_oauth2', '{system}_sandbox_oauth2'];

    // ── Routing guard ────────────────────────────────────────────────────────

    public static function is{SystemName}Connector(string $applicationCode): bool
    {
        return in_array($applicationCode, self::{SYSTEM}_CODES, true);
    }

    // ── Request preparation (called from ConnectorOAuth2::makeRequest) ───────

    /**
     * Mutates $input before the HTTP call.
     * Sets base_url_override from the stored instance URL.
     * Must NOT add an Authorization header — the base strategy handles that.
     */
    public function prepareRequest(Connector $connector, array $input): array
    {
        $instanceUrl = rtrim($connector->config?->instance_url ?? $connector->base_url, '/');
        $input['base_url_override'] = $instanceUrl;
        return $input;
    }

    // ── OAuth callback utilities ─────────────────────────────────────────────

    /**
     * Extracts a dynamic base URL from the OAuth token response values.
     * Called from CreateConfigConnector::callback() after getAccessToken().
     */
    public function extractInstanceUrl(array $oauthValues): ?string
    {
        return $oauthValues['instance_url'] ?? null;
    }

    // ── Query utilities ──────────────────────────────────────────────────────

    public function buildQueryUrl(string $instanceUrl, string $query): string
    {
        return rtrim($instanceUrl, '/') . '/api/' . self::API_VERSION . '/query?q=' . urlencode($query);
    }

    public function formatQueryResponse(array $response): array
    {
        $records = $response['records'] ?? [];
        $cleaned = [];
        foreach ($records as $record) {
            unset($record['attributes']); // Strip system metadata
            $cleaned[] = $record;
        }

        $result = [
            'totalSize' => $response['totalSize'] ?? 0,
            'done'      => $response['done'] ?? true,
            'records'   => $cleaned,
        ];

        if (isset($response['nextRecordsUrl'])) {
            $result['nextRecordsUrl'] = $response['nextRecordsUrl'];
        }

        return $result;
    }

    public function handlePaginationToken(array $response): ?string
    {
        if (($response['done'] ?? true) === false) {
            return $response['nextRecordsUrl'] ?? null;
        }
        return null;
    }

    // ── Error mapping ────────────────────────────────────────────────────────

    public function mapErrorCode(array $error): string
    {
        $code = $error['errorCode'] ?? '';

        return match ($code) {
            'SESSION_EXPIRED'    => 'token_expired',
            'RATE_LIMIT'         => 'rate_limit',
            'TIMEOUT'            => 'query_timeout',
            'VALIDATION_ERROR'   => 'validation_error',
            default              => 'api_error',
        };
    }
}
```

### Build checklist

```
[ ] 1. MIGRATION
      [ ] Add columns to connector_config via Schema::table() (no ->after(), all nullable)
      [ ] Add columns to ConnectorConfig::$fillable

[ ] 2. SEEDER
      [ ] Find the last "id" value in ConnectorSeeder $records array (currently 30)
      [ ] Append application type entries with sequential hardcoded "id" values (31, 32, ...)
      [ ] One entry per application variant (e.g. production + sandbox = two entries)

[ ] 3. JSON DATA FILE  ← required: without this the connector form shows an error
      [ ] Create src/Domain/Ipaas/Connectors/Data/{application_code}.json
      [ ] Filename must exactly match the seeder "code" value
      [ ] Set "application" to the hardcoded seeder "id" as a string (e.g. "31")
      [ ] Set "auth_type", "grant_type", "media_type" to correct connector_config_types IDs
      [ ] Pre-populate auth_endpoint, access_token_url, scope, base_url with system defaults
      [ ] Set throttle_max_requests / throttle_per_seconds to system-appropriate values
      [ ] If system has variants (sandbox, CA, etc.) create one JSON file per application code

[ ] 4. SERVICE CLASS
      [ ] Create src/App/Services/Ipaas/Connectors/{SystemName}Service.php
      [ ] Define CODES constant + static is{SystemName}Connector() guard
      [ ] Implement prepareRequest() — sets base_url_override, no Authorization header
      [ ] Implement extractInstanceUrl() if system has dynamic API host
      [ ] Implement mapErrorCode() with all known error codes
      [ ] Implement formatQueryResponse() with $response['records'] ?? [] guard
      [ ] Implement handlePaginationToken()
      [ ] Implement buildQueryUrl() if system uses query-style endpoints

[ ] 5. STRATEGY HOOK (ConnectorOAuth2::makeRequest)
      [ ] Add routing: if {SystemName}Service::is{SystemName}Connector → prepareRequest
      [ ] Use static method, not inline in_array

[ ] 6. OAUTH CALLBACK (CreateConfigConnector::callback)
      [ ] Import service class
      [ ] Conditionally skip HttpBasicAuthOptionProvider for this system
      [ ] Extract extra token values (instance_url, etc.) and persist to connector_config
      [ ] Use {SystemName}Service::is{SystemName}Connector() not inline in_array

[ ] 7. IPAAS HELPER (if needed)
      [ ] Extend isUnauthorizedError() if system uses non-401 session expiry signal

[ ] 8. TESTS
      [ ] {SystemName}ServiceTest.php — all public methods + IpaasHelper regression
      [ ] ConnectorOAuth2{SystemName}Test.php — routing, grant type, decrypt guard
      [ ] {SystemName}ConnectorTest.php — callback, instance URL, HttpBasicAuth conditional, 204
      [ ] Ensure all inline connector_config table schemas include new columns

[ ] 9. STATIC ANALYSIS
      [ ] Run PHPStan level 5 on all modified/created production files
      [ ] Zero errors required before merge

[ ] 10. TEST SUITE
      [ ] Run all new tests: must pass
      [ ] Run existing iPaaS tests: must pass (no regressions)
```

---

## 8. Recommendations

### 8.1 Centralise application code identification

Currently, each new connector must add its application codes in two places:
- `{SystemName}Service::CODES` (used by `makeRequest` and `callback`)

This is already consolidated in the Salesforce implementation. Enforce it as a rule: **never use inline `in_array($appCode, ['foo', 'bar'])` outside the service class.** Always call the service's static guard method.

### 8.2 Extract the `makeRequest` hook into a configurable registry

Today, every new OAuth2 connector requires editing `ConnectorOAuth2::makeRequest()` to add a new `if` branch. As the number of connectors grows, this method will accumulate branching logic.

A cleaner approach would be a service registry:

```php
// Potential future architecture
$applicationCode = $connector->app?->code ?? '';
$service = ConnectorServiceRegistry::for($applicationCode);
if ($service !== null) {
    $input = $service->prepareRequest($connector, $input);
}
```

Where `ConnectorServiceRegistry` maps application codes to service class names. This eliminates the need to modify `ConnectorOAuth2` for each new connector.

### 8.3 Standardise `mapErrorCode()` return values across all connectors

The return values of `mapErrorCode()` (`token_expired`, `rate_limit`, `query_timeout`, `validation_error`, `api_error`) should be defined as constants in a shared enum or class. Currently they are plain strings and could drift between connectors.

### 8.4 Consolidate test schema definitions

Every test file that creates a `connector_config` table inline must include all current columns. When a new migration adds a column, all inline definitions need updating. A single `ensureConnectorConfigTable()` method in `SetupMultiTenancyForTests` (already in use) is the correct approach — tests should call this instead of defining the schema inline.

### 8.5 Make `instance_url` a first-class concern in the Connector model

Currently `instance_url` lives on `ConnectorConfig` and is accessed via `$connector->config?->instance_url`. A helper method on `Connector` would be cleaner:

```php
// On the Connector model
public function getEffectiveBaseUrl(): string
{
    return rtrim($this->config?->instance_url ?? $this->base_url, '/');
}
```

This removes the null-safe chain from service classes and provides a single testable method for base URL resolution.

### 8.6 Document which systems need `HttpBasicAuthOptionProvider` disabled

The current approach adds a connector code check inside `CreateConfigConnector::callback()`. As more connectors are added, this list will grow. Consider inverting the logic — store a flag in `connector_config_types` (e.g. `token_auth_in_body: true`) so the callback reads configuration rather than checking hard-coded lists.
