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
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # A workflow.
5
+ #
6
+ # The API description declares only `id` and `name`. The list endpoint accepts
7
+ # a `statuses` filter, but the response schema exposes no status field, so any
8
+ # status the API happens to return is reachable through {#to_h}.
9
+ Workflow = Data.define(:id, :name, :raw) do
10
+ def self.from(payload)
11
+ return nil if payload.nil?
12
+
13
+ new(id: payload["id"], name: payload["name"], raw: Coercion.snapshot(payload))
14
+ end
15
+
16
+ # The payload exactly as the API sent it, including any field this gem does
17
+ # not declare.
18
+ def to_h
19
+ raw
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Rails
5
+ # Registers the gem's Rails integration.
6
+ #
7
+ # Deliberately minimal: it makes the generator discoverable and nothing else.
8
+ # There is no initializer hook that configures the gem, because the gem holds
9
+ # no global state — `rails g flodesk:install` scaffolds a client constant in
10
+ # the host application instead.
11
+ class Railtie < ::Rails::Railtie
12
+ railtie_name "flodesk"
13
+
14
+ generators do
15
+ require_relative "../../generators/flodesk/install_generator"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional Rails integration.
4
+ #
5
+ # Loaded automatically when Rails is present, and never otherwise: the gem must
6
+ # work in a plain Ruby process and declares no runtime dependency on Rails or
7
+ # ActiveSupport.
8
+ require_relative "rails/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Records the rate-limit state observed on the most recent response.
5
+ #
6
+ # Storage is thread-local by design. A client is frozen and shared across
7
+ # threads, so it cannot hold this as mutable state; and a caller pacing itself
8
+ # wants the limit state for *its own* requests, not whatever another thread
9
+ # last saw.
10
+ #
11
+ # Flodesk sends no reset header, so `remaining` is the only actionable signal
12
+ # available and the gem cannot guarantee staying within quota.
13
+ module RateLimit
14
+ State = Data.define(:limit, :remaining)
15
+
16
+ def self.record(client, response)
17
+ limit = response.rate_limit
18
+ remaining = response.rate_limit_remaining
19
+ return if limit.nil? && remaining.nil?
20
+
21
+ Thread.current[key(client)] = State.new(limit: limit, remaining: remaining)
22
+ end
23
+
24
+ # The rate-limit state from this thread's most recent request through
25
+ # `client`, or nil if none has been observed.
26
+ def self.last(client)
27
+ Thread.current[key(client)]
28
+ end
29
+
30
+ def self.key(client)
31
+ :"flodesk_rate_limit_#{client.object_id}"
32
+ end
33
+ private_class_method :key
34
+ end
35
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Strips personally identifiable information from anything the gem logs or
5
+ # instruments.
6
+ #
7
+ # Two facts make this necessary rather than optional. Several paths accept an
8
+ # email address in place of an id, so the request path itself can carry PII.
9
+ # And webhook payloads embed both `email` and `optin_ip`.
10
+ module Redaction
11
+ # Fields that must never appear in log or instrumentation output.
12
+ SENSITIVE_KEYS = %w[email custom_fields optin_ip].freeze
13
+
14
+ PLACEHOLDER = "[REDACTED]"
15
+
16
+ # A path segment holding an email address, standing in for an id.
17
+ #
18
+ # Matches both the literal "@" and its percent-encoded form. By the time a
19
+ # path reaches here the segment has usually been encoded already, so
20
+ # checking only for "@" would let `a%40b.com` through — which is exactly how
21
+ # an email address ends up in a log.
22
+ EMAIL_IN_SEGMENT = /@|%40/i
23
+
24
+ def self.path(path)
25
+ path.to_s
26
+ .split("/")
27
+ .map { |segment| segment.match?(EMAIL_IN_SEGMENT) ? PLACEHOLDER : segment }
28
+ .join("/")
29
+ end
30
+
31
+ # Recursively removes sensitive values, preserving structure so the shape of
32
+ # a payload stays debuggable.
33
+ def self.payload(value)
34
+ case value
35
+ when Hash
36
+ value.to_h do |k, v|
37
+ SENSITIVE_KEYS.include?(k.to_s) ? [k, PLACEHOLDER] : [k, payload(v)]
38
+ end
39
+ when Array
40
+ value.map { |v| payload(v) }
41
+ else
42
+ value
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Resources
5
+ # Shared behavior for resource namespaces.
6
+ #
7
+ # Subclasses stay thin: they translate idiomatic Ruby arguments into the
8
+ # shape each endpoint wants, declare whether the operation is safe to retry,
9
+ # and hand the parsed body to a value object.
10
+ class Base
11
+ # The API rejects `per_page` above 100.
12
+ MAX_PER_PAGE = 100
13
+
14
+ # Characters left unescaped in a path segment, per RFC 3986 "unreserved".
15
+ UNRESERVED = /[^A-Za-z0-9\-._~]/n
16
+
17
+ def initialize(client)
18
+ @client = client
19
+ freeze
20
+ end
21
+
22
+ private
23
+
24
+ def get(path, query: nil)
25
+ @client.request(:get, path, query: query, idempotent: true).body
26
+ end
27
+
28
+ def post(path, body: nil, query: nil, idempotent: false, retry_rate_limit: true)
29
+ @client.request(
30
+ :post, path,
31
+ body: body, query: query, idempotent: idempotent, retry_rate_limit: retry_rate_limit
32
+ ).body
33
+ end
34
+
35
+ def put(path, body: nil)
36
+ # PUT replaces the resource's state, so repeating it is safe.
37
+ @client.request(:put, path, body: body, idempotent: true).body
38
+ end
39
+
40
+ def delete(path, body: nil)
41
+ @client.request(:delete, path, body: body, idempotent: true).body
42
+ end
43
+
44
+ # Fetches one page and wraps it in a Page.
45
+ #
46
+ # The Page is given a fetcher closing over this call's path, filters and
47
+ # page size, which is what lets `auto_paging_each` walk forward without
48
+ # the caller restating any of it.
49
+ def paginated_list(path, klass:, page: nil, per_page: nil,
50
+ per_page_key: "per_page", filters: {})
51
+ query = pagination(page: page, per_page: per_page, per_page_key: per_page_key)
52
+ .merge(filters.compact)
53
+
54
+ Page.from(
55
+ get(path, query: query),
56
+ klass: klass,
57
+ fetcher: lambda { |next_page|
58
+ paginated_list(
59
+ path, klass: klass, page: next_page, per_page: per_page,
60
+ per_page_key: per_page_key, filters: filters
61
+ )
62
+ }
63
+ )
64
+ end
65
+
66
+ # Walks every page of a list operation, fetching each on demand.
67
+ #
68
+ # Returns a lazy Enumerator when no block is given, so nothing is
69
+ # requested until iteration begins and each traversal re-fetches.
70
+ def each_page_item(path, **, &block)
71
+ return to_enum(:each_page_item, path, **) unless block
72
+
73
+ paginated_list(path, **).auto_paging_each(&block)
74
+ end
75
+
76
+ # Builds the pagination query for an endpoint, translating the uniform
77
+ # caller-facing arguments into whatever spelling the endpoint expects.
78
+ # Most take `per_page`; GET /workflows takes `perPage`.
79
+ def pagination(page:, per_page:, per_page_key: "per_page")
80
+ validate_per_page!(per_page)
81
+
82
+ query = {}
83
+ query["page"] = page unless page.nil?
84
+ query[per_page_key] = per_page unless per_page.nil?
85
+ query
86
+ end
87
+
88
+ def validate_per_page!(per_page)
89
+ return if per_page.nil?
90
+
91
+ return if per_page.is_a?(Integer) && per_page.between?(1, MAX_PER_PAGE)
92
+
93
+ raise ArgumentError,
94
+ "per_page must be an Integer between 1 and #{MAX_PER_PAGE}, got #{per_page.inspect}"
95
+ end
96
+
97
+ # Validates a value against a closed enum from the API description.
98
+ # Filters are rejected before a request is made, since the API would only
99
+ # reject them after a round trip.
100
+ def validate_enum!(name, value, allowed)
101
+ return nil if value.nil?
102
+
103
+ normalized = value.to_s
104
+ unless allowed.include?(normalized)
105
+ raise ArgumentError,
106
+ "#{name} must be one of #{allowed.join(", ")}, got #{value.inspect}"
107
+ end
108
+
109
+ normalized
110
+ end
111
+
112
+ # Path segments may be an id or an email address, and several endpoints
113
+ # accept either. Emails need percent-encoding so "+" survives the round
114
+ # trip rather than being read as a space.
115
+ def encode_segment(value)
116
+ raise ArgumentError, "identifier cannot be blank" if value.nil? || value.to_s.empty?
117
+
118
+ value.to_s.b.gsub(UNRESERVED) { |c| format("%%%02X", c.ord) }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Resources
5
+ # Operations under the `Campaign` tag of the Flodesk API.
6
+ #
7
+ # These endpoints cannot be safely exercised against a live account during
8
+ # development, so they are covered only by specification-derived stubs and
9
+ # are less battle-tested than the subscriber and segment operations.
10
+ class Campaigns < Base
11
+ PATH = "/campaigns"
12
+
13
+ # This endpoint's filters are PascalCase, unlike anything else in the API.
14
+ # Its pagination parameters, confusingly, remain snake_case. Callers use
15
+ # idiomatic snake_case throughout and this map does the translating.
16
+ FILTER_KEYS = {
17
+ search: "Search",
18
+ order_by: "OrderBy",
19
+ sort: "Sort",
20
+ status: "Status",
21
+ shared_as_template: "SharedAsTemplate"
22
+ }.freeze
23
+
24
+ # GET /campaigns
25
+ def list(page: nil, per_page: nil, **filters)
26
+ paginated_list(
27
+ PATH, klass: Campaign, page: page, per_page: per_page,
28
+ filters: list_filters(**filters)
29
+ )
30
+ end
31
+
32
+ # Walks every campaign across all pages, fetching each page on demand.
33
+ def auto_paging_each(page: nil, per_page: nil, **filters, &)
34
+ each_page_item(
35
+ PATH, klass: Campaign, page: page, per_page: per_page,
36
+ filters: list_filters(**filters), &
37
+ )
38
+ end
39
+
40
+ # POST /campaigns/canva — publishes an email campaign. Returns 201.
41
+ #
42
+ # ---------------------------------------------------------------------
43
+ # NEVER RETRIED. NOT ON 5xx, NOT ON TIMEOUT, NOT ON 429.
44
+ #
45
+ # This operation sends an email campaign to the subscriber list. A retry
46
+ # can deliver it a second time, which is unrecoverable and visible to
47
+ # every recipient. No response code — including a timeout or a 429 —
48
+ # proves the campaign was not accepted, so the only safe policy is to
49
+ # surface the failure and let a human decide.
50
+ # ---------------------------------------------------------------------
51
+ #
52
+ # Returns the raw response body: the `PublishCanvaRes` schema declares
53
+ # only `id` and `url`, which is not a Campaign.
54
+ def publish_canva(bundle_url: nil, title: nil, design_token: nil,
55
+ page_id: nil, campaign_id: nil)
56
+ post(
57
+ "#{PATH}/canva",
58
+ body: {
59
+ "bundle_url" => bundle_url,
60
+ "title" => title,
61
+ "design_token" => design_token,
62
+ "page_id" => page_id,
63
+ "campaign_id" => campaign_id
64
+ }.compact,
65
+ idempotent: false,
66
+ retry_rate_limit: false
67
+ )
68
+ end
69
+
70
+ # POST /campaigns/studio — publishes an email campaign. Returns 201.
71
+ #
72
+ # ---------------------------------------------------------------------
73
+ # NEVER RETRIED. Same policy as {#publish_canva}, for the same reason.
74
+ #
75
+ # Both endpoints are summarized upstream as publishing a *draft*, which
76
+ # is a weaker hazard than an immediate send. The policy does not lean on
77
+ # that distinction: "draft" is a one-line summary in the specification,
78
+ # not a guarantee, and the failure it would license is unrecoverable and
79
+ # visible to every recipient. Retrying is the bet that cannot be unmade,
80
+ # so it is not taken.
81
+ # ---------------------------------------------------------------------
82
+ #
83
+ # Returns the raw response body: the `PublishStudioRes` schema declares
84
+ # only `id` and `url`, which is not a Campaign.
85
+ def publish_studio(html: nil, title: nil, campaign_id: nil, asset_id: nil)
86
+ post(
87
+ "#{PATH}/studio",
88
+ body: {
89
+ "html" => html,
90
+ "title" => title,
91
+ "campaign_id" => campaign_id,
92
+ "asset_id" => asset_id
93
+ }.compact,
94
+ idempotent: false,
95
+ retry_rate_limit: false
96
+ )
97
+ end
98
+
99
+ # GET /campaigns/canva/design-state
100
+ #
101
+ # Returns the raw response body: `CanvaDesignStateRes` declares only
102
+ # `design_id` and `campaign_id`.
103
+ def canva_design_state
104
+ get("#{PATH}/canva/design-state")
105
+ end
106
+
107
+ private
108
+
109
+ def list_filters(search: nil, order_by: nil, sort: nil, status: nil,
110
+ shared_as_template: nil)
111
+ {
112
+ FILTER_KEYS[:search] => search,
113
+ FILTER_KEYS[:order_by] => order_by,
114
+ FILTER_KEYS[:sort] => sort,
115
+ FILTER_KEYS[:status] => validate_enum!("status", status, Enums::CAMPAIGN_STATUSES),
116
+ FILTER_KEYS[:shared_as_template] => shared_as_template
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Resources
5
+ # Operations under the `Custom Field` tag of the Flodesk API.
6
+ #
7
+ # Custom fields are identified by `key`, which the API derives from the
8
+ # `label` given at creation. Values stored against a field are always
9
+ # strings; see {Subscribers#upsert} for how non-string values are handled.
10
+ class CustomFields < Base
11
+ PATH = "/custom-fields"
12
+
13
+ # GET /custom-fields — paginated.
14
+ #
15
+ # Distinct from {#list_all}, which returns every field in one unpaginated
16
+ # response. The two endpoints are easy to confuse.
17
+ def list(page: nil, per_page: nil)
18
+ paginated_list(PATH, klass: CustomField, page: page, per_page: per_page)
19
+ end
20
+
21
+ # Walks every custom field across all pages, fetching each on demand.
22
+ def auto_paging_each(page: nil, per_page: nil, &)
23
+ each_page_item(PATH, klass: CustomField, page: page, per_page: per_page, &)
24
+ end
25
+
26
+ # GET /custom-fields/all — every field in one response.
27
+ #
28
+ # Returns a plain Array rather than a {Page}, because this endpoint is not
29
+ # paginated and has no `meta` block to report.
30
+ def list_all
31
+ body = get("#{PATH}/all")
32
+
33
+ Coercion.array_of(CustomField, body.is_a?(Hash) ? body["data"] : body)
34
+ end
35
+
36
+ # POST /custom-fields — returns 201 with the created field.
37
+ #
38
+ # NOT idempotent: this creates a new record and the API offers no
39
+ # idempotency key, so a retry could leave a duplicate field behind.
40
+ def create(label:)
41
+ raise ArgumentError, "label is required" if label.nil? || label.to_s.empty?
42
+
43
+ CustomField.from(post(PATH, body: { "label" => label.to_s }, idempotent: false))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Resources
5
+ # Operations under the `Segment` tag of the Flodesk API.
6
+ class Segments < Base
7
+ PATH = "/segments"
8
+
9
+ # GET /segments
10
+ def list(page: nil, per_page: nil)
11
+ paginated_list(PATH, klass: Segment, page: page, per_page: per_page)
12
+ end
13
+
14
+ # Walks every segment across all pages, fetching each page on demand.
15
+ def auto_paging_each(page: nil, per_page: nil, &)
16
+ each_page_item(PATH, klass: Segment, page: page, per_page: per_page, &)
17
+ end
18
+
19
+ # GET /segments/{id}
20
+ def retrieve(id)
21
+ Segment.from(get("#{PATH}/#{encode_segment(id)}"))
22
+ end
23
+
24
+ # POST /segments — returns 201 with the created segment.
25
+ #
26
+ # NOT idempotent. This endpoint creates a new record and the API offers no
27
+ # idempotency key, so a retry after a timeout or 5xx would leave a second,
28
+ # duplicate segment behind. Failures are surfaced to the caller instead.
29
+ def create(name:, color: nil)
30
+ raise ArgumentError, "name is required" if name.nil? || name.to_s.empty?
31
+
32
+ Segment.from(
33
+ post(
34
+ PATH,
35
+ body: { "name" => name.to_s, "color" => color }.compact,
36
+ idempotent: false
37
+ )
38
+ )
39
+ end
40
+
41
+ # GET /segments/colors — the palette available for segment colors.
42
+ #
43
+ # The only error this endpoint documents is 401.
44
+ def colors
45
+ get("#{PATH}/colors")
46
+ end
47
+ end
48
+ end
49
+ end