flodesk 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 +101 -0
- data/LICENSE.txt +21 -0
- data/README.md +351 -0
- data/Rakefile +12 -0
- data/lib/flodesk/auth.rb +32 -0
- data/lib/flodesk/client.rb +84 -0
- data/lib/flodesk/coercion.rb +69 -0
- data/lib/flodesk/connection.rb +179 -0
- data/lib/flodesk/enums.rb +42 -0
- data/lib/flodesk/errors.rb +139 -0
- data/lib/flodesk/instrumentation.rb +35 -0
- data/lib/flodesk/objects/batch_item_error.rb +29 -0
- data/lib/flodesk/objects/batch_result.rb +44 -0
- data/lib/flodesk/objects/campaign.rb +28 -0
- data/lib/flodesk/objects/custom_field.rb +21 -0
- data/lib/flodesk/objects/page.rb +72 -0
- data/lib/flodesk/objects/segment.rb +36 -0
- data/lib/flodesk/objects/subscriber.rb +39 -0
- data/lib/flodesk/objects/webhook.rb +24 -0
- data/lib/flodesk/objects/workflow.rb +22 -0
- data/lib/flodesk/rails/railtie.rb +19 -0
- data/lib/flodesk/rails.rb +8 -0
- data/lib/flodesk/rate_limit.rb +35 -0
- data/lib/flodesk/redaction.rb +46 -0
- data/lib/flodesk/resources/base.rb +122 -0
- data/lib/flodesk/resources/campaigns.rb +121 -0
- data/lib/flodesk/resources/custom_fields.rb +47 -0
- data/lib/flodesk/resources/segments.rb +49 -0
- data/lib/flodesk/resources/subscribers.rb +239 -0
- data/lib/flodesk/resources/webhooks.rb +93 -0
- data/lib/flodesk/resources/workflows.rb +75 -0
- data/lib/flodesk/response.rb +35 -0
- data/lib/flodesk/retry_policy.rb +46 -0
- data/lib/flodesk/test_helpers.rb +152 -0
- data/lib/flodesk/version.rb +5 -0
- data/lib/flodesk/webhooks/event.rb +78 -0
- data/lib/flodesk/webhooks/handler.rb +167 -0
- data/lib/flodesk/webhooks/verification.rb +61 -0
- data/lib/flodesk.rb +59 -0
- data/lib/generators/flodesk/install_generator.rb +61 -0
- data/lib/generators/flodesk/templates/initializer.rb.tt +28 -0
- data/sig/flodesk/client.rbs +91 -0
- data/sig/flodesk/errors.rbs +51 -0
- data/sig/flodesk/objects.rbs +137 -0
- data/sig/flodesk/resources.rbs +129 -0
- data/sig/flodesk/webhooks.rbs +57 -0
- data/sig/flodesk.rbs +61 -0
- metadata +97 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Flodesk
|
|
8
|
+
# Executes HTTP requests against the Flodesk API.
|
|
9
|
+
#
|
|
10
|
+
# A Connection holds no per-request mutable state, so one instance is safe to
|
|
11
|
+
# share across threads. Each request opens its own `Net::HTTP` session rather
|
|
12
|
+
# than reusing a socket: simple, and correct under concurrency. Connection
|
|
13
|
+
# pooling is a possible later optimization, not a v1 requirement.
|
|
14
|
+
class Connection
|
|
15
|
+
# Exceptions Net::HTTP raises when a request never completed.
|
|
16
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
|
|
17
|
+
|
|
18
|
+
CONNECTION_ERRORS = [
|
|
19
|
+
EOFError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
|
|
20
|
+
Errno::ENETUNREACH, IOError, SocketError
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(client)
|
|
24
|
+
@client = client
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Issues a request and returns a Response, retrying per the retry policy.
|
|
29
|
+
#
|
|
30
|
+
# `idempotent` declares whether repeating this operation is safe.
|
|
31
|
+
#
|
|
32
|
+
# For POST it must be stated explicitly and defaults to false, because in
|
|
33
|
+
# this API retry safety is a property of the endpoint rather than the verb:
|
|
34
|
+
# POST /subscribers and POST /subscribers/batch are upserts and safe to
|
|
35
|
+
# repeat, while POST /segments, POST /custom-fields, POST /webhooks and
|
|
36
|
+
# POST /campaigns/canva all create, so repeating them duplicates a record or
|
|
37
|
+
# sends a campaign twice. Assuming POST is retryable is the single most
|
|
38
|
+
# damaging mistake a client of this API can make.
|
|
39
|
+
#
|
|
40
|
+
# GET, PUT and DELETE are idempotent by HTTP definition and default to true.
|
|
41
|
+
def request(method, path, query: nil, body: nil, idempotent: nil, retry_rate_limit: true)
|
|
42
|
+
policy = RetryPolicy.new(
|
|
43
|
+
idempotent: idempotent.nil? ? method != :post : idempotent,
|
|
44
|
+
retry_rate_limit: retry_rate_limit,
|
|
45
|
+
max_retries: @client.max_retries
|
|
46
|
+
)
|
|
47
|
+
attempt = 0
|
|
48
|
+
|
|
49
|
+
loop do
|
|
50
|
+
attempt += 1
|
|
51
|
+
error = attempt_once(method, path, query, body, attempt) { |response| return response }
|
|
52
|
+
|
|
53
|
+
raise error unless policy.retry?(error, attempt)
|
|
54
|
+
|
|
55
|
+
backoff(attempt)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Yields the response to the caller's block on success. Returns the error to
|
|
62
|
+
# weigh for retry on failure, rather than raising, so the retry decision is
|
|
63
|
+
# made in one place.
|
|
64
|
+
def attempt_once(method, path, query, body, attempt)
|
|
65
|
+
response = execute(method, path, query, body, attempt)
|
|
66
|
+
if response.status < 400
|
|
67
|
+
yield response
|
|
68
|
+
return nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
Error.from_response(
|
|
72
|
+
status: response.status, body: response.body, headers: response.headers
|
|
73
|
+
)
|
|
74
|
+
rescue *TIMEOUT_ERRORS => e
|
|
75
|
+
TimeoutError.new("#{method.to_s.upcase} #{Redaction.path(path)} timed out (#{e.class})")
|
|
76
|
+
rescue *CONNECTION_ERRORS => e
|
|
77
|
+
ConnectionError.new("#{method.to_s.upcase} #{Redaction.path(path)} failed (#{e.class})")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execute(method, path, query, body, attempt)
|
|
81
|
+
uri = build_uri(path, query)
|
|
82
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
|
|
84
|
+
raw = http_for(uri).request(build_request(method, uri, body))
|
|
85
|
+
response = to_response(raw)
|
|
86
|
+
|
|
87
|
+
Instrumentation.request(
|
|
88
|
+
method: method,
|
|
89
|
+
path: path,
|
|
90
|
+
status: response.status,
|
|
91
|
+
duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
|
|
92
|
+
attempt: attempt,
|
|
93
|
+
rate_limit_remaining: response.rate_limit_remaining
|
|
94
|
+
)
|
|
95
|
+
RateLimit.record(@client, response)
|
|
96
|
+
response
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_uri(path, query)
|
|
100
|
+
uri = URI.parse("#{@client.base_url}#{path}")
|
|
101
|
+
uri.query = URI.encode_www_form(query) if query && !query.empty?
|
|
102
|
+
uri
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_request(method, uri, body)
|
|
106
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
|
|
107
|
+
|
|
108
|
+
request["Accept"] = "application/json"
|
|
109
|
+
request["User-Agent"] = @client.user_agent
|
|
110
|
+
@client.auth.apply(request)
|
|
111
|
+
|
|
112
|
+
if body
|
|
113
|
+
request["Content-Type"] = "application/json"
|
|
114
|
+
request.body = JSON.generate(body)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
request
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def http_for(uri)
|
|
121
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
122
|
+
http.use_ssl = uri.scheme == "https"
|
|
123
|
+
http.open_timeout = @client.open_timeout
|
|
124
|
+
http.read_timeout = @client.read_timeout
|
|
125
|
+
http
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def to_response(raw)
|
|
129
|
+
status = raw.code.to_i
|
|
130
|
+
|
|
131
|
+
Response.new(
|
|
132
|
+
status: status,
|
|
133
|
+
headers: raw.each_header.to_h { |k, v| [k.to_s.downcase, v] },
|
|
134
|
+
body: parse_body(raw, status)
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# 204 responses carry no body, and several endpoints use them; parsing one
|
|
139
|
+
# as JSON would raise on an empty string.
|
|
140
|
+
def parse_body(raw, status)
|
|
141
|
+
return nil if status == 204
|
|
142
|
+
|
|
143
|
+
text = raw.body
|
|
144
|
+
return nil if text.nil? || text.strip.empty?
|
|
145
|
+
|
|
146
|
+
begin
|
|
147
|
+
JSON.parse(text)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
# A 2xx that is not parseable is a genuine failure. An error response
|
|
150
|
+
# with an HTML body (from an edge proxy, say) must still become a typed
|
|
151
|
+
# error, so hand the raw text through for the error to carry.
|
|
152
|
+
raise Error, "Malformed JSON in #{status} response" if status < 400
|
|
153
|
+
|
|
154
|
+
text
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def backoff(attempt)
|
|
159
|
+
delay = backoff_delay(attempt)
|
|
160
|
+
sleep(delay) if delay
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Exponential backoff with jitter, capped at the rate-limit window length.
|
|
164
|
+
#
|
|
165
|
+
# Flodesk sends no rate-limit reset header, so no correct wait is
|
|
166
|
+
# computable; this is a documented heuristic. Returns nil when backoff is
|
|
167
|
+
# disabled. Kept pure and separate from the sleep so it can be asserted
|
|
168
|
+
# directly without a timing-dependent test.
|
|
169
|
+
def backoff_delay(attempt)
|
|
170
|
+
base = @client.backoff_base
|
|
171
|
+
return nil if base.nil? || base.zero?
|
|
172
|
+
|
|
173
|
+
capped = [base * (2**(attempt - 1)), MAX_BACKOFF_SECONDS].min
|
|
174
|
+
# Jitter over the lower half, so concurrent callers desynchronize instead
|
|
175
|
+
# of retrying in lockstep.
|
|
176
|
+
capped * (0.5 + (Kernel.rand * 0.5))
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# Closed enumerations documented by the API description.
|
|
5
|
+
#
|
|
6
|
+
# These live in one namespace rather than on the individual value objects for
|
|
7
|
+
# two reasons. Constants assigned inside a `Data.define do ... end` block bind
|
|
8
|
+
# to the *enclosing lexical scope*, not to the resulting class — so a
|
|
9
|
+
# `STATUSES` written inside two such blocks silently defines and then clobbers
|
|
10
|
+
# a single `Flodesk::STATUSES`. Collecting them here also gives the contract
|
|
11
|
+
# spec one place to verify against `openapi.json`.
|
|
12
|
+
module Enums
|
|
13
|
+
# `SubscriberRes.status`. `bounced`, `complained` and `cleaned` are terminal
|
|
14
|
+
# delivery states rather than subscriber actions; `archived` is an action
|
|
15
|
+
# the account owner took on the record.
|
|
16
|
+
#
|
|
17
|
+
# Order is significant: the contract spec compares this against the
|
|
18
|
+
# specification's enum array for equality, not as a set.
|
|
19
|
+
SUBSCRIBER_STATUSES = %w[
|
|
20
|
+
active unsubscribed unconfirmed bounced complained cleaned archived
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# `SubscriberRes.source`.
|
|
24
|
+
SUBSCRIBER_SOURCES = %w[manual csv form_optin integration checkout].freeze
|
|
25
|
+
|
|
26
|
+
# The `statuses` filter on `GET /workflows`. Note that the workflow response
|
|
27
|
+
# schema exposes no status field, so these are only ever sent, never parsed.
|
|
28
|
+
WORKFLOW_STATUSES = %w[active paused draft].freeze
|
|
29
|
+
|
|
30
|
+
# `CampaignItem.status`, also accepted as the `Status` list filter.
|
|
31
|
+
CAMPAIGN_STATUSES = %w[
|
|
32
|
+
draft pending scheduled composing sending done failed
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# The only events Flodesk can deliver to a webhook.
|
|
36
|
+
WEBHOOK_EVENTS = %w[
|
|
37
|
+
subscriber.created
|
|
38
|
+
subscriber.added_to_segment
|
|
39
|
+
subscriber.unsubscribed
|
|
40
|
+
].freeze
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# Base class for everything this gem raises. Rescue this to catch any Flodesk
|
|
5
|
+
# failure, including transport-level ones.
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
# Maps an HTTP response to the appropriate error instance.
|
|
8
|
+
#
|
|
9
|
+
# The Flodesk OpenAPI description declares *no* schema for any error
|
|
10
|
+
# response — every 4xx documents only an empty description. The
|
|
11
|
+
# `{"code": ..., "message": ...}` envelope parsed here was established by
|
|
12
|
+
# probing the live API, so it is not contract-verifiable and must never be
|
|
13
|
+
# assumed present. An unexpected body degrades to a status-derived message
|
|
14
|
+
# rather than raising while building the error.
|
|
15
|
+
def self.from_response(status:, body: nil, headers: {})
|
|
16
|
+
klass_for(status).new(status: status, body: body, headers: headers)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.klass_for(status)
|
|
20
|
+
case status
|
|
21
|
+
when 400 then BadRequestError
|
|
22
|
+
when 401, 403 then AuthenticationError
|
|
23
|
+
when 404 then NotFoundError
|
|
24
|
+
when 429 then RateLimitError
|
|
25
|
+
when 500..599 then ServerError
|
|
26
|
+
else APIError
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
private_class_method :klass_for
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raised when Flodesk returns an HTTP error response, as opposed to the
|
|
33
|
+
# request failing before a response was received.
|
|
34
|
+
class APIError < Error
|
|
35
|
+
# The HTTP status code of the response.
|
|
36
|
+
attr_reader :status
|
|
37
|
+
|
|
38
|
+
# The `code` field from the error envelope, or nil when absent.
|
|
39
|
+
attr_reader :code
|
|
40
|
+
|
|
41
|
+
# The response body exactly as parsed, for debugging unexpected shapes.
|
|
42
|
+
attr_reader :raw_body
|
|
43
|
+
|
|
44
|
+
def initialize(status:, body: nil, headers: {})
|
|
45
|
+
@status = status
|
|
46
|
+
@raw_body = body
|
|
47
|
+
@headers = normalize_headers(headers)
|
|
48
|
+
@code = extract(body, "code")
|
|
49
|
+
|
|
50
|
+
super(extract(body, "message") || "HTTP #{status}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Value of `X-Fd-RateLimit-Limit`, or nil when the header was absent.
|
|
54
|
+
def rate_limit
|
|
55
|
+
integer_header("x-fd-ratelimit-limit")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Value of `X-Fd-RateLimit-Remaining`, or nil when the header was absent.
|
|
59
|
+
def rate_limit_remaining
|
|
60
|
+
integer_header("x-fd-ratelimit-remaining")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Reads a field from the envelope, tolerating any body shape. A body may be
|
|
66
|
+
# a parsed Hash, an HTML string from an edge proxy, or nil.
|
|
67
|
+
def extract(body, key)
|
|
68
|
+
return nil unless body.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
value = body[key] || body[key.to_sym]
|
|
71
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def normalize_headers(headers)
|
|
75
|
+
return {} unless headers.respond_to?(:each_pair)
|
|
76
|
+
|
|
77
|
+
headers.each_pair.to_h { |k, v| [k.to_s.downcase, v] }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def integer_header(name)
|
|
81
|
+
value = @headers[name]
|
|
82
|
+
value = value.first if value.is_a?(Array)
|
|
83
|
+
return nil if value.nil? || value.to_s.empty?
|
|
84
|
+
|
|
85
|
+
Integer(value, exception: false)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 400 — the request payload was rejected. Never retried: the same payload
|
|
90
|
+
# cannot succeed on a second attempt.
|
|
91
|
+
class BadRequestError < APIError; end
|
|
92
|
+
|
|
93
|
+
# 401 or 403 — the API key was missing, malformed, or not accepted.
|
|
94
|
+
class AuthenticationError < APIError; end
|
|
95
|
+
|
|
96
|
+
# 404 — the addressed resource does not exist.
|
|
97
|
+
class NotFoundError < APIError; end
|
|
98
|
+
|
|
99
|
+
# 5xx — Flodesk failed to process an otherwise valid request.
|
|
100
|
+
class ServerError < APIError; end
|
|
101
|
+
|
|
102
|
+
# 429 — the rate limit was exceeded.
|
|
103
|
+
#
|
|
104
|
+
# Flodesk returns `X-Fd-RateLimit-Limit` and `X-Fd-RateLimit-Remaining` but
|
|
105
|
+
# *no reset header*, so there is no way to compute when the window reopens.
|
|
106
|
+
# `retry_after` therefore always returns nil, and any backoff this gem applies
|
|
107
|
+
# is a documented heuristic rather than a computed wait.
|
|
108
|
+
class RateLimitError < APIError
|
|
109
|
+
# Always nil: the API exposes no reset time. Present so callers can ask
|
|
110
|
+
# without special-casing, and to document the absence explicitly.
|
|
111
|
+
def retry_after
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The request failed before a response was received.
|
|
117
|
+
class ConnectionError < Error; end
|
|
118
|
+
|
|
119
|
+
# The request exceeded a configured connect or read timeout.
|
|
120
|
+
#
|
|
121
|
+
# For non-idempotent operations a timeout is genuinely ambiguous: the write
|
|
122
|
+
# may have been applied. Such operations are never retried.
|
|
123
|
+
class TimeoutError < Error; end
|
|
124
|
+
|
|
125
|
+
# Raised when a batch operation returns 200 while reporting per-record
|
|
126
|
+
# failures. Carries the complete result, so records that succeeded remain
|
|
127
|
+
# available to the caller and no work is lost by raising.
|
|
128
|
+
class PartialFailureError < Error
|
|
129
|
+
# The BatchResult describing both successes and failures.
|
|
130
|
+
attr_reader :result
|
|
131
|
+
|
|
132
|
+
def initialize(result:)
|
|
133
|
+
@result = result
|
|
134
|
+
|
|
135
|
+
super("#{result.failures.size} of " \
|
|
136
|
+
"#{result.failures.size + result.successes.size} records failed")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# Emits `flodesk.request` notifications when ActiveSupport is available.
|
|
5
|
+
#
|
|
6
|
+
# One event is emitted per *attempt*, not per logical call, so retries are
|
|
7
|
+
# visible in whatever collects them. Payloads carry no PII: no request body, no
|
|
8
|
+
# response body, no API key, and any email embedded in a path is redacted.
|
|
9
|
+
module Instrumentation
|
|
10
|
+
EVENT_NAME = "flodesk.request"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# True when ActiveSupport::Notifications can be published to. Checked per
|
|
15
|
+
# call rather than cached, since a host application may load ActiveSupport
|
|
16
|
+
# after this gem.
|
|
17
|
+
def available?
|
|
18
|
+
defined?(ActiveSupport::Notifications) ? true : false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def request(method:, path:, status:, duration:, attempt:, rate_limit_remaining:)
|
|
22
|
+
return unless available?
|
|
23
|
+
|
|
24
|
+
ActiveSupport::Notifications.instrument(
|
|
25
|
+
EVENT_NAME,
|
|
26
|
+
method: method.to_s.upcase,
|
|
27
|
+
endpoint: Redaction.path(path),
|
|
28
|
+
status: status,
|
|
29
|
+
duration: duration,
|
|
30
|
+
attempt: attempt,
|
|
31
|
+
rate_limit_remaining: rate_limit_remaining
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# A single record's failure within a batch operation.
|
|
5
|
+
#
|
|
6
|
+
# `index` is the zero-based position in the submitted array, which is what
|
|
7
|
+
# makes a failure actionable: it identifies which of your inputs was rejected
|
|
8
|
+
# even when neither `id` nor `email` came back.
|
|
9
|
+
BatchItemError = Data.define(:index, :email, :id, :code, :message, :raw) do
|
|
10
|
+
def self.from(payload)
|
|
11
|
+
return nil if payload.nil?
|
|
12
|
+
|
|
13
|
+
new(
|
|
14
|
+
index: payload["index"],
|
|
15
|
+
email: payload["email"],
|
|
16
|
+
id: payload["id"],
|
|
17
|
+
code: payload["code"],
|
|
18
|
+
message: payload["message"],
|
|
19
|
+
raw: Coercion.snapshot(payload)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The payload exactly as the API sent it, including any field this gem does
|
|
24
|
+
# not declare.
|
|
25
|
+
def to_h
|
|
26
|
+
raw
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# The outcome of a batch operation.
|
|
5
|
+
#
|
|
6
|
+
# `POST /subscribers/batch` is the only endpoint in this API where success and
|
|
7
|
+
# failure arrive in the *same* 200 response. A client that treats 2xx as
|
|
8
|
+
# success silently drops subscribers, which is why this is a first-class object
|
|
9
|
+
# with an explicit {#success?} rather than a bare hash, and why
|
|
10
|
+
# `batch_upsert` raises {PartialFailureError} unless told not to.
|
|
11
|
+
BatchResult = Data.define(:successes, :failures, :raw) do
|
|
12
|
+
def self.from(payload)
|
|
13
|
+
payload ||= {}
|
|
14
|
+
|
|
15
|
+
new(
|
|
16
|
+
successes: Coercion.array_of(Subscriber, payload["successes"]),
|
|
17
|
+
failures: Coercion.array_of(BatchItemError, payload["failures"]),
|
|
18
|
+
raw: Coercion.snapshot(payload)
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# True when every submitted record was accepted.
|
|
23
|
+
def success?
|
|
24
|
+
failures.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Total records accounted for in the response.
|
|
28
|
+
def size
|
|
29
|
+
successes.size + failures.size
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Emails of the records that failed, for assembling a retry batch. Failures
|
|
33
|
+
# that carry no email — matched by id — are omitted.
|
|
34
|
+
def failed_emails
|
|
35
|
+
failures.filter_map(&:email)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The payload exactly as the API sent it, including any field this gem does
|
|
39
|
+
# not declare.
|
|
40
|
+
def to_h
|
|
41
|
+
raw
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# An email campaign.
|
|
5
|
+
Campaign = Data.define(:id, :name, :subject, :status, :created_at, :updated_at, :raw) do
|
|
6
|
+
def self.from(payload)
|
|
7
|
+
return nil if payload.nil?
|
|
8
|
+
|
|
9
|
+
new(
|
|
10
|
+
id: payload["id"],
|
|
11
|
+
name: payload["name"],
|
|
12
|
+
subject: payload["subject"],
|
|
13
|
+
# Coerced to a symbol like the subscriber enums, despite the list
|
|
14
|
+
# endpoint spelling its filter parameter `Status`.
|
|
15
|
+
status: Coercion.enum(payload["status"], Enums::CAMPAIGN_STATUSES),
|
|
16
|
+
created_at: Coercion.time(payload["created_at"]),
|
|
17
|
+
updated_at: Coercion.time(payload["updated_at"]),
|
|
18
|
+
raw: Coercion.snapshot(payload)
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The payload exactly as the API sent it, including any field this gem does
|
|
23
|
+
# not declare.
|
|
24
|
+
def to_h
|
|
25
|
+
raw
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# A custom field definition.
|
|
5
|
+
#
|
|
6
|
+
# The API identifies custom fields by `key`, not by an id. Values stored
|
|
7
|
+
# against a field are always strings.
|
|
8
|
+
CustomField = Data.define(:key, :label, :raw) do
|
|
9
|
+
def self.from(payload)
|
|
10
|
+
return nil if payload.nil?
|
|
11
|
+
|
|
12
|
+
new(key: payload["key"], label: payload["label"], raw: Coercion.snapshot(payload))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The payload exactly as the API sent it, including any field this gem does
|
|
16
|
+
# not declare.
|
|
17
|
+
def to_h
|
|
18
|
+
raw
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# One page of a list response.
|
|
5
|
+
#
|
|
6
|
+
# Holds the items plus the `meta` block the API returns. A Page never fetches
|
|
7
|
+
# anything by itself: `list` issues exactly one request, and traversing
|
|
8
|
+
# further pages is the explicit opt-in of `auto_paging_each`. Walking a large
|
|
9
|
+
# collection can consume the whole 100-requests-per-minute budget, and that
|
|
10
|
+
# cost should be visible at the call site rather than hidden behind `each`.
|
|
11
|
+
Page = Data.define(
|
|
12
|
+
:items, :page, :per_page, :total_pages, :total_items, :raw, :fetcher
|
|
13
|
+
) do
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
# `fetcher` is a callable taking a page number and returning the next Page.
|
|
17
|
+
# It is supplied by the resource that built this Page, and is what
|
|
18
|
+
# `auto_paging_each` walks.
|
|
19
|
+
def self.from(payload, klass:, fetcher: nil)
|
|
20
|
+
payload ||= {}
|
|
21
|
+
meta = payload["meta"] || {}
|
|
22
|
+
|
|
23
|
+
new(
|
|
24
|
+
items: Coercion.array_of(klass, payload["data"]),
|
|
25
|
+
page: meta["page"],
|
|
26
|
+
per_page: meta["per_page"],
|
|
27
|
+
total_pages: meta["total_pages"],
|
|
28
|
+
total_items: meta["total_items"],
|
|
29
|
+
raw: Coercion.snapshot(payload),
|
|
30
|
+
fetcher: fetcher
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def each(&)
|
|
35
|
+
items.each(&)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty?
|
|
39
|
+
items.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True when the metadata shows a page after this one. False when metadata is
|
|
43
|
+
# absent, since nothing then indicates another page exists.
|
|
44
|
+
def more_pages?
|
|
45
|
+
return false if page.nil? || total_pages.nil?
|
|
46
|
+
|
|
47
|
+
page < total_pages
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The payload exactly as the API sent it.
|
|
51
|
+
def to_h
|
|
52
|
+
raw
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Walks this page and every page after it, fetching each on demand.
|
|
56
|
+
#
|
|
57
|
+
# Returns a lazy Enumerator, so `.first(5)` fetches only what it needs
|
|
58
|
+
# instead of the whole collection. Deliberately not the behavior of `each`.
|
|
59
|
+
def auto_paging_each(&block)
|
|
60
|
+
return to_enum(:auto_paging_each) unless block_given?
|
|
61
|
+
|
|
62
|
+
current = self
|
|
63
|
+
loop do
|
|
64
|
+
current.items.each(&block)
|
|
65
|
+
break unless current.more_pages? && current.fetcher
|
|
66
|
+
|
|
67
|
+
current = current.fetcher.call(current.page + 1)
|
|
68
|
+
break if current.nil?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# A segment.
|
|
5
|
+
#
|
|
6
|
+
# Covers both `SegmentRes` and the smaller `SegmentMini` the API nests inside
|
|
7
|
+
# subscriber payloads: fields absent from the narrower shape simply read nil.
|
|
8
|
+
#
|
|
9
|
+
# `segment_type` is `"static"` or `"dynamic"`. It is deliberately not coerced
|
|
10
|
+
# to a Symbol: the specification documents those two values in prose only and
|
|
11
|
+
# declares no enum array, so there is nothing to validate against and
|
|
12
|
+
# inventing a closed set here would be a guess.
|
|
13
|
+
Segment = Data.define(
|
|
14
|
+
:id, :name, :color, :total_active_subscribers, :created_at, :segment_type, :raw
|
|
15
|
+
) do
|
|
16
|
+
def self.from(payload)
|
|
17
|
+
return nil if payload.nil?
|
|
18
|
+
|
|
19
|
+
new(
|
|
20
|
+
id: payload["id"],
|
|
21
|
+
name: payload["name"],
|
|
22
|
+
color: payload["color"],
|
|
23
|
+
total_active_subscribers: payload["total_active_subscribers"],
|
|
24
|
+
created_at: Coercion.time(payload["created_at"]),
|
|
25
|
+
segment_type: payload["segment_type"],
|
|
26
|
+
raw: Coercion.snapshot(payload)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The payload exactly as the API sent it, including any field this gem does
|
|
31
|
+
# not declare.
|
|
32
|
+
def to_h
|
|
33
|
+
raw
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# A subscriber.
|
|
5
|
+
Subscriber = Data.define(
|
|
6
|
+
:id, :status, :email, :source, :first_name, :last_name,
|
|
7
|
+
:segments, :custom_fields, :optin_ip, :optin_timestamp, :created_at, :raw
|
|
8
|
+
) do
|
|
9
|
+
def self.from(payload)
|
|
10
|
+
return nil if payload.nil?
|
|
11
|
+
|
|
12
|
+
new(
|
|
13
|
+
id: payload["id"],
|
|
14
|
+
status: Coercion.enum(payload["status"], Enums::SUBSCRIBER_STATUSES),
|
|
15
|
+
email: payload["email"],
|
|
16
|
+
source: Coercion.enum(payload["source"], Enums::SUBSCRIBER_SOURCES),
|
|
17
|
+
first_name: payload["first_name"],
|
|
18
|
+
last_name: payload["last_name"],
|
|
19
|
+
segments: Coercion.array_of(Segment, payload["segments"]),
|
|
20
|
+
custom_fields: Coercion.string_hash(payload["custom_fields"]),
|
|
21
|
+
optin_ip: payload["optin_ip"],
|
|
22
|
+
optin_timestamp: Coercion.time(payload["optin_timestamp"]),
|
|
23
|
+
created_at: Coercion.time(payload["created_at"]),
|
|
24
|
+
raw: Coercion.snapshot(payload)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The payload exactly as the API sent it, including any field this gem does
|
|
29
|
+
# not declare.
|
|
30
|
+
def to_h
|
|
31
|
+
raw
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when this subscriber can receive marketing email.
|
|
35
|
+
def active?
|
|
36
|
+
status == :active
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# A registered webhook.
|
|
5
|
+
Webhook = Data.define(:id, :post_url, :events, :created_at, :raw) do
|
|
6
|
+
def self.from(payload)
|
|
7
|
+
return nil if payload.nil?
|
|
8
|
+
|
|
9
|
+
new(
|
|
10
|
+
id: payload["id"],
|
|
11
|
+
post_url: payload["post_url"],
|
|
12
|
+
events: Coercion.snapshot(payload["events"] || []),
|
|
13
|
+
created_at: Coercion.time(payload["created_at"]),
|
|
14
|
+
raw: Coercion.snapshot(payload)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The payload exactly as the API sent it, including any field this gem does
|
|
19
|
+
# not declare.
|
|
20
|
+
def to_h
|
|
21
|
+
raw
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|