Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# UNRELEASED

- Feat: Add `Dynamics365Adapter` for Microsoft Dynamics 365 / Dataverse Web API v9.2. Supports OAuth 2.0 `client_credentials` with an in-memory bearer token cache (auto-refresh on expiration and on 401 retry), single-record `upsert!` (PATCH by GUID, PATCH by Dataverse alternate key, or POST create depending on inputs) and `delete!` (DELETE by GUID with idempotent 404). Batch operations (`batch_upsert!` / `batch_delete!`) raise `NotImplementedError` in this version — use `Etlify::SyncJob` per record. Native `$batch` multipart support is planned for a follow-up release. Zero external dependency (Net::HTTP + JSON stdlib).
- Feat: `Etlify::Adapters::DefaultHttp#request` now returns `{status:, body:, headers:}` (previously `{status:, body:}`). Backward-compatible: existing adapters ignore the new key. Required by `Dynamics365Adapter` to read the `OData-EntityId` response header that carries the Dataverse GUID after upsert.

# V0.11.0

- Feat: Add `enabled:` flag to `Etlify::CRM.register` (default `true`). When a CRM is registered with `enabled: false`, all sync and delete calls become a no-op: `Model#crm_sync!` and `Model#crm_delete!` return `true` without enqueuing any job, `Etlify::Synchronizer.call` and `Etlify::Deleter.call` return `:disabled`, `Etlify::BatchSynchronizer.call` returns stats with `disabled: true`, and `Etlify::StaleRecords::BatchSync.call` silently skips disabled CRMs while still processing enabled ones. No adapter call, no write to `crm_synchronisations`. Useful to keep Etlify dormant in development or test environments. New public helper `Etlify::CRM.enabled?(name)` (returns `true` for unknown CRMs as a safe default).
Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,67 @@ adapter.batch_delete!(

---

## Microsoft Dynamics 365 adapter (Web API v9.2)

Etlify ships with `Etlify::Adapters::Dynamics365Adapter` for Microsoft Dynamics 365 / Dataverse. It uses `Net::HTTP` (no external dependency) and authenticates via OAuth 2.0 `client_credentials`. The bearer token is cached in-memory per process, refreshed before expiration, and re-fetched transparently on a 401 response (one retry per request).

### Configuration

```ruby
Etlify.configure do |config|
Etlify::CRM.register(
:dynamics_365,
adapter: Etlify::Adapters::Dynamics365Adapter.new(
tenant_id: ENV["DYNAMICS_TENANT_ID"],
client_id: ENV["DYNAMICS_CLIENT_ID"],
client_secret: ENV["DYNAMICS_CLIENT_SECRET"],
resource_uri: ENV["DYNAMICS_RESOURCE_URI"]
),
enabled: Rails.env.production? || Rails.env.staging?,
options: {
job_class: Etlify::SyncJob,
rate_limit: { max_requests: 100, period: 5 },
}
)
end
```

`resource_uri` is the Dataverse environment URL (e.g. `https://contoso.crm.dynamics.com`). `api_version` defaults to `"9.2"` and can be overridden.

### Behaviour

- `object_type`: the Dataverse entity set name (plural, lowercase — e.g. `"contacts"`, `"accounts"`, `"leads"`).
- `id_property`: the **alternate key** field name configured on the Dataverse entity (e.g. `"emailaddress1"` for contacts). Must be declared as an alternate key in Dataverse, otherwise upsert returns a 400.
- `crm_id`: if provided, the adapter PATCHes the record directly by GUID and skips alternate-key resolution.
- The Dataverse GUID is read back from the `OData-EntityId` response header after every upsert/create.

### Example: Contact upsert

```ruby
class User < ApplicationRecord
include Etlify::Model

dynamics_365_etlified_with(
serializer: DynamicsContactSerializer,
crm_object_type: "contacts",
id_property: "emailaddress1",
sync_if: ->(user) { user.email.present? }
)
end

# Later
user.dynamics_365_sync!
```

### Limitations

- **No batch support in v1.** `batch_upsert!` and `batch_delete!` raise `NotImplementedError`. `Etlify::BatchSyncJob` is therefore not usable with `:dynamics_365` — register the CRM with `options: { job_class: Etlify::SyncJob }` (per-record sync). Native Dataverse `$batch` (multipart/mixed changeset) support is planned for a follow-up release.
- **Alternate keys must exist in Dataverse.** The property passed as `id_property` has to be configured as an alternate key on the target entity beforehand.
- **Dataverse Service Protection limits.** Dataverse enforces ~6000 requests / 300s / application user. The recommended `rate_limit` above (`100/5s`) keeps a comfortable margin.
- **Token cache is per process.** Each Sidekiq worker maintains its own bearer cache (~1 OAuth round-trip per worker per hour, negligible).

---

## Writing your own adapter

Implement the following interface:
Expand Down Expand Up @@ -737,6 +798,7 @@ expect(fake_adapter).to have_received(:upsert!).with(
- `Etlify::Adapters::NullAdapter` (default; no-op)
- `Etlify::Adapters::HubspotV3Adapter` (API v3, with batch support)
- `Etlify::Adapters::AirtableV0Adapter` (API v0, with batch support)
- `Etlify::Adapters::Dynamics365Adapter` (Dataverse Web API v9.2, OAuth client_credentials, single-record only)

---

Expand Down
1 change: 1 addition & 0 deletions lib/etlify/adapters.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require_relative "adapters/null_adapter"
require_relative "adapters/hubspot_v3_adapter"
require_relative "adapters/airtable_v0_adapter"
require_relative "adapters/dynamics_365_adapter"
8 changes: 6 additions & 2 deletions lib/etlify/adapters/default_http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Etlify
module Adapters
# Simple Net::HTTP client used by default (dependency-free).
# Shared across adapters (Airtable, HubSpot, etc.).
# Signature: request(method, url, headers:, body:) → {status:, body:}
# Signature: request(method, url, headers:, body:) → {status:, body:, headers:}
class DefaultHttp
OPEN_TIMEOUT = 5
READ_TIMEOUT = 30
Expand All @@ -30,7 +30,11 @@ def request(method, url, headers: {}, body: nil)
http_request.body = body if body

response = http.request(http_request)
{status: response.code.to_i, body: response.body}
{
status: response.code.to_i,
body: response.body,
headers: response.to_hash,
}
end
end
end
Expand Down
Loading