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
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
data/lib/flodesk/auth.rb
ADDED
|
@@ -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
|