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,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
|