equipoise 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +202 -0
- data/NOTICE +12 -0
- data/README.md +168 -0
- data/lib/equipoise/batch_result.rb +45 -0
- data/lib/equipoise/client.rb +188 -0
- data/lib/equipoise/configuration.rb +136 -0
- data/lib/equipoise/contacts.rb +149 -0
- data/lib/equipoise/custom_fields.rb +61 -0
- data/lib/equipoise/errors.rb +129 -0
- data/lib/equipoise/railtie.rb +17 -0
- data/lib/equipoise/response.rb +68 -0
- data/lib/equipoise/syncable.rb +78 -0
- data/lib/equipoise/util.rb +25 -0
- data/lib/equipoise/version.rb +12 -0
- data/lib/equipoise.rb +92 -0
- data/lib/generators/equipoise/install_generator.rb +25 -0
- data/lib/generators/equipoise/templates/equipoise.rb +22 -0
- metadata +90 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::Configuration
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Holds the producer's connection settings. api_key, base_url, and source are
|
|
6
|
+
# read from the environment LAZILY (on every read) unless explicitly assigned:
|
|
7
|
+
#
|
|
8
|
+
# EQUIPOISE_API_KEY → api_key (eq_live_… / eq_test_…)
|
|
9
|
+
# EQUIPOISE_API_URL → base_url (defaults to the production host)
|
|
10
|
+
# EQUIPOISE_SOURCE → source (the producer's source identifier)
|
|
11
|
+
#
|
|
12
|
+
# Reading from ENV at call time is what makes the key rotation-aware: rotate the
|
|
13
|
+
# secret in the environment and the next request picks it up — no redeploy. An
|
|
14
|
+
# explicit `config.api_key = "…"` captures the value and is NOT rotation-aware.
|
|
15
|
+
#
|
|
16
|
+
# Transport security: `validate!` refuses to send credentials over plaintext
|
|
17
|
+
# HTTP to a non-loopback host (an `http://` base_url would leak the Bearer key on
|
|
18
|
+
# the wire). Local dev (`http://localhost`) is allowed; set `allow_insecure_http`
|
|
19
|
+
# to permit a trusted TLS-terminating internal endpoint.
|
|
20
|
+
#
|
|
21
|
+
# Used by: Equipoise.configure, Equipoise::Client
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
module Equipoise
|
|
25
|
+
class Configuration
|
|
26
|
+
DEFAULT_BASE_URL = "https://app.equipoi.se/api/v1"
|
|
27
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
28
|
+
DEFAULT_READ_TIMEOUT = 15
|
|
29
|
+
DEFAULT_MAX_RETRIES = 2
|
|
30
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
31
|
+
PRODUCTION_HOST = URI.parse(DEFAULT_BASE_URL).host # "app.equipoi.se"
|
|
32
|
+
|
|
33
|
+
attr_accessor :open_timeout,
|
|
34
|
+
:read_timeout,
|
|
35
|
+
:max_retries,
|
|
36
|
+
:retry_backoff_base,
|
|
37
|
+
:max_retry_backoff,
|
|
38
|
+
:allow_insecure_http,
|
|
39
|
+
:enabled,
|
|
40
|
+
:logger,
|
|
41
|
+
:user_agent
|
|
42
|
+
attr_writer :api_key,
|
|
43
|
+
:base_url,
|
|
44
|
+
:source
|
|
45
|
+
|
|
46
|
+
def initialize(env: ENV)
|
|
47
|
+
@env = env
|
|
48
|
+
@api_key = nil
|
|
49
|
+
@base_url = nil
|
|
50
|
+
@source = nil
|
|
51
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
52
|
+
@read_timeout = DEFAULT_READ_TIMEOUT
|
|
53
|
+
@max_retries = DEFAULT_MAX_RETRIES
|
|
54
|
+
@retry_backoff_base = 0.5
|
|
55
|
+
@max_retry_backoff = 30
|
|
56
|
+
@allow_insecure_http = false
|
|
57
|
+
@enabled = true
|
|
58
|
+
@logger = nil
|
|
59
|
+
@user_agent = "equipoise-ruby/#{Equipoise::VERSION}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# — Claude authored — test-mode kill switch (#5). When disabled the client
|
|
63
|
+
# short-circuits to a no-op before any network call OR validation, so a stray
|
|
64
|
+
# EQUIPOISE_API_KEY in a producer's test env can never make a live call.
|
|
65
|
+
def disabled?
|
|
66
|
+
!enabled
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# — Claude authored — explicit assignment wins; otherwise read ENV each call
|
|
70
|
+
# (rotation-aware).
|
|
71
|
+
def api_key
|
|
72
|
+
resolve(@api_key, "EQUIPOISE_API_KEY")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def base_url
|
|
76
|
+
resolve(@base_url, "EQUIPOISE_API_URL") || DEFAULT_BASE_URL
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def source
|
|
80
|
+
resolve(@source, "EQUIPOISE_SOURCE")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate!
|
|
84
|
+
raise ConfigurationError, "api_key is required (set EQUIPOISE_API_KEY or Equipoise.configure)" if api_key.to_s.empty?
|
|
85
|
+
raise ConfigurationError, "base_url is required" if base_url.to_s.empty?
|
|
86
|
+
|
|
87
|
+
ensure_secure_transport!
|
|
88
|
+
ensure_environment_match!
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def resolve(explicit, env_key)
|
|
95
|
+
return explicit unless explicit.to_s.empty?
|
|
96
|
+
|
|
97
|
+
value = @env[env_key]
|
|
98
|
+
value.to_s.empty? ? nil : value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# — Claude authored — never transmit the Bearer key in cleartext to a remote
|
|
102
|
+
# host. https is always fine; http is allowed only for loopback dev or when
|
|
103
|
+
# the producer explicitly opts in for a trusted internal endpoint.
|
|
104
|
+
def ensure_secure_transport!
|
|
105
|
+
uri = URI.parse(base_url)
|
|
106
|
+
return if uri.scheme == "https"
|
|
107
|
+
return if allow_insecure_http
|
|
108
|
+
return if LOOPBACK_HOSTS.include?(uri.host)
|
|
109
|
+
|
|
110
|
+
raise ConfigurationError,
|
|
111
|
+
"Refusing to send API credentials over plaintext HTTP to #{uri.host.inspect}. " \
|
|
112
|
+
"Use https://, or set config.allow_insecure_http = true for a trusted internal endpoint."
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# — Claude authored (#3) — the key prefix encodes its environment (eq_test_ /
|
|
116
|
+
# eq_live_); refuse the dangerous host mismatches no producer can see coming:
|
|
117
|
+
# a TEST key pointed at the production host (test creds writing the real CRM)
|
|
118
|
+
# or a LIVE key pointed at localhost (prod creds in a dev loop). Staging and
|
|
119
|
+
# custom-domain hosts are intentionally not flagged (no false positives).
|
|
120
|
+
def ensure_environment_match!
|
|
121
|
+
host = URI.parse(base_url).host
|
|
122
|
+
key_env = api_key.start_with?("eq_live_") ? :live : (api_key.start_with?("eq_test_") ? :test : nil)
|
|
123
|
+
return if key_env.nil? # non-standard key prefix — don't guess
|
|
124
|
+
|
|
125
|
+
if key_env == :test && host == PRODUCTION_HOST
|
|
126
|
+
raise ConfigurationError,
|
|
127
|
+
"A test key (eq_test_) is pointed at the production host (#{host}). This would write test " \
|
|
128
|
+
"credentials into the live CRM. Use a live key, or point base_url at your test/local host."
|
|
129
|
+
elsif key_env == :live && LOOPBACK_HOSTS.include?(host)
|
|
130
|
+
raise ConfigurationError,
|
|
131
|
+
"A live key (eq_live_) is pointed at a local host (#{host}). Use a test key (eq_test_) for " \
|
|
132
|
+
"local development, or point base_url at the production host."
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::Contacts
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# The contact-sync resource — the producer's main surface. Maps 1:1 onto the
|
|
6
|
+
# receiver's sync endpoints:
|
|
7
|
+
#
|
|
8
|
+
# sync(external_id:, email:, name:, tags:, …) → POST /contacts/sync
|
|
9
|
+
# batch_sync([...]) → POST /contacts/sync/batch (≤500/call)
|
|
10
|
+
# deactivate(external_id:) → POST /contacts/sync/:id/deactivate
|
|
11
|
+
# find(external_id:) → GET /contacts/sync/:id
|
|
12
|
+
#
|
|
13
|
+
# `source` defaults to the client's configured source; an explicit arg overrides
|
|
14
|
+
# it per call. Idempotency: pass `idempotency_key:` to make a single sync's retry
|
|
15
|
+
# safe. Batch retries are safe via the receiver's `(source, external_id)` upsert
|
|
16
|
+
# (the receiver does not consume Idempotency-Key on the batch endpoint).
|
|
17
|
+
#
|
|
18
|
+
# Used by: Equipoise::Client#contacts
|
|
19
|
+
# =============================================================================
|
|
20
|
+
|
|
21
|
+
module Equipoise
|
|
22
|
+
class Contacts
|
|
23
|
+
BATCH_LIMIT = 500
|
|
24
|
+
|
|
25
|
+
def initialize(client)
|
|
26
|
+
@client = client
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# — Claude authored — upsert one contact by (source, external_id). Unknown
|
|
30
|
+
# attributes (email/name/first_name/last_name/phone/tags/custom_fields/
|
|
31
|
+
# create_missing_fields) pass straight through; nils are dropped.
|
|
32
|
+
def sync(external_id:, source: nil, idempotency_key: nil, **attributes)
|
|
33
|
+
payload = { source: resolve_source(source), external_id: external_id.to_s }
|
|
34
|
+
.merge(attributes)
|
|
35
|
+
.compact
|
|
36
|
+
|
|
37
|
+
headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
|
|
38
|
+
@client.request(:post, "/contacts/sync", body: payload, headers: headers)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# — Claude authored — upsert many contacts. Chunks to ≤500/call and returns a
|
|
42
|
+
# BatchResult aggregating every per-item result across chunks. It NEVER raises
|
|
43
|
+
# mid-batch: a per-item server rejection comes back as an `ok: false` item
|
|
44
|
+
# (the receiver returns 200 per chunk), and a chunk-level transport/HTTP
|
|
45
|
+
# failure marks that chunk's records failed so earlier chunks' successes are
|
|
46
|
+
# preserved and the producer can retry only the failures.
|
|
47
|
+
def batch_sync(records, source: nil, chunk_size: BATCH_LIMIT)
|
|
48
|
+
default_source = source || @client.config.source
|
|
49
|
+
prepared = records.map { |record| prepare_record(record, default_source) }
|
|
50
|
+
ensure_sources!(prepared)
|
|
51
|
+
|
|
52
|
+
items = []
|
|
53
|
+
prepared.each_slice(chunk_size) { |chunk| items.concat(sync_chunk(chunk)) }
|
|
54
|
+
BatchResult.new(items)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# — Claude authored — deactivate the external link; the Contact is retained.
|
|
58
|
+
def deactivate(external_id:, source: nil)
|
|
59
|
+
@client.request(:post, "#{sync_path(external_id)}/deactivate",
|
|
60
|
+
query: { source: resolve_source(source) })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# — Claude authored — read back the Contact linked to (source, external_id).
|
|
64
|
+
def find(external_id:, source: nil)
|
|
65
|
+
@client.request(:get, sync_path(external_id),
|
|
66
|
+
query: { source: resolve_source(source) })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# — Claude authored — record an external event on the contact's timeline.
|
|
70
|
+
# `event_type: "subscribed"`/`"unsubscribed"` toggles the contact's email
|
|
71
|
+
# subscription (state + activity); any other event_type logs a generic
|
|
72
|
+
# timeline event. `metadata` is a free-form hash of producer context.
|
|
73
|
+
def record_activity(external_id:, event_type:, source: nil, label: nil, occurred_at: nil, metadata: nil)
|
|
74
|
+
body = { event_type: event_type, label: label, occurred_at: occurred_at, metadata: metadata }.compact
|
|
75
|
+
@client.request(:post, "#{sync_path(external_id)}/activity",
|
|
76
|
+
query: { source: resolve_source(source) },
|
|
77
|
+
body: body)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Convenience wrappers for the two subscription events.
|
|
81
|
+
def subscribe(external_id:, source: nil)
|
|
82
|
+
record_activity(external_id: external_id, event_type: "subscribed", source: source)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def unsubscribe(external_id:, source: nil)
|
|
86
|
+
record_activity(external_id: external_id, event_type: "unsubscribed", source: source)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def prepare_record(record, default_source)
|
|
92
|
+
record = Util.symbolize_keys(record)
|
|
93
|
+
record[:source] ||= default_source
|
|
94
|
+
record[:external_id] = record[:external_id].to_s if record[:external_id]
|
|
95
|
+
record.compact
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Fail fast locally (like single sync) when any record has no source, rather
|
|
99
|
+
# than shipping a source-less contact and getting an opaque server 422.
|
|
100
|
+
def ensure_sources!(prepared)
|
|
101
|
+
missing = prepared.select { |record| record[:source].to_s.empty? }
|
|
102
|
+
return if missing.empty?
|
|
103
|
+
|
|
104
|
+
ids = missing.map { |record| record[:external_id] }.compact
|
|
105
|
+
raise ArgumentError,
|
|
106
|
+
"source is required for every batch record (missing for: #{ids.join(', ')}); " \
|
|
107
|
+
"configure Equipoise.source or pass source:"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def sync_chunk(chunk)
|
|
111
|
+
response = @client.request(:post, "/contacts/sync/batch",
|
|
112
|
+
body: { contacts: chunk },
|
|
113
|
+
headers: { "Idempotency-Key" => chunk_idempotency_key(chunk) })
|
|
114
|
+
array_data(response.data)
|
|
115
|
+
rescue Equipoise::ApiError, Equipoise::ConnectionError => e
|
|
116
|
+
chunk.map { |record| failed_item(record, e) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def array_data(data)
|
|
120
|
+
data.is_a?(Array) ? data : []
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def failed_item(record, error)
|
|
124
|
+
status = error.respond_to?(:code) && error.code ? error.code : "error"
|
|
125
|
+
{ "external_id" => record[:external_id], "status" => status, "ok" => false, "errors" => Array(error.message) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolve_source(source)
|
|
129
|
+
resolved = source || @client.config.source
|
|
130
|
+
raise ArgumentError, "source is required (configure Equipoise.source or pass source:)" if resolved.to_s.empty?
|
|
131
|
+
|
|
132
|
+
resolved
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# external_id may contain dots/spaces (e.g. "stripe.cus_42"); url-encode the
|
|
136
|
+
# path segment while leaving unreserved characters (incl. ".") intact.
|
|
137
|
+
def sync_path(external_id)
|
|
138
|
+
"/contacts/sync/#{ERB::Util.url_encode(external_id.to_s)}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Deterministic per-chunk key from the chunk contents: identical contents →
|
|
142
|
+
# identical key (a safe replay when/if the receiver honors it on batch),
|
|
143
|
+
# different contents → different key (no false replay). Forward-compatible;
|
|
144
|
+
# batch idempotency today rests on the (source, external_id) upsert.
|
|
145
|
+
def chunk_idempotency_key(chunk)
|
|
146
|
+
"batch-#{Digest::SHA256.hexdigest(JSON.generate(chunk))[0, 32]}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::CustomFields
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Manage a producer's typed custom-field definitions (the schema the producer
|
|
6
|
+
# owns). Values themselves ride along on a contact sync via
|
|
7
|
+
# `custom_fields: { key: value }`; this resource is for declaring/inspecting the
|
|
8
|
+
# field definitions up front.
|
|
9
|
+
#
|
|
10
|
+
# declare(key:, field_type:, options:, source:) → POST /custom_fields (upsert)
|
|
11
|
+
# list(customizable_type:) → GET /custom_fields
|
|
12
|
+
# get(key) → GET /custom_fields/:key
|
|
13
|
+
# update(key, label:, …) → PATCH /custom_fields/:key
|
|
14
|
+
# delete(key, purge_values:) → DELETE /custom_fields/:key
|
|
15
|
+
#
|
|
16
|
+
# Used by: Equipoise::Client#custom_fields
|
|
17
|
+
# =============================================================================
|
|
18
|
+
|
|
19
|
+
module Equipoise
|
|
20
|
+
class CustomFields
|
|
21
|
+
def initialize(client)
|
|
22
|
+
@client = client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def list(customizable_type: nil, page: nil, per_page: nil)
|
|
26
|
+
query = { customizable_type: customizable_type, page: page, per_page: per_page }.compact
|
|
27
|
+
@client.request(:get, "/custom_fields", query: query)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get(key, customizable_type: nil)
|
|
31
|
+
query = { customizable_type: customizable_type }.compact
|
|
32
|
+
@client.request(:get, field_path(key), query: query)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# — Claude authored — create-or-update a field by key (server returns 201 vs
|
|
36
|
+
# 200). `customizable_type` from the body is ignored server-side (defaults to
|
|
37
|
+
# Contact); a client can't declare fields on arbitrary owner types.
|
|
38
|
+
def declare(key:, **attributes)
|
|
39
|
+
@client.request(:post, "/custom_fields", body: { custom_field: { key: key, **attributes }.compact })
|
|
40
|
+
end
|
|
41
|
+
alias upsert declare
|
|
42
|
+
|
|
43
|
+
def update(key, **attributes)
|
|
44
|
+
@client.request(:patch, field_path(key), body: { custom_field: attributes.compact })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns true on a 204. Pass purge_values: true to delete a field that has
|
|
48
|
+
# values (otherwise the server replies 409).
|
|
49
|
+
def delete(key, purge_values: false)
|
|
50
|
+
query = purge_values ? { purge_values: true } : {}
|
|
51
|
+
@client.request(:delete, field_path(key), query: query)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def field_path(key)
|
|
58
|
+
"/custom_fields/#{ERB::Util.url_encode(key.to_s)}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise errors
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Typed exception hierarchy so producers can rescue precisely:
|
|
6
|
+
#
|
|
7
|
+
# Equipoise::Error — base for everything the gem raises
|
|
8
|
+
# ├─ ConfigurationError — missing api_key/base_url (raised locally)
|
|
9
|
+
# ├─ ConnectionError — network failure after bounded retries
|
|
10
|
+
# └─ ApiError — the server returned a non-2xx
|
|
11
|
+
# ├─ AuthenticationError — 401 (missing/invalid/expired/revoked key)
|
|
12
|
+
# ├─ ForbiddenError — 403 (missing scope / forbidden_source)
|
|
13
|
+
# ├─ NotFoundError — 404
|
|
14
|
+
# ├─ ConflictError — 409 (sync race / locked custom field)
|
|
15
|
+
# ├─ ValidationError — 422 (#details carries the field errors)
|
|
16
|
+
# ├─ RateLimitError — 429 (#retry_after seconds)
|
|
17
|
+
# └─ ServerError — 5xx
|
|
18
|
+
#
|
|
19
|
+
# ApiError.from_response builds the right subclass from a parsed Response,
|
|
20
|
+
# tolerating both the `{error:{code,message,details}}` envelope and the legacy
|
|
21
|
+
# rack-attack `{error:"Rate limit exceeded", retry_after:…}` body.
|
|
22
|
+
#
|
|
23
|
+
# Used by: Equipoise::Client, every resource
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
module Equipoise
|
|
27
|
+
class Error < StandardError
|
|
28
|
+
# — Claude authored — the retry taxonomy lives in the gem so no producer has
|
|
29
|
+
# to memorize which failures are transient. Base default: not retryable.
|
|
30
|
+
# A producer's job becomes: `rescue Equipoise::Error => e; raise if e.retryable?`.
|
|
31
|
+
def retryable?
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class ConfigurationError < Error; end
|
|
37
|
+
|
|
38
|
+
# A transport failure (timeout, refused, DNS, TLS). Always worth a retry.
|
|
39
|
+
class ConnectionError < Error
|
|
40
|
+
def retryable?
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class ApiError < Error
|
|
46
|
+
attr_reader :status, :code, :details, :response
|
|
47
|
+
|
|
48
|
+
def initialize(message = nil, status: nil, code: nil, details: nil, response: nil)
|
|
49
|
+
super(message)
|
|
50
|
+
@status = status
|
|
51
|
+
@code = code
|
|
52
|
+
@details = details
|
|
53
|
+
@response = response
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 429 + any 5xx are transient (back off and retry); every 4xx is a permanent
|
|
57
|
+
# client error the producer must fix (validation, auth, scope, not-found).
|
|
58
|
+
def retryable?
|
|
59
|
+
status == 429 || status.to_i >= 500
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# — Claude authored — pick the subclass by HTTP status and parse the error
|
|
63
|
+
# envelope. Robust to an empty body (401) and the legacy string error body.
|
|
64
|
+
def self.from_response(response)
|
|
65
|
+
status = response.status
|
|
66
|
+
body = response.body
|
|
67
|
+
payload = body.is_a?(Hash) ? body["error"] : nil
|
|
68
|
+
|
|
69
|
+
code, message, details =
|
|
70
|
+
if payload.is_a?(Hash)
|
|
71
|
+
[payload["code"], payload["message"], payload["details"]]
|
|
72
|
+
elsif payload.is_a?(String)
|
|
73
|
+
[nil, payload, nil]
|
|
74
|
+
else
|
|
75
|
+
[nil, nil, nil]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
message ||= default_message(status)
|
|
79
|
+
klass = class_for(status)
|
|
80
|
+
|
|
81
|
+
if klass == RateLimitError
|
|
82
|
+
klass.new(message, status: status, code: code, details: details,
|
|
83
|
+
response: response, retry_after: response.retry_after)
|
|
84
|
+
else
|
|
85
|
+
klass.new(message, status: status, code: code, details: details, response: response)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.class_for(status)
|
|
90
|
+
case status
|
|
91
|
+
when 401 then AuthenticationError
|
|
92
|
+
when 403 then ForbiddenError
|
|
93
|
+
when 404 then NotFoundError
|
|
94
|
+
when 409 then ConflictError
|
|
95
|
+
when 422 then ValidationError
|
|
96
|
+
when 429 then RateLimitError
|
|
97
|
+
when 500..599 then ServerError
|
|
98
|
+
else ApiError
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.default_message(status)
|
|
103
|
+
{
|
|
104
|
+
401 => "Unauthorized",
|
|
105
|
+
403 => "Forbidden",
|
|
106
|
+
404 => "Not found",
|
|
107
|
+
409 => "Conflict",
|
|
108
|
+
422 => "Validation failed",
|
|
109
|
+
429 => "Rate limit exceeded"
|
|
110
|
+
}.fetch(status) { status >= 500 ? "Server error" : "Request failed (#{status})" }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class AuthenticationError < ApiError; end
|
|
115
|
+
class ForbiddenError < ApiError; end
|
|
116
|
+
class NotFoundError < ApiError; end
|
|
117
|
+
class ConflictError < ApiError; end
|
|
118
|
+
class ValidationError < ApiError; end
|
|
119
|
+
class ServerError < ApiError; end
|
|
120
|
+
|
|
121
|
+
class RateLimitError < ApiError
|
|
122
|
+
attr_reader :retry_after
|
|
123
|
+
|
|
124
|
+
def initialize(message = nil, retry_after: nil, **opts)
|
|
125
|
+
super(message, **opts)
|
|
126
|
+
@retry_after = retry_after
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::Railtie (loaded only under Rails)
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# When the gem is required inside a Rails app, make the optional Syncable concern
|
|
6
|
+
# available without a manual require. The generator (`rails g equipoise:install`)
|
|
7
|
+
# writes the initializer. Plain Ruby usage never touches this file.
|
|
8
|
+
#
|
|
9
|
+
# Used by: lib/equipoise.rb (required only when Rails::Railtie is defined)
|
|
10
|
+
# =============================================================================
|
|
11
|
+
|
|
12
|
+
require "equipoise/syncable"
|
|
13
|
+
|
|
14
|
+
module Equipoise
|
|
15
|
+
class Railtie < Rails::Railtie
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::Response
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# A thin wrapper over a successful API response: the HTTP status, the
|
|
6
|
+
# (downcased) header hash, and the parsed JSON body. The whole API uses a
|
|
7
|
+
# `{ data, meta }` envelope, so `#data` / `#meta` unwrap it, and `#sync_status`
|
|
8
|
+
# surfaces the `meta.sync_status` ("created" | "updated") the sync endpoints add.
|
|
9
|
+
#
|
|
10
|
+
# `#retry_after` is the single Retry-After parser for the whole gem (used by both
|
|
11
|
+
# the retry-loop backoff and the surfaced RateLimitError): it reads the standard
|
|
12
|
+
# header, falls back to the legacy rack-attack body `retry_after`, and supports
|
|
13
|
+
# BOTH delta-seconds and the RFC 7231 HTTP-date form.
|
|
14
|
+
#
|
|
15
|
+
# Used by: Equipoise::Client, Equipoise::Contacts, Equipoise::CustomFields, ApiError
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
module Equipoise
|
|
19
|
+
class Response
|
|
20
|
+
attr_reader :status, :headers, :body
|
|
21
|
+
|
|
22
|
+
def initialize(status:, headers:, body:)
|
|
23
|
+
@status = status
|
|
24
|
+
@headers = headers || {}
|
|
25
|
+
@body = body
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def success?
|
|
29
|
+
(200..299).cover?(status)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The envelope's `data` (Hash or Array). Falls back to the whole body when a
|
|
33
|
+
# response isn't enveloped (defensive — every documented 2xx is).
|
|
34
|
+
def data
|
|
35
|
+
body.is_a?(Hash) && body.key?("data") ? body["data"] : body
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def meta
|
|
39
|
+
body.is_a?(Hash) ? (body["meta"] || {}) : {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sync_status
|
|
43
|
+
meta["sync_status"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Seconds to wait, from the Retry-After header or the legacy body field.
|
|
47
|
+
# Returns an Integer (or nil when neither is present / unparseable).
|
|
48
|
+
def retry_after
|
|
49
|
+
raw = headers["retry-after"]
|
|
50
|
+
raw = body["retry_after"] if raw.to_s.empty? && body.is_a?(Hash)
|
|
51
|
+
parse_retry_after(raw)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def parse_retry_after(raw)
|
|
57
|
+
value = raw.to_s.strip
|
|
58
|
+
return nil if value.empty?
|
|
59
|
+
return value.to_i if value.match?(/\A\d+\z/) # delta-seconds
|
|
60
|
+
|
|
61
|
+
# RFC 7231 also allows an HTTP-date; convert to seconds-from-now (≥ 0).
|
|
62
|
+
seconds = (Time.httpdate(value) - Time.now).ceil
|
|
63
|
+
seconds.negative? ? 0 : seconds
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::Syncable (optional Rails add-on)
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Mix into a producer's model to keep an Equipoise Contact in lockstep with it.
|
|
6
|
+
# Dependency-free (no ActiveSupport required): when the host responds to
|
|
7
|
+
# `after_commit` (an ActiveRecord model), it wires the create/update hook;
|
|
8
|
+
# otherwise it's inert, so a PORO can include it for testing.
|
|
9
|
+
#
|
|
10
|
+
# class User < ApplicationRecord
|
|
11
|
+
# include Equipoise::Syncable
|
|
12
|
+
#
|
|
13
|
+
# def equipoise_external_id = id.to_s
|
|
14
|
+
# def equipoise_contact_attributes
|
|
15
|
+
# { email:, name: full_name, tags: ["AMP"] }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# # call from your soft-delete path:
|
|
19
|
+
# def soft_delete_account!
|
|
20
|
+
# super
|
|
21
|
+
# deactivate_from_equipoise
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# The default `equipoise_sync!` posts inline; in production wrap it in a job
|
|
26
|
+
# (override `equipoise_sync!` to enqueue) so a web request never blocks on the
|
|
27
|
+
# round-trip. In dev, ensure the queue runs inline or the post never fires.
|
|
28
|
+
#
|
|
29
|
+
# Producer-side firing is exercised in AMP (S13); the gem suite tests the
|
|
30
|
+
# transport methods directly.
|
|
31
|
+
#
|
|
32
|
+
# Used by: producer models that opt in (e.g. AMP's User)
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
module Equipoise
|
|
36
|
+
module Syncable
|
|
37
|
+
def self.included(base)
|
|
38
|
+
return unless base.respond_to?(:after_commit)
|
|
39
|
+
|
|
40
|
+
base.after_commit :equipoise_sync!, on: [:create, :update]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Override to enqueue instead of posting inline. No-op until configured so a
|
|
44
|
+
# boot before credentials exist (or a test env) doesn't make live calls.
|
|
45
|
+
# Rescues Equipoise::Error so a transient API/transport failure in this
|
|
46
|
+
# after_commit hook never propagates out of the host's `save!` (Rails 7.1+
|
|
47
|
+
# re-raises after_commit exceptions). Best-effort, like envelope tracking.
|
|
48
|
+
# Host bugs (a NoMethodError in equipoise_contact_attributes) still surface.
|
|
49
|
+
def equipoise_sync!
|
|
50
|
+
return unless Equipoise.configured?
|
|
51
|
+
|
|
52
|
+
sync_to_equipoise
|
|
53
|
+
rescue Equipoise::Error => e
|
|
54
|
+
Equipoise.configuration.logger&.warn(
|
|
55
|
+
"[equipoise] sync failed for #{self.class}##{equipoise_external_id}: #{e.message}"
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sync_to_equipoise
|
|
60
|
+
Equipoise.client.contacts.sync(external_id: equipoise_external_id, **equipoise_contact_attributes)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def deactivate_from_equipoise
|
|
64
|
+
Equipoise.client.contacts.deactivate(external_id: equipoise_external_id)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# — Sensible default; override in the host model when the external id isn't
|
|
68
|
+
# the primary key.
|
|
69
|
+
def equipoise_external_id
|
|
70
|
+
id.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Host models MUST implement this — the attributes posted on each sync.
|
|
74
|
+
def equipoise_contact_attributes
|
|
75
|
+
raise NotImplementedError, "#{self.class} must define #equipoise_contact_attributes"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::Util
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Small stateless helpers shared across the gem. Currently just the symbol-key
|
|
6
|
+
# transform used by both Contacts (normalizing caller records) and BatchResult
|
|
7
|
+
# (normalizing server per-item results) — kept in one place so the two can't
|
|
8
|
+
# drift.
|
|
9
|
+
#
|
|
10
|
+
# Used by: Equipoise::Contacts, Equipoise::BatchResult
|
|
11
|
+
# =============================================================================
|
|
12
|
+
|
|
13
|
+
module Equipoise
|
|
14
|
+
module Util
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# — Claude authored — shallow string→symbol key transform; passes non-Hash
|
|
18
|
+
# input through untouched.
|
|
19
|
+
def symbolize_keys(hash)
|
|
20
|
+
return hash unless hash.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Equipoise::VERSION
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Single source of truth for the gem version, read by the gemspec and exposed
|
|
6
|
+
# at runtime. Pre-1.0 while the gem is proven against AMP before any public
|
|
7
|
+
# RubyGems publish.
|
|
8
|
+
# =============================================================================
|
|
9
|
+
|
|
10
|
+
module Equipoise
|
|
11
|
+
VERSION = "0.1.0"
|
|
12
|
+
end
|