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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +101 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +351 -0
  5. data/Rakefile +12 -0
  6. data/lib/flodesk/auth.rb +32 -0
  7. data/lib/flodesk/client.rb +84 -0
  8. data/lib/flodesk/coercion.rb +69 -0
  9. data/lib/flodesk/connection.rb +179 -0
  10. data/lib/flodesk/enums.rb +42 -0
  11. data/lib/flodesk/errors.rb +139 -0
  12. data/lib/flodesk/instrumentation.rb +35 -0
  13. data/lib/flodesk/objects/batch_item_error.rb +29 -0
  14. data/lib/flodesk/objects/batch_result.rb +44 -0
  15. data/lib/flodesk/objects/campaign.rb +28 -0
  16. data/lib/flodesk/objects/custom_field.rb +21 -0
  17. data/lib/flodesk/objects/page.rb +72 -0
  18. data/lib/flodesk/objects/segment.rb +36 -0
  19. data/lib/flodesk/objects/subscriber.rb +39 -0
  20. data/lib/flodesk/objects/webhook.rb +24 -0
  21. data/lib/flodesk/objects/workflow.rb +22 -0
  22. data/lib/flodesk/rails/railtie.rb +19 -0
  23. data/lib/flodesk/rails.rb +8 -0
  24. data/lib/flodesk/rate_limit.rb +35 -0
  25. data/lib/flodesk/redaction.rb +46 -0
  26. data/lib/flodesk/resources/base.rb +122 -0
  27. data/lib/flodesk/resources/campaigns.rb +121 -0
  28. data/lib/flodesk/resources/custom_fields.rb +47 -0
  29. data/lib/flodesk/resources/segments.rb +49 -0
  30. data/lib/flodesk/resources/subscribers.rb +239 -0
  31. data/lib/flodesk/resources/webhooks.rb +93 -0
  32. data/lib/flodesk/resources/workflows.rb +75 -0
  33. data/lib/flodesk/response.rb +35 -0
  34. data/lib/flodesk/retry_policy.rb +46 -0
  35. data/lib/flodesk/test_helpers.rb +152 -0
  36. data/lib/flodesk/version.rb +5 -0
  37. data/lib/flodesk/webhooks/event.rb +78 -0
  38. data/lib/flodesk/webhooks/handler.rb +167 -0
  39. data/lib/flodesk/webhooks/verification.rb +61 -0
  40. data/lib/flodesk.rb +59 -0
  41. data/lib/generators/flodesk/install_generator.rb +61 -0
  42. data/lib/generators/flodesk/templates/initializer.rb.tt +28 -0
  43. data/sig/flodesk/client.rbs +91 -0
  44. data/sig/flodesk/errors.rbs +51 -0
  45. data/sig/flodesk/objects.rbs +137 -0
  46. data/sig/flodesk/resources.rbs +129 -0
  47. data/sig/flodesk/webhooks.rbs +57 -0
  48. data/sig/flodesk.rbs +61 -0
  49. metadata +97 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 999226163522bb5f96dd6a3c8dc4465e082c50edb329a9f4ab549ccb00841ef5
4
+ data.tar.gz: ba0ed925433c53183b33fdce21fa43bea96d14f82e699ff16e5042a14f87bc37
5
+ SHA512:
6
+ metadata.gz: c39fbbac523b7fead38581e89c47387b68f2a574025d298ce44e6c71db8a36392ab117d9700d6f76263280502594195da793aa6954721fe0519fafeff0b85527
7
+ data.tar.gz: 1360b3cb89ba5d41ea31345ed79ae3179228242fbfc2f3f281e6b22c79ca8c4455eae298bf057c24aff303d10603ff8e70630c4839a1f0d2d73d30bd712e3f51
data/CHANGELOG.md ADDED
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-09-19
4
+
5
+ Initial release.
6
+
7
+ > **The public API is unstable until it has been exercised against a live Flodesk
8
+ > account.** Everything here is verified against the vendored API description and
9
+ > WebMock stubs, but only the `401` envelope has been confirmed against the real
10
+ > service. Expect breaking changes in `0.1.x`.
11
+
12
+ The vendored API description (`spec/fixtures/openapi.json`) was captured
13
+ 2026-08-30.
14
+
15
+ ### Added
16
+
17
+ - `Flodesk::Client` — explicit, frozen, thread-safe. No global configuration, so
18
+ per-tenant API keys are straightforward.
19
+ - Full coverage of all 26 documented operations across six resources:
20
+ subscribers, segments, custom fields, workflows, webhooks and campaigns
21
+ (including the Canva and Studio publishing endpoints).
22
+ - API-key (HTTP Basic) authentication, behind a strategy seam so OAuth2 can be
23
+ added later without a breaking change.
24
+ - Immutable `Data.define` value objects, each exposing `#to_h` with the raw
25
+ payload — so a field Flodesk adds is reachable without a gem release.
26
+ - `BatchResult` with explicit partial-failure semantics. `batch_upsert` raises
27
+ `Flodesk::PartialFailureError` when any record fails, carrying the successes;
28
+ `raise_on_failure: false` returns the result quietly.
29
+ - Per-endpoint idempotency declarations driving retries. `POST /segments`,
30
+ `POST /custom-fields`, `POST /webhooks`, `POST /campaigns/canva` and
31
+ `POST /campaigns/studio` are never retried; the campaign publishing endpoints
32
+ are never retried even on `429`, because a retry could send a campaign to the
33
+ entire list twice.
34
+ - One uniform `page:` / `per_page:` interface across every list endpoint,
35
+ translating to `per_page`, `perPage` or PascalCase filters as each requires.
36
+ - Opt-in `auto_paging_each` returning a lazy `Enumerator`. Not the default,
37
+ because traversing a large collection can consume the whole rate-limit budget.
38
+ - Webhook handling with **mandatory** verification — Flodesk signs nothing — via
39
+ either a constant-time token-in-path check or authoritative re-fetch, plus a
40
+ composed SHA-256 dedupe key since events carry no unique id.
41
+ - Rails integration: `rails g flodesk:install`, a Railtie, `flodesk.request`
42
+ `ActiveSupport::Notifications` events, and opt-in WebMock test helpers. All
43
+ loaded conditionally; the gem declares **no runtime dependencies**.
44
+ - PII redaction throughout logging and instrumentation: `email`,
45
+ `custom_fields` and `optin_ip` never appear, including when an email is
46
+ embedded in a request path.
47
+ - RBS signatures for the public surface.
48
+ - `subscribers.list(status: :archived)` and `Subscriber#status == :archived` —
49
+ the seventh `SubscriberRes.status` value.
50
+ - `Segment#segment_type` — `"static"` or `"dynamic"`. Not symbolized: the
51
+ description documents the two values in prose but declares no enum array.
52
+ - A contract spec that walks the vendored `openapi.json` and fails the build when
53
+ client coverage drifts from the documented API. It verifies **request bodies**,
54
+ not just query parameters — a change to a documented body would otherwise sail
55
+ through green, the client simply ceasing to send a field while every stubbed
56
+ example still passed. `Subscribers::SUBSCRIBER_FIELDS` is asserted to match
57
+ `CreateOrUpdateSubscriberItem` exactly, which is what makes rejecting unknown
58
+ keys safe rather than brittle: a field Flodesk adds fails the build instead of
59
+ becoming a runtime rejection of a value the API accepts.
60
+
61
+ ### Behavior worth knowing
62
+
63
+ - `subscribers.upsert` and `batch_upsert` **raise `ArgumentError` on an
64
+ unrecognized attribute** rather than dropping it. A misspelled `frist_name:`
65
+ would otherwise vanish silently while the request reported success, leaving the
66
+ caller believing they had written a field they had not. Batch errors name the
67
+ offending record by index. Error messages name the rejected key only, never
68
+ its value.
69
+ - `batch_upsert` validates argument shape before contents: a non-Array, or a
70
+ record that is not a Hash, raises a named `ArgumentError` rather than a
71
+ `NoMethodError` from inside the payload builder. Passing a single record
72
+ instead of an array would otherwise have its *values* parsed as field names.
73
+ - The gem is published as **`flodesk`**, while the repository is named
74
+ `flodesk-rb`. The Ruby namespace is `Flodesk`. Requests identify themselves as
75
+ `flodesk/<version>`, or `<app_name> flodesk/<version>` when `app_name:` is
76
+ given.
77
+ - `campaigns.publish_studio` appeared in the 2026-08-30 specification capture and
78
+ has not been confirmed against the live API, so the maturity caveat above
79
+ applies to it in full.
80
+
81
+ ### Notes on the API this wraps
82
+
83
+ Behaviors the client absorbs, recorded here because they are easy to
84
+ rediscover the hard way:
85
+
86
+ - `POST /subscribers` is an upsert returning `200` for both creation and update,
87
+ and never reports which occurred.
88
+ - `POST /subscribers/batch` reports per-record failures inside a `200`.
89
+ - `GET /workflows` spells its page-size parameter `perPage`; `GET /campaigns`
90
+ uses PascalCase filters while keeping snake_case pagination.
91
+ - `GET /workflows`'s `statuses` filter is comma-separated, not repeated keys, and
92
+ has its own enum (`active`/`paused`/`draft`) distinct from the subscriber and
93
+ campaign status enums.
94
+ - `POST /webhooks` requires a `name` field.
95
+ - Custom field values are typed `string` only.
96
+ - Rate-limit responses include `X-Fd-RateLimit-Limit` and `-Remaining` but **no
97
+ reset header**, so no correct backoff interval is computable.
98
+ - The description declares no error-body schema anywhere; the `{code, message}`
99
+ envelope was established by probing the live API.
100
+
101
+ [0.1.0]: https://github.com/mymat-yoga/flodesk-rb/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 mymat.yoga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,351 @@
1
+ # flodesk
2
+
3
+ A dependency-free Ruby client for the [Flodesk API](https://developers.flodesk.com), built for Rails apps.
4
+
5
+ Covers all 26 documented operations across subscribers, segments, custom fields, workflows, webhooks and campaigns — and absorbs the API's rough edges so you don't have to think about them:
6
+
7
+ - **Pagination isn't uniform.** Most endpoints take `per_page`; `GET /workflows` takes `perPage`; `GET /campaigns` takes PascalCase filters. You always pass `page:` and `per_page:`.
8
+ - **Batch upsert reports failure inside a `200`.** A client that treats 2xx as success silently drops subscribers. Here, partial failure raises by default.
9
+ - **Retry safety is per-endpoint, not per-verb.** Most `POST`s are idempotent upserts, but four create records — and one *publishes an email campaign*. A generic "retry POST on 5xx" wrapper can send a campaign to your whole list twice.
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ gem "flodesk"
15
+ ```
16
+
17
+ Requires Ruby 3.2+. No runtime dependencies.
18
+
19
+ > The gem is `flodesk`; the repository is named `flodesk-rb`. The Ruby namespace
20
+ > is `Flodesk` — there is no `Flodesk::Rb`. The `-rb` suffix is a leftover of how
21
+ > the repository was first generated and appears nowhere in the public API.
22
+
23
+ ## Quick start
24
+
25
+ ```ruby
26
+ client = Flodesk::Client.new(
27
+ api_key: ENV.fetch("FLODESK_API_KEY"),
28
+ app_name: "MyApp (myapp.com)" # the API asks integrations to identify themselves
29
+ )
30
+
31
+ client.subscribers.upsert(email: "ada@example.com", first_name: "Ada")
32
+ ```
33
+
34
+ Create and manage API keys at [app.flodesk.com/account/integration/api](https://app.flodesk.com/account/integration/api).
35
+
36
+ ### Rails
37
+
38
+ ```bash
39
+ bin/rails g flodesk:install
40
+ bin/rails credentials:edit # add: flodesk_api_key: fd_your_key_here
41
+ ```
42
+
43
+ That writes `config/initializers/flodesk.rb` assigning a client to a `FLODESK` constant. The gem holds **no global configuration** — ownership stays visible in your app, and a second Flodesk account is just another `Flodesk::Client.new`. A client is frozen and safe to share across request threads.
44
+
45
+ ## Configuration
46
+
47
+ ```ruby
48
+ Flodesk::Client.new(
49
+ api_key: "fd_...", # required
50
+ app_name: "MyApp", # added to the User-Agent
51
+ open_timeout: 5,
52
+ read_timeout: 15,
53
+ max_retries: 2, # 0 disables retrying entirely
54
+ backoff_base: 0.5
55
+ )
56
+ ```
57
+
58
+ ## Subscribers
59
+
60
+ ```ruby
61
+ # Create or update. The API returns 200 for both and never says which, so this
62
+ # cannot tell you whether the subscriber was new. Unknown attributes raise
63
+ # rather than being dropped, so a typo surfaces instead of vanishing.
64
+ subscriber = client.subscribers.upsert(
65
+ email: "ada@example.com",
66
+ first_name: "Ada",
67
+ segment_ids: ["seg_123"], # max 50
68
+ custom_fields: { "tier" => "gold" },
69
+ double_optin: true # only honored on creation
70
+ )
71
+
72
+ subscriber.email # => "ada@example.com"
73
+ subscriber.status # => :active
74
+ subscriber.active? # => true
75
+ subscriber.segments.first.name
76
+ subscriber.to_h # the raw payload, always available
77
+
78
+ client.subscribers.retrieve("ada@example.com") # id or email
79
+ client.subscribers.add_to_segments("sub_1", ["seg_123"])
80
+ client.subscribers.remove_from_segments("sub_1", ["seg_123"])
81
+ client.subscribers.unsubscribe("sub_1")
82
+ ```
83
+
84
+ Custom field values are typed `string` throughout the API, so non-string values are coerced (`42` → `"42"`, `true` → `"true"`). `nil` is preserved, because it means "clear this field" rather than "set it to empty".
85
+
86
+ ### Batch upsert
87
+
88
+ Up to 50 records per request, at 20 requests/minute — an effective ceiling of 1,000 upserts/minute.
89
+
90
+ The API returns `200` carrying **both** `successes` and `failures`, so `batch_upsert` raises on partial failure by default. The error carries the whole result, so successful records are never lost:
91
+
92
+ ```ruby
93
+ begin
94
+ client.subscribers.batch_upsert([
95
+ { email: "ada@example.com" },
96
+ { email: "not-an-email" }
97
+ ])
98
+ rescue Flodesk::PartialFailureError => e
99
+ e.result.successes # => [Subscriber]
100
+ e.result.failures.first.code # => "invalid_email"
101
+ e.result.failures.first.index # => 1, the position in your input
102
+ e.result.failed_emails # ready to retry
103
+ end
104
+ ```
105
+
106
+ Prefer to inspect rather than rescue? Opt out explicitly:
107
+
108
+ ```ruby
109
+ result = client.subscribers.batch_upsert(rows, raise_on_failure: false)
110
+ result.success? # => false
111
+ result.failures
112
+ ```
113
+
114
+ ## Listing and pagination
115
+
116
+ `list` issues exactly one request and returns a `Page`:
117
+
118
+ ```ruby
119
+ page = client.subscribers.list(page: 2, per_page: 100, status: :active)
120
+
121
+ page.items # => [Subscriber]
122
+ page.total_items
123
+ page.more_pages?
124
+ page.each { |s| ... } # this page only — no further requests
125
+ ```
126
+
127
+ To walk everything, opt in explicitly. This is *not* the behavior of `each`, because traversing a large list can consume your entire 100 requests/minute budget — a cost that should be visible at the call site:
128
+
129
+ ```ruby
130
+ client.subscribers.auto_paging_each do |subscriber|
131
+ # ...
132
+ end
133
+
134
+ # Lazy: fetches only what it needs.
135
+ client.subscribers.auto_paging_each.first(10)
136
+ ```
137
+
138
+ ## Segments, custom fields, workflows
139
+
140
+ ```ruby
141
+ client.segments.list
142
+ client.segments.retrieve("seg_123")
143
+ client.segments.create(name: "VIPs", color: "#ffeecc")
144
+ client.segments.colors
145
+
146
+ client.custom_fields.list # paginated
147
+ client.custom_fields.list_all # every field, unpaginated
148
+ client.custom_fields.create(label: "Favorite colour")
149
+
150
+ client.workflows.list(statuses: [:active, :paused])
151
+ client.workflows.add_subscriber("wf_123", email: "ada@example.com")
152
+ client.workflows.remove_subscriber("wf_123", "ada@example.com")
153
+ ```
154
+
155
+ ## Campaigns
156
+
157
+ ```ruby
158
+ client.campaigns.list(search: "spring", status: :draft, order_by: "created_at")
159
+
160
+ client.campaigns.publish_canva(bundle_url: "https://...", title: "Spring")
161
+ client.campaigns.publish_studio(html: "<html>...</html>", title: "Spring")
162
+ ```
163
+
164
+ > **Maturity caveat.** The campaign publishing endpoints (`publish_canva`, `publish_studio`, `canva_design_state`) cannot be safely exercised against a live account during development, so they are covered only by specification-derived stubs and are less battle-tested than the subscriber and segment operations.
165
+
166
+ > **`publish_canva` and `publish_studio` are never retried** — not on `5xx`, not on a timeout, not even on `429`. They publish an email campaign, and no response code proves the campaign was *not* accepted. A retry could send it to your entire list a second time, which is unrecoverable and visible to every recipient. Failures are surfaced for a human to decide.
167
+ >
168
+ > Both are documented upstream as publishing a *draft*, which is a weaker hazard than an immediate send. The policy deliberately does not lean on that: "draft" is a one-line summary in the API description, not a guarantee, and the mistake it would license cannot be undone.
169
+
170
+ ## Errors
171
+
172
+ Everything descends from `Flodesk::Error`, so you can rescue broadly or narrowly:
173
+
174
+ ```ruby
175
+ begin
176
+ client.subscribers.retrieve("nope")
177
+ rescue Flodesk::NotFoundError => e
178
+ e.status # => 404
179
+ e.code # => "not_found"
180
+ e.message
181
+ e.raw_body # for debugging an unexpected shape
182
+ end
183
+ ```
184
+
185
+ | Class | Cause |
186
+ | --- | --- |
187
+ | `Flodesk::BadRequestError` | `400` — your payload was rejected. Never retried |
188
+ | `Flodesk::AuthenticationError` | `401`/`403` |
189
+ | `Flodesk::NotFoundError` | `404` |
190
+ | `Flodesk::RateLimitError` | `429` |
191
+ | `Flodesk::ServerError` | `5xx` |
192
+ | `Flodesk::TimeoutError` | connect or read timeout |
193
+ | `Flodesk::ConnectionError` | the request never completed |
194
+ | `Flodesk::PartialFailureError` | a batch reported per-record failures |
195
+
196
+ The API declares no error-body schema anywhere; the `{code, message}` envelope this gem parses was established by probing the live API. Parsing therefore degrades gracefully — an HTML body from an edge proxy still raises a typed error carrying the raw response.
197
+
198
+ ## Rate limits and retries
199
+
200
+ | Endpoint | Limit |
201
+ | --- | --- |
202
+ | Everything (default) | 100 requests/minute |
203
+ | `POST /subscribers/batch` | 20 requests/minute (≤50 subscribers each) |
204
+
205
+ Flodesk returns `X-Fd-RateLimit-Limit` and `X-Fd-RateLimit-Remaining` but **no reset header**, so there is no correct wait time to compute. Backoff is exponential with jitter, capped at 60 seconds, and is a documented heuristic — **this gem does not promise to keep you within quota.** Check the observed state if you need to pace a bulk job yourself:
206
+
207
+ ```ruby
208
+ client.rate_limit&.remaining # => 68
209
+ ```
210
+
211
+ (Rate-limit state is per-thread, since a shared frozen client cannot hold mutable state.)
212
+
213
+ Which operations get retried:
214
+
215
+ | Safe to retry | Never retried |
216
+ | --- | --- |
217
+ | `POST /subscribers` (upsert) | `POST /segments` (creates) |
218
+ | `POST /subscribers/batch` (upsert) | `POST /custom-fields` (creates) |
219
+ | `POST .../segments` (idempotent add) | `POST /webhooks` (creates) |
220
+ | `POST .../unsubscribe` (terminal state) | `POST /campaigns/canva` (**publishes**) |
221
+ | `POST /workflows/.../subscribers` | `POST /campaigns/studio` (**publishes**) |
222
+ | every `GET`, `PUT`, `DELETE` | |
223
+
224
+ ## Webhooks
225
+
226
+ > **Flodesk does not sign webhooks.** The API description declares `security: []` on all three events — there is no signature header to verify. Anyone who learns your callback URL can forge a `subscriber.created` event.
227
+
228
+ Because of that, **verification is mandatory**: constructing a handler without choosing a strategy raises rather than defaulting to trust.
229
+
230
+ ### Strategy 1 — token in the callback path
231
+
232
+ Cheap, no extra API call.
233
+
234
+ ```ruby
235
+ # One-time: generate and store a token, then register the webhook.
236
+ token = Flodesk::Webhooks::Handler.generate_token
237
+
238
+ client.webhooks.create(
239
+ name: "My app",
240
+ post_url: "https://app.example.com/flodesk/#{token}",
241
+ events: ["subscriber.created"]
242
+ )
243
+ ```
244
+
245
+ ```ruby
246
+ # config/routes.rb
247
+ post "/flodesk/:token", to: "flodesk_webhooks#create"
248
+
249
+ class FlodeskWebhooksController < ApplicationController
250
+ skip_before_action :verify_authenticity_token
251
+
252
+ HANDLER = Flodesk::Webhooks::Handler.new(
253
+ token: Rails.application.credentials.flodesk_webhook_token
254
+ )
255
+
256
+ def create
257
+ status, _headers, body = HANDLER.respond(
258
+ body: request.raw_post,
259
+ token: params[:token],
260
+ on_error: ->(e) { Rails.logger.error("flodesk webhook failed: #{e.class}") }
261
+ ) do |event|
262
+ SyncSubscriberJob.perform_later(event.subscriber.id) unless seen?(event.dedupe_key)
263
+ end
264
+
265
+ render plain: body.join, status: status
266
+ end
267
+ end
268
+ ```
269
+
270
+ ⚠️ **The token appears in your logs.** It is a path segment, so it lands in Rails request logs and your web server's access logs. Filter or silence that route before deploying, or use strategy 2 if you'd rather not put a secret in a URL at all.
271
+
272
+ ### Strategy 2 — re-fetch (strongest)
273
+
274
+ Treats the payload as an untrusted *hint*: takes only the subscriber id and reads the authoritative record back from the API. Forgery-proof, but costs one API call per event against your 100/minute budget.
275
+
276
+ ```ruby
277
+ HANDLER = Flodesk::Webhooks::Handler.new(verify: :refetch, client: FLODESK)
278
+
279
+ event = HANDLER.call(body: request.raw_post)
280
+ event.subscriber.email # from the API, not from the payload
281
+ ```
282
+
283
+ IP allowlisting is not an option — Flodesk publishes no ranges.
284
+
285
+ ### Replay protection is yours
286
+
287
+ The event schemas define **no unique event id**, so the gem exposes a composed, SHA-256 dedupe key and leaves storage to you — only your app has a database:
288
+
289
+ ```ruby
290
+ event.dedupe_key # stable across identical deliveries
291
+ event.known? # false for an event name the gem doesn't recognize yet
292
+ event.to_h # raw payload, so a new Flodesk event stays usable
293
+ ```
294
+
295
+ Respond with any 2xx to acknowledge. `#respond` maps failures to non-2xx (401 unverified, 400 unparseable, 500 if your block raises) so Flodesk retries rather than considering the event delivered.
296
+
297
+ ### PII in webhook payloads
298
+
299
+ Events embed `email` **and** `optin_ip`. The gem never logs payload contents, redacts sensitive fields from instrumentation, and keeps PII out of `Event#inspect` — but what your own handler logs is up to you.
300
+
301
+ ## Instrumentation
302
+
303
+ When ActiveSupport is present, every request attempt emits `flodesk.request`:
304
+
305
+ ```ruby
306
+ ActiveSupport::Notifications.subscribe("flodesk.request") do |*args|
307
+ event = ActiveSupport::Notifications::Event.new(*args)
308
+ event.payload
309
+ # => { method: "GET", endpoint: "/subscribers/[REDACTED]", status: 200,
310
+ # duration: 0.08, attempt: 1, rate_limit_remaining: 68 }
311
+ end
312
+ ```
313
+
314
+ One event per *attempt*, so retries are visible. Payloads carry no request body, no response body, no API key, and any email embedded in a path is redacted.
315
+
316
+ ## Testing
317
+
318
+ ```ruby
319
+ require "flodesk/test_helpers"
320
+
321
+ RSpec.configure { |c| c.include Flodesk::TestHelpers }
322
+
323
+ stub_flodesk_upsert(email: "ada@example.com")
324
+ stub_flodesk_error(:get, "/subscribers/nope", status: 404)
325
+ stub_flodesk_batch(failures: [flodesk_batch_failure(index: 1)])
326
+ ```
327
+
328
+ Fixture payloads follow the API description rather than whatever your code expects, so a stub can't drift into hiding a broken integration.
329
+
330
+ ## What this gem does not do
331
+
332
+ - **OAuth2 / partner integrations.** API-key auth only. The OAuth2 flow needs token storage, expiry, and single-use refresh-token rotation whose concurrent-refresh race requires locking — a subsystem, not a feature. An auth seam is in place so it can be added without a breaking change.
333
+ - **ActiveRecord-style persistence.** No `save!`, dirty tracking, or lazy associations. `POST /subscribers` is upsert-only and never reports create-vs-update, so those semantics would be fiction, and lazy associations would hide N+1 HTTP calls.
334
+ - **Code generation.** The OpenAPI description is vendored as a *test oracle* instead: the suite asserts every documented operation has a client method, that declared parameters are sent, and that every documented status maps to an error class. Dropping in a newer spec fails the build when Flodesk adds an endpoint.
335
+
336
+ ## Development
337
+
338
+ ```bash
339
+ bin/setup
340
+ bundle exec rspec
341
+ bundle exec rubocop
342
+ bundle exec rbs -I sig validate
343
+ ```
344
+
345
+ ## Contributing
346
+
347
+ Bug reports and pull requests are welcome at <https://github.com/mymat-yoga/flodesk-rb>.
348
+
349
+ ## License
350
+
351
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Authentication strategies.
5
+ #
6
+ # Only API-key (HTTP Basic) auth is implemented. This seam exists so OAuth2
7
+ # bearer tokens can be added later without changing Connection: partner
8
+ # integrations need an authorization-code flow with token storage, expiry, and
9
+ # single-use refresh-token rotation, which is a subsystem rather than a
10
+ # feature and is deliberately out of scope for v1.
11
+ module Auth
12
+ # Flodesk expects the API key as the HTTP Basic username with an empty
13
+ # password.
14
+ class ApiKey
15
+ def initialize(api_key)
16
+ @api_key = api_key
17
+ freeze
18
+ end
19
+
20
+ def apply(request)
21
+ request.basic_auth(@api_key, "")
22
+ request
23
+ end
24
+
25
+ # Never interpolate the key into logs or inspect output.
26
+ def inspect
27
+ "#<#{self.class.name} api_key=[REDACTED]>"
28
+ end
29
+ alias to_s inspect
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Entry point to the Flodesk API.
5
+ #
6
+ # client = Flodesk::Client.new(
7
+ # api_key: ENV.fetch("FLODESK_API_KEY"),
8
+ # app_name: "MyApp (myapp.com)"
9
+ # )
10
+ # client.subscribers.upsert(email: "a@b.com")
11
+ #
12
+ # There is deliberately no global configuration: per-tenant API keys stay
13
+ # trivial, and no process-wide state can leak between tests. Instances are
14
+ # frozen at construction, so a client assigned to a constant is safe to share
15
+ # across request threads.
16
+ class Client
17
+ DEFAULT_OPEN_TIMEOUT = 5
18
+ DEFAULT_READ_TIMEOUT = 15
19
+ DEFAULT_MAX_RETRIES = 2
20
+ DEFAULT_BACKOFF_BASE = 0.5
21
+
22
+ attr_reader :api_key, :app_name, :base_url, :open_timeout, :read_timeout,
23
+ :max_retries, :backoff_base, :auth,
24
+ :subscribers, :segments, :custom_fields, :workflows, :webhooks, :campaigns
25
+
26
+ def initialize(api_key: nil, app_name: nil, base_url: DEFAULT_BASE_URL,
27
+ open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
28
+ max_retries: DEFAULT_MAX_RETRIES, backoff_base: DEFAULT_BACKOFF_BASE)
29
+ # Validate at construction rather than on first request, so a
30
+ # misconfigured initializer fails at boot instead of in a background job.
31
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
32
+ raise ArgumentError, "max_retries must be >= 0" if max_retries.negative?
33
+
34
+ @api_key = api_key.to_s.dup.freeze
35
+ @app_name = app_name.nil? ? nil : app_name.to_s.dup.freeze
36
+ @base_url = base_url.to_s.sub(%r{/+\z}, "").dup.freeze
37
+ @open_timeout = open_timeout
38
+ @read_timeout = read_timeout
39
+ @max_retries = max_retries
40
+ @backoff_base = backoff_base
41
+ @auth = Auth::ApiKey.new(@api_key)
42
+ @connection = Connection.new(self)
43
+
44
+ build_resources
45
+ freeze
46
+ end
47
+
48
+ # Flodesk's documentation asks integrations to identify themselves.
49
+ def user_agent
50
+ [@app_name, "flodesk/#{VERSION}"].compact.join(" ")
51
+ end
52
+
53
+ # Rate-limit state from this thread's most recent request, or nil.
54
+ # Only `remaining` is actionable: the API sends no reset header.
55
+ def rate_limit
56
+ RateLimit.last(self)
57
+ end
58
+
59
+ # @api private
60
+ def request(...)
61
+ @connection.request(...)
62
+ end
63
+
64
+ # Never leak the API key through inspect, which Rails prints in consoles and
65
+ # error pages.
66
+ def inspect
67
+ "#<#{self.class.name} base_url=#{@base_url.inspect} api_key=[REDACTED]>"
68
+ end
69
+ alias to_s inspect
70
+
71
+ private
72
+
73
+ # Built eagerly rather than memoized lazily: these are tiny, and a frozen
74
+ # client shared across threads must not mutate a memo hash concurrently.
75
+ def build_resources
76
+ @subscribers = Resources::Subscribers.new(self)
77
+ @segments = Resources::Segments.new(self)
78
+ @custom_fields = Resources::CustomFields.new(self)
79
+ @workflows = Resources::Workflows.new(self)
80
+ @webhooks = Resources::Webhooks.new(self)
81
+ @campaigns = Resources::Campaigns.new(self)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Flodesk
6
+ # Turns raw JSON values into useful Ruby ones.
7
+ #
8
+ # Every coercion here passes unrecognized input through unchanged rather than
9
+ # raising. Flodesk can add an enum member or change a format at any time, and
10
+ # a client that raises on an unfamiliar value would break working code for a
11
+ # change that does not actually affect the caller.
12
+ module Coercion
13
+ module_function
14
+
15
+ # Converts a documented enum value to a Symbol. Values outside `allowed`
16
+ # are returned as-is, so a newly introduced Flodesk value cannot break an
17
+ # existing caller.
18
+ def enum(value, allowed)
19
+ return nil if value.nil?
20
+
21
+ allowed.include?(value.to_s) ? value.to_s.to_sym : value
22
+ end
23
+
24
+ # Parses an ISO 8601 timestamp to a Time, passing unparseable input through.
25
+ def time(value)
26
+ return nil if value.nil?
27
+ return value unless value.is_a?(String)
28
+
29
+ begin
30
+ Time.iso8601(value)
31
+ rescue ArgumentError
32
+ value
33
+ end
34
+ end
35
+
36
+ # Builds an array of value objects, tolerating a missing or empty list.
37
+ def array_of(klass, value)
38
+ return [] unless value.is_a?(Array)
39
+
40
+ value.filter_map { |item| klass.from(item) }.freeze
41
+ end
42
+
43
+ # Custom field values are typed `string` throughout the API, so keys and
44
+ # values pass through intact.
45
+ def string_hash(value)
46
+ return {} unless value.is_a?(Hash)
47
+
48
+ snapshot(value)
49
+ end
50
+
51
+ # An immutable copy of a payload.
52
+ #
53
+ # Copied rather than frozen in place: `.from` is public, so freezing the
54
+ # argument would be a side effect on data the caller still owns and could
55
+ # break code that reuses the hash afterwards. The copy also means a later
56
+ # caller mutation cannot change what the value object reports.
57
+ #
58
+ # Recursive, so nested hashes and arrays are copied and frozen too: a
59
+ # top-level-only copy would still hand back references the caller could
60
+ # mutate underneath a supposedly immutable object.
61
+ def snapshot(value)
62
+ case value
63
+ when Hash then value.transform_values { |v| snapshot(v) }.freeze
64
+ when Array then value.map { |v| snapshot(v) }.freeze
65
+ else value
66
+ end
67
+ end
68
+ end
69
+ end