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,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations under the `Subscriber` tag of the Flodesk API.
|
|
6
|
+
class Subscribers < Base
|
|
7
|
+
PATH = "/subscribers"
|
|
8
|
+
|
|
9
|
+
# The API caps segments per subscriber at 50.
|
|
10
|
+
MAX_SEGMENTS = 50
|
|
11
|
+
|
|
12
|
+
# The API caps a batch at 50 subscribers per request. Combined with the
|
|
13
|
+
# endpoint's 20-requests-per-minute limit, that is a ceiling of 1,000
|
|
14
|
+
# upserts per minute.
|
|
15
|
+
MAX_BATCH_SIZE = 50
|
|
16
|
+
|
|
17
|
+
# Every field `CreateOrUpdateSubscriberItem` documents, and the single
|
|
18
|
+
# source of truth for both building the payload and rejecting unknown
|
|
19
|
+
# keys — a duplicated list is how a field quietly stops being covered.
|
|
20
|
+
#
|
|
21
|
+
# The contract spec asserts this matches the schema exactly. That is what
|
|
22
|
+
# makes rejecting unknown keys safe rather than brittle: a field Flodesk
|
|
23
|
+
# adds fails the build here, instead of becoming a runtime ArgumentError
|
|
24
|
+
# for a value the API would have accepted.
|
|
25
|
+
SUBSCRIBER_FIELDS = %i[
|
|
26
|
+
id email first_name last_name custom_fields segment_ids
|
|
27
|
+
double_optin optin_ip optin_timestamp
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# GET /subscribers
|
|
31
|
+
#
|
|
32
|
+
# Issues exactly one request. Use {#auto_paging_each} to walk every page.
|
|
33
|
+
def list(page: nil, per_page: nil, status: nil, segment_id: nil)
|
|
34
|
+
paginated_list(
|
|
35
|
+
PATH, klass: Subscriber, page: page, per_page: per_page,
|
|
36
|
+
filters: list_filters(status: status, segment_id: segment_id)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Walks every subscriber across all pages, fetching each page on demand.
|
|
41
|
+
#
|
|
42
|
+
# Traversing a large list can consume the entire 100-requests-per-minute
|
|
43
|
+
# budget, which is why this is opt-in rather than the behavior of `list`.
|
|
44
|
+
def auto_paging_each(page: nil, per_page: nil, status: nil, segment_id: nil, &)
|
|
45
|
+
each_page_item(
|
|
46
|
+
PATH, klass: Subscriber, page: page, per_page: per_page,
|
|
47
|
+
filters: list_filters(status: status, segment_id: segment_id), &
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET /subscribers/{id_or_email}
|
|
52
|
+
#
|
|
53
|
+
# `id_or_email` may be either; an email is percent-encoded into the path.
|
|
54
|
+
def retrieve(id_or_email)
|
|
55
|
+
Subscriber.from(get("#{PATH}/#{encode_segment(id_or_email)}"))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /subscribers — creates or updates.
|
|
59
|
+
#
|
|
60
|
+
# Declared idempotent: repeating it converges on the same state, so it is
|
|
61
|
+
# safe to retry after a timeout or 5xx.
|
|
62
|
+
#
|
|
63
|
+
# The API returns 200 for both a creation and an update and never reports
|
|
64
|
+
# which occurred, so this cannot tell you whether the subscriber was new.
|
|
65
|
+
def upsert(**attrs)
|
|
66
|
+
Subscriber.from(post(PATH, body: subscriber_payload(attrs), idempotent: true))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# POST /subscribers/batch — up to 50 records in one request.
|
|
70
|
+
#
|
|
71
|
+
# Returns a {BatchResult}. Because the API reports per-record failures
|
|
72
|
+
# inside a 200 response, this raises {PartialFailureError} when any record
|
|
73
|
+
# failed — carrying the full result, so successes are not lost. Pass
|
|
74
|
+
# `raise_on_failure: false` to receive the result quietly instead.
|
|
75
|
+
def batch_upsert(records, raise_on_failure: true)
|
|
76
|
+
payload = batch_payload(records)
|
|
77
|
+
result = BatchResult.from(
|
|
78
|
+
post("#{PATH}/batch", body: { "subscribers" => payload }, idempotent: true)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
raise PartialFailureError.new(result: result) if raise_on_failure && !result.success?
|
|
82
|
+
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# POST /subscribers/{id_or_email}/segments
|
|
87
|
+
#
|
|
88
|
+
# Idempotent: adding a segment the subscriber already has has no further
|
|
89
|
+
# effect.
|
|
90
|
+
def add_to_segments(id_or_email, segment_ids)
|
|
91
|
+
Subscriber.from(
|
|
92
|
+
post(
|
|
93
|
+
"#{PATH}/#{encode_segment(id_or_email)}/segments",
|
|
94
|
+
body: { "segment_ids" => validate_segment_ids!(segment_ids) },
|
|
95
|
+
idempotent: true
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# DELETE /subscribers/{id_or_email}/segments
|
|
101
|
+
#
|
|
102
|
+
# Idempotent: removing a segment the subscriber does not have has no
|
|
103
|
+
# further effect.
|
|
104
|
+
def remove_from_segments(id_or_email, segment_ids)
|
|
105
|
+
Subscriber.from(
|
|
106
|
+
delete(
|
|
107
|
+
"#{PATH}/#{encode_segment(id_or_email)}/segments",
|
|
108
|
+
body: { "segment_ids" => validate_segment_ids!(segment_ids) }
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# POST /subscribers/{id_or_email}/unsubscribe
|
|
114
|
+
#
|
|
115
|
+
# Idempotent: unsubscribing is a terminal state, so repeating it is safe.
|
|
116
|
+
def unsubscribe(id_or_email)
|
|
117
|
+
Subscriber.from(
|
|
118
|
+
post("#{PATH}/#{encode_segment(id_or_email)}/unsubscribe", idempotent: true)
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def list_filters(status:, segment_id:)
|
|
125
|
+
{
|
|
126
|
+
"status" => validate_enum!("status", status, Enums::SUBSCRIBER_STATUSES),
|
|
127
|
+
"segment_id" => segment_id
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Builds a `CreateOrUpdateSubscriberItem`. `index` is included in the error
|
|
132
|
+
# message when validating a batch, so a rejected record is identifiable.
|
|
133
|
+
def subscriber_payload(attrs, index: nil)
|
|
134
|
+
attrs = normalize_keys(attrs, index)
|
|
135
|
+
validate_known_keys!(attrs, index)
|
|
136
|
+
validate_identifier!(attrs, index)
|
|
137
|
+
|
|
138
|
+
SUBSCRIBER_FIELDS.each_with_object({}) do |field, payload|
|
|
139
|
+
value = coerce_field(field, attrs[field])
|
|
140
|
+
payload[field.to_s] = value unless value.nil?
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `false` is a meaningful value for `double_optin`, so only `nil` — the
|
|
145
|
+
# caller having said nothing — omits a field.
|
|
146
|
+
def coerce_field(field, value)
|
|
147
|
+
return nil if value.nil?
|
|
148
|
+
|
|
149
|
+
case field
|
|
150
|
+
when :custom_fields then stringify_custom_fields(value)
|
|
151
|
+
when :segment_ids then validate_segment_ids!(value)
|
|
152
|
+
else value
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def batch_payload(records)
|
|
157
|
+
# Shape is checked before contents. `each_with_index` over a Hash yields
|
|
158
|
+
# [[key, value], 0], so a single record passed instead of an array used
|
|
159
|
+
# to have its *values* parsed as field names — putting a subscriber
|
|
160
|
+
# email into an error message.
|
|
161
|
+
unless records.is_a?(Array)
|
|
162
|
+
raise ArgumentError,
|
|
163
|
+
"records must be an Array of subscriber attributes, got #{records.class}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
raise ArgumentError, "records cannot be empty" if records.empty?
|
|
167
|
+
|
|
168
|
+
if records.size > MAX_BATCH_SIZE
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"a batch accepts at most #{MAX_BATCH_SIZE} records, got #{records.size}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
records.each_with_index.map { |record, i| subscriber_payload(record, index: i) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# `to_s.to_sym` rather than `to_sym`: a non-symbolizable key (an Integer,
|
|
177
|
+
# say) should surface as an unknown field, not a NoMethodError from deep
|
|
178
|
+
# inside the payload builder.
|
|
179
|
+
def normalize_keys(attrs, index = nil)
|
|
180
|
+
return {} if attrs.nil?
|
|
181
|
+
|
|
182
|
+
unless attrs.is_a?(Hash)
|
|
183
|
+
at = index.nil? ? "" : " at index #{index}"
|
|
184
|
+
raise ArgumentError,
|
|
185
|
+
"subscriber attributes must be a Hash#{at}, got #{attrs.class}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
attrs.to_h { |k, v| [k.to_s.to_sym, v] }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# An unrecognized key used to be dropped on the floor, so a misspelled
|
|
192
|
+
# `frist_name:` vanished and the request reported success. Silence is the
|
|
193
|
+
# worst outcome here: the caller believes they wrote a field they did not.
|
|
194
|
+
def validate_known_keys!(attrs, index)
|
|
195
|
+
unknown = attrs.keys - SUBSCRIBER_FIELDS
|
|
196
|
+
return if unknown.empty?
|
|
197
|
+
|
|
198
|
+
at = index.nil? ? "" : " at index #{index}"
|
|
199
|
+
noun = unknown.one? ? "field" : "fields"
|
|
200
|
+
raise ArgumentError,
|
|
201
|
+
"unknown subscriber #{noun}#{at}: #{unknown.join(", ")}. " \
|
|
202
|
+
"Accepted: #{SUBSCRIBER_FIELDS.join(", ")}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_identifier!(attrs, index)
|
|
206
|
+
return if present?(attrs[:id]) || present?(attrs[:email])
|
|
207
|
+
|
|
208
|
+
at = index.nil? ? "" : " at index #{index}"
|
|
209
|
+
raise ArgumentError, "either email or id must be provided#{at}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_segment_ids!(segment_ids)
|
|
213
|
+
ids = Array(segment_ids)
|
|
214
|
+
raise ArgumentError, "segment_ids cannot be empty" if ids.empty?
|
|
215
|
+
|
|
216
|
+
if ids.size > MAX_SEGMENTS
|
|
217
|
+
raise ArgumentError,
|
|
218
|
+
"a subscriber accepts at most #{MAX_SEGMENTS} segments, got #{ids.size}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
ids.map(&:to_s)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Custom field values are typed `string` throughout the API, so anything
|
|
225
|
+
# else would simply be rejected. Coercing is friendlier than raising and
|
|
226
|
+
# cannot lose information. `nil` is preserved: it means "clear this
|
|
227
|
+
# field", which is a different instruction from the empty string.
|
|
228
|
+
def stringify_custom_fields(fields)
|
|
229
|
+
return nil if fields.nil?
|
|
230
|
+
|
|
231
|
+
fields.to_h { |key, value| [key.to_s, value&.to_s] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def present?(value)
|
|
235
|
+
!value.nil? && !value.to_s.empty?
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations under the `Webhook` tag of the Flodesk API.
|
|
6
|
+
#
|
|
7
|
+
# Registering a webhook is only half the job. Flodesk signs nothing — the API
|
|
8
|
+
# description declares `security: []` on all three events — so the endpoint
|
|
9
|
+
# you register must be able to establish authenticity by other means. See
|
|
10
|
+
# {Flodesk::Webhooks::Handler} for the two supported strategies, and choose
|
|
11
|
+
# one before deciding what `post_url` to register here.
|
|
12
|
+
class Webhooks < Base
|
|
13
|
+
PATH = "/webhooks"
|
|
14
|
+
|
|
15
|
+
# GET /webhooks
|
|
16
|
+
def list(page: nil, per_page: nil)
|
|
17
|
+
paginated_list(PATH, klass: Webhook, page: page, per_page: per_page)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Walks every webhook across all pages, fetching each page on demand.
|
|
21
|
+
def auto_paging_each(page: nil, per_page: nil, &)
|
|
22
|
+
each_page_item(PATH, klass: Webhook, page: page, per_page: per_page, &)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /webhooks/{id}
|
|
26
|
+
def retrieve(id)
|
|
27
|
+
Webhook.from(get("#{PATH}/#{encode_segment(id)}"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# POST /webhooks — returns 201 with the created webhook.
|
|
31
|
+
#
|
|
32
|
+
# NOT idempotent: this creates a new registration and the API offers no
|
|
33
|
+
# idempotency key, so a retry could leave a duplicate webhook delivering
|
|
34
|
+
# every event twice.
|
|
35
|
+
def create(name:, post_url:, events:)
|
|
36
|
+
raise ArgumentError, "name is required" if blank?(name)
|
|
37
|
+
raise ArgumentError, "post_url is required" if blank?(post_url)
|
|
38
|
+
|
|
39
|
+
Webhook.from(
|
|
40
|
+
post(
|
|
41
|
+
PATH,
|
|
42
|
+
body: {
|
|
43
|
+
"name" => name.to_s,
|
|
44
|
+
"post_url" => post_url.to_s,
|
|
45
|
+
"events" => validate_events!(events)
|
|
46
|
+
},
|
|
47
|
+
idempotent: false
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# PUT /webhooks/{id}
|
|
53
|
+
#
|
|
54
|
+
# Idempotent: PUT replaces the registration's state, so repeating it
|
|
55
|
+
# converges on the same result. Every field is optional; only those
|
|
56
|
+
# supplied are sent.
|
|
57
|
+
def update(id, name: nil, post_url: nil, events: nil)
|
|
58
|
+
body = {
|
|
59
|
+
"name" => name&.to_s,
|
|
60
|
+
"post_url" => post_url&.to_s,
|
|
61
|
+
"events" => events.nil? ? nil : validate_events!(events)
|
|
62
|
+
}.compact
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "at least one of name, post_url or events is required" if body.empty?
|
|
65
|
+
|
|
66
|
+
Webhook.from(put("#{PATH}/#{encode_segment(id)}", body: body))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# DELETE /webhooks/{id} — returns 204.
|
|
70
|
+
#
|
|
71
|
+
# Idempotent. Returns nil, since a 204 carries no body to parse.
|
|
72
|
+
def delete(id)
|
|
73
|
+
super("#{PATH}/#{encode_segment(id)}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Flodesk delivers exactly three events. Rejecting anything else here
|
|
79
|
+
# avoids registering a webhook that can never fire.
|
|
80
|
+
def validate_events!(events)
|
|
81
|
+
list = Array(events).map(&:to_s)
|
|
82
|
+
raise ArgumentError, "events cannot be empty" if list.empty?
|
|
83
|
+
|
|
84
|
+
list.each { |event| validate_enum!("events", event, Enums::WEBHOOK_EVENTS) }
|
|
85
|
+
list
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def blank?(value)
|
|
89
|
+
value.nil? || value.to_s.empty?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations under the `Workflow` tag of the Flodesk API.
|
|
6
|
+
class Workflows < Base
|
|
7
|
+
PATH = "/workflows"
|
|
8
|
+
|
|
9
|
+
# This endpoint spells its page-size parameter `perPage`, unlike every
|
|
10
|
+
# other list endpoint in the API, which use `per_page`. Callers pass
|
|
11
|
+
# `per_page:` regardless; the translation happens here.
|
|
12
|
+
PER_PAGE_KEY = "perPage"
|
|
13
|
+
|
|
14
|
+
# GET /workflows
|
|
15
|
+
#
|
|
16
|
+
# `statuses` accepts a single value or an array.
|
|
17
|
+
def list(page: nil, per_page: nil, statuses: nil)
|
|
18
|
+
paginated_list(
|
|
19
|
+
PATH, klass: Workflow, page: page, per_page: per_page,
|
|
20
|
+
per_page_key: PER_PAGE_KEY, filters: status_filter(statuses)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Walks every workflow across all pages, fetching each page on demand.
|
|
25
|
+
def auto_paging_each(page: nil, per_page: nil, statuses: nil, &)
|
|
26
|
+
each_page_item(
|
|
27
|
+
PATH, klass: Workflow, page: page, per_page: per_page,
|
|
28
|
+
per_page_key: PER_PAGE_KEY, filters: status_filter(statuses), &
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# POST /workflows/{workflow_id}/subscribers — returns 204.
|
|
33
|
+
#
|
|
34
|
+
# Idempotent: enrolling an already-enrolled subscriber has no further
|
|
35
|
+
# effect. Returns nil, since a 204 carries no body to parse.
|
|
36
|
+
def add_subscriber(workflow_id, id: nil, email: nil)
|
|
37
|
+
raise ArgumentError, "either id or email must be provided" if blank?(id) && blank?(email)
|
|
38
|
+
|
|
39
|
+
post(
|
|
40
|
+
"#{PATH}/#{encode_segment(workflow_id)}/subscribers",
|
|
41
|
+
body: { "id" => id, "email" => email }.compact,
|
|
42
|
+
idempotent: true
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# DELETE /workflows/{workflow_id}/subscribers/{id_or_email} — returns 204.
|
|
47
|
+
#
|
|
48
|
+
# Idempotent. Returns nil, since a 204 carries no body to parse.
|
|
49
|
+
def remove_subscriber(workflow_id, id_or_email)
|
|
50
|
+
delete(
|
|
51
|
+
"#{PATH}/#{encode_segment(workflow_id)}/subscribers/#{encode_segment(id_or_email)}"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# `statuses` is an array parameter, but the API documents it as
|
|
58
|
+
# comma-separated (`statuses=active,paused`) rather than repeated keys.
|
|
59
|
+
# A single value is wrapped so callers can pass either form.
|
|
60
|
+
def status_filter(statuses)
|
|
61
|
+
return { "statuses" => nil } if statuses.nil?
|
|
62
|
+
|
|
63
|
+
validated = Array(statuses).map do |status|
|
|
64
|
+
validate_enum!("statuses", status, Enums::WORKFLOW_STATUSES)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
{ "statuses" => validated.join(",") }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def blank?(value)
|
|
71
|
+
value.nil? || value.to_s.empty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# A completed HTTP response: status, normalized headers, and parsed body.
|
|
5
|
+
#
|
|
6
|
+
# Instances are immutable. `body` is nil for 204 responses, which several
|
|
7
|
+
# endpoints return and which must never be parsed as JSON.
|
|
8
|
+
Response = Data.define(:status, :headers, :body) do
|
|
9
|
+
# Value of `X-Fd-RateLimit-Limit`, or nil when absent.
|
|
10
|
+
def rate_limit
|
|
11
|
+
integer_header("x-fd-ratelimit-limit")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Value of `X-Fd-RateLimit-Remaining`, or nil when absent.
|
|
15
|
+
def rate_limit_remaining
|
|
16
|
+
integer_header("x-fd-ratelimit-remaining")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Flodesk sends no rate-limit reset header, so no reset time exists to
|
|
20
|
+
# report. Present to make the absence explicit rather than surprising.
|
|
21
|
+
def rate_limit_reset
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def integer_header(name)
|
|
28
|
+
value = headers[name]
|
|
29
|
+
value = value.first if value.is_a?(Array)
|
|
30
|
+
return nil if value.nil? || value.to_s.empty?
|
|
31
|
+
|
|
32
|
+
Integer(value, exception: false)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flodesk
|
|
4
|
+
# Decides whether a failed request may be retried.
|
|
5
|
+
#
|
|
6
|
+
# This is a distinct concept in this client because Flodesk offers no
|
|
7
|
+
# idempotency-key header, so the only thing making a retry safe is knowing
|
|
8
|
+
# that the operation is naturally idempotent. That knowledge lives with each
|
|
9
|
+
# endpoint, and this class is where it is applied.
|
|
10
|
+
class RetryPolicy
|
|
11
|
+
# Whether repeating the operation is safe.
|
|
12
|
+
attr_reader :idempotent
|
|
13
|
+
|
|
14
|
+
# Whether a 429 may be retried. False only for POST /campaigns/canva, where
|
|
15
|
+
# a 429 cannot prove the campaign was not accepted.
|
|
16
|
+
attr_reader :retry_rate_limit
|
|
17
|
+
|
|
18
|
+
# Maximum retries after the initial attempt.
|
|
19
|
+
attr_reader :max_retries
|
|
20
|
+
|
|
21
|
+
def initialize(idempotent:, retry_rate_limit:, max_retries:)
|
|
22
|
+
@idempotent = idempotent
|
|
23
|
+
@retry_rate_limit = retry_rate_limit
|
|
24
|
+
@max_retries = max_retries
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# True when `error` may be retried on attempt number `attempt`.
|
|
29
|
+
def retry?(error, attempt)
|
|
30
|
+
attempt <= max_retries && retriable_error?(error)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def retriable_error?(error)
|
|
36
|
+
case error
|
|
37
|
+
when RateLimitError then retry_rate_limit
|
|
38
|
+
when ServerError, TimeoutError, ConnectionError then idempotent
|
|
39
|
+
else
|
|
40
|
+
# Every other 4xx is caused by the request itself and cannot succeed on
|
|
41
|
+
# a second identical attempt.
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "webmock"
|
|
5
|
+
|
|
6
|
+
module Flodesk
|
|
7
|
+
# WebMock stubs and fixture payloads for testing Flodesk integrations.
|
|
8
|
+
#
|
|
9
|
+
# Opt-in: nothing here is loaded by `require "flodesk"`.
|
|
10
|
+
#
|
|
11
|
+
# require "flodesk/test_helpers"
|
|
12
|
+
#
|
|
13
|
+
# RSpec.configure { |c| c.include Flodesk::TestHelpers }
|
|
14
|
+
#
|
|
15
|
+
# stub_flodesk_upsert(email: "a@b.com")
|
|
16
|
+
# stub_flodesk_error(:post, "/subscribers", status: 404)
|
|
17
|
+
#
|
|
18
|
+
# Payload shapes follow the API description, so a stub cannot drift toward
|
|
19
|
+
# whatever the calling code happens to expect — the classic way a green suite
|
|
20
|
+
# hides a broken integration.
|
|
21
|
+
module TestHelpers
|
|
22
|
+
BASE_URL = Flodesk::DEFAULT_BASE_URL
|
|
23
|
+
|
|
24
|
+
# A `SubscriberRes` payload with every documented field populated.
|
|
25
|
+
def flodesk_subscriber(overrides = {})
|
|
26
|
+
{
|
|
27
|
+
"id" => "sub_test_1",
|
|
28
|
+
"status" => "active",
|
|
29
|
+
"email" => "subscriber@example.com",
|
|
30
|
+
"source" => "manual",
|
|
31
|
+
"first_name" => "Test",
|
|
32
|
+
"last_name" => "Subscriber",
|
|
33
|
+
"segments" => [],
|
|
34
|
+
"custom_fields" => {},
|
|
35
|
+
"optin_ip" => "203.0.113.1",
|
|
36
|
+
"optin_timestamp" => "2024-01-01T00:00:00.000Z",
|
|
37
|
+
"created_at" => "2024-01-01T00:00:00.000Z"
|
|
38
|
+
}.merge(stringify(overrides))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A `SegmentRes` payload.
|
|
42
|
+
def flodesk_segment(overrides = {})
|
|
43
|
+
{
|
|
44
|
+
"id" => "seg_test_1",
|
|
45
|
+
"name" => "Test Segment",
|
|
46
|
+
"color" => "#ffeecc",
|
|
47
|
+
"total_active_subscribers" => 0,
|
|
48
|
+
"created_at" => "2024-01-01T00:00:00.000Z"
|
|
49
|
+
}.merge(stringify(overrides))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A `WebhookRes` payload.
|
|
53
|
+
def flodesk_webhook(overrides = {})
|
|
54
|
+
{
|
|
55
|
+
"id" => "wh_test_1",
|
|
56
|
+
"post_url" => "https://example.com/flodesk",
|
|
57
|
+
"events" => ["subscriber.created"],
|
|
58
|
+
"created_at" => "2024-01-01T00:00:00.000Z"
|
|
59
|
+
}.merge(stringify(overrides))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A paginated list envelope, with `meta` shaped as the API returns it.
|
|
63
|
+
def flodesk_list(items, page: 1, per_page: 20, total_pages: 1, total_items: nil)
|
|
64
|
+
{
|
|
65
|
+
"meta" => {
|
|
66
|
+
"page" => page, "per_page" => per_page,
|
|
67
|
+
"total_pages" => total_pages, "total_items" => total_items || items.size
|
|
68
|
+
},
|
|
69
|
+
"data" => items
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Stubs a successful subscriber upsert.
|
|
74
|
+
def stub_flodesk_upsert(email: "subscriber@example.com", **overrides)
|
|
75
|
+
stub_flodesk(:post, "/subscribers",
|
|
76
|
+
response: flodesk_subscriber(email: email, **overrides))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Stubs a subscriber retrieval by id or email.
|
|
80
|
+
def stub_flodesk_retrieve(id_or_email, **overrides)
|
|
81
|
+
stub_flodesk(
|
|
82
|
+
:get, "/subscribers/#{encode(id_or_email)}",
|
|
83
|
+
response: flodesk_subscriber(id: id_or_email.to_s, **overrides)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Stubs a subscriber list.
|
|
88
|
+
def stub_flodesk_list_subscribers(subscribers = [flodesk_subscriber], **pagination)
|
|
89
|
+
stub_flodesk(:get, "/subscribers", response: flodesk_list(subscribers, **pagination))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Stubs a batch upsert.
|
|
93
|
+
#
|
|
94
|
+
# Pass `failures:` to exercise partial failure, which is the case worth
|
|
95
|
+
# testing: the API reports it inside a 200, and by default the gem raises
|
|
96
|
+
# {Flodesk::PartialFailureError} for it.
|
|
97
|
+
def stub_flodesk_batch(successes: [flodesk_subscriber], failures: [])
|
|
98
|
+
stub_flodesk(
|
|
99
|
+
:post, "/subscribers/batch",
|
|
100
|
+
response: { "successes" => successes, "failures" => failures }
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A `BatchItemError` payload.
|
|
105
|
+
def flodesk_batch_failure(index: 0, code: "invalid_email", **overrides)
|
|
106
|
+
{
|
|
107
|
+
"index" => index,
|
|
108
|
+
"email" => "bad@",
|
|
109
|
+
"id" => nil,
|
|
110
|
+
"code" => code,
|
|
111
|
+
"message" => "Email is invalid"
|
|
112
|
+
}.merge(stringify(overrides))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Stubs an error response using the live API's `{code, message}` envelope.
|
|
116
|
+
def stub_flodesk_error(method, path, status:, code: nil, message: nil)
|
|
117
|
+
stub_flodesk(
|
|
118
|
+
method, path, status: status,
|
|
119
|
+
response: { "code" => code || "error", "message" => message || "Request failed" }
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Stubs a rate-limited response, including the headers the API sends. There
|
|
124
|
+
# is deliberately no reset header, because the API does not send one.
|
|
125
|
+
def stub_flodesk_rate_limited(method, path, limit: 100)
|
|
126
|
+
stub_flodesk(
|
|
127
|
+
method, path, status: 429,
|
|
128
|
+
response: { "code" => "rate_limited", "message" => "Too many requests" },
|
|
129
|
+
headers: { "X-Fd-RateLimit-Limit" => limit.to_s, "X-Fd-RateLimit-Remaining" => "0" }
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The general-purpose stub the helpers above are built on.
|
|
134
|
+
def stub_flodesk(method, path, response: nil, status: 200, headers: {}, base_url: BASE_URL)
|
|
135
|
+
WebMock::API.stub_request(method, "#{base_url}#{path}").to_return(
|
|
136
|
+
status: status,
|
|
137
|
+
body: response.nil? ? "" : JSON.generate(response),
|
|
138
|
+
headers: { "Content-Type" => "application/json" }.merge(headers)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def stringify(hash)
|
|
145
|
+
hash.to_h { |k, v| [k.to_s, v] }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def encode(value)
|
|
149
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|