gibbon 4.0.0 → 4.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cb1352737a9335535ba7d79420ec208817700b6e8b4fcf07a449499837b721f
4
- data.tar.gz: 5fd15b6c055607e95a2e81f8b4fda95b606dc6be3ac7aa147057acd13c946a1a
3
+ metadata.gz: 421fe35f5c093158bac5d59d0a684325e8b77888257b6758fd805d9c86608520
4
+ data.tar.gz: cd15c1fa018171363d7fe32c6dddbe14e14985ed02ca1cbdcb313240e39c1640
5
5
  SHA512:
6
- metadata.gz: 20bd382a94c31402452f63037c0f266cdb291493b72b93b1aab2c15021fb108fbf9a45b4ced1f3070c2c2a18aaee3bd117ef98dc4b8846b3a07f44ac042c864f
7
- data.tar.gz: a030570ce92ecd7744ee1ed8dead15f0a176e3b178fd0b410e4c11a870f90ebbc0ba5909a82ae59b55959cf1ae3c06f0fd612826b5fdf93a26954907aba96729
6
+ metadata.gz: 0da6bfbdd8f9bff5ffbeac3943f3c3f571204b9a38869dfa2e75cba7bc1ba2863aa7dcdcb82e3167b54ed9dd0222d118a7b74606b2d7d8f92ec8fc8681529d24
7
+ data.tar.gz: 0ff34fa92007f022b7faf7368cf1285ed995efa43e964b0328928e8c466bc1661d0cdc0a32f0fed612e4cb5d54deaba74dfdfecae5dc264e4215ddb4bfc39ddf
data/.document CHANGED
@@ -1,5 +1,5 @@
1
1
  lib/**/*.rb
2
- bin/*
3
- -
4
- features/**/*.feature
2
+ -
3
+ README.markdown
4
+ CHANGELOG.md
5
5
  LICENSE.txt
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ ruby: ['3.1', '3.2', '3.3', '3.4', '4.0']
12
+ steps:
13
+ - uses: actions/checkout@v5
14
+ - uses: ruby/setup-ruby@v1
15
+ with:
16
+ ruby-version: ${{ matrix.ruby }}
17
+ bundler-cache: true
18
+ - run: bundle exec rake
data/.yardopts ADDED
@@ -0,0 +1,7 @@
1
+ --markup markdown
2
+ --readme README.markdown
3
+ --no-private
4
+ lib/**/*.rb
5
+ -
6
+ CHANGELOG.md
7
+ LICENSE.txt
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## [4.0.1] - 2026-10-23
2
+ - Honor explicit `false` instance overrides for `symbolize_keys` and `debug`, while preserving class defaults for omitted options and `nil`.
3
+ - Constrain `json` to versions below 3.0 to preserve compatibility with the `multi_json` adapter available on Ruby 3.1.
4
+ - Expand API documentation, update usage examples, and add gem metadata links.
5
+
1
6
  ## [4.0.0] - 2026-07-23
2
7
  - Require Ruby 3.1+ (drops support for EOL Rubies 2.4 through 3.0)
3
8
  - Require TLS 1.2 as a minimum instead of pinning it exactly, allowing TLS 1.3
data/README.markdown CHANGED
@@ -2,21 +2,32 @@
2
2
 
3
3
  Gibbon is an API wrapper for MailChimp's [API](https://mailchimp.com/developer/marketing/api/).
4
4
 
5
- [![Build Status](https://app.travis-ci.com/amro/gibbon.svg?branch=master)](https://app.travis-ci.com/github/amro/gibbon)
5
+ [![Build Status](https://github.com/amro/gibbon/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/amro/gibbon/actions/workflows/ci.yml)
6
6
 
7
7
  ## Important Notes
8
8
 
9
9
  Please read MailChimp's [Getting Started Guide](https://mailchimp.com/developer/marketing/guides/quick-start/).
10
10
 
11
- Gibbon 3.0.0+ returns a `Gibbon::Response` instead of the response body directly. `Gibbon::Response` exposes the parsed response `body` and `headers`.
11
+ Gibbon returns a `Gibbon::Response`, which exposes the parsed response `body` and the response `headers`. See [Responses](#responses) below.
12
+
13
+ See the [CHANGELOG](CHANGELOG.md) for release history and [Upgrading](#upgrading) for breaking changes.
12
14
 
13
15
  ## Installation
14
16
 
15
17
  $ gem install gibbon
16
18
 
19
+ Or add it to your `Gemfile`:
20
+
21
+ ```ruby
22
+ gem "gibbon"
23
+ ```
24
+
17
25
  ## Requirements
18
26
 
19
- A MailChimp account and API key. You can see your API keys [here](https://admin.mailchimp.com/account/api).
27
+ * **Ruby 3.1 or newer.** Gibbon 4.0 dropped support for Ruby 2.4 through 3.0.
28
+ * A MailChimp account and API key. You can see your API keys [here](https://admin.mailchimp.com/account/api).
29
+
30
+ Gibbon depends on [Faraday](https://github.com/lostisland/faraday) (both 1.x and 2.x are supported) and [MultiJson](https://github.com/intridea/multi_json). Connections to MailChimp negotiate TLS 1.2 or newer.
20
31
 
21
32
  ## Usage
22
33
 
@@ -26,7 +37,7 @@ First, create a *one-time use instance* of `Gibbon::Request`:
26
37
  gibbon = Gibbon::Request.new(api_key: "your_api_key")
27
38
  ```
28
39
 
29
- ***Note*** Only reuse instances of Gibbon after terminating a call with a verb, which makes a request. Requests are light weight objects that update an internal path based on your call chain. When you terminate a call chain with a verb, a request instance makes a request and resets the path.
40
+ ***Note*** Only reuse instances of Gibbon after terminating a call with a verb, which makes a request. Requests are light weight objects that update an internal path based on your call chain. When you terminate a call chain with a verb, a request instance makes a request and resets the path. See [How call chains build paths](#how-call-chains-build-paths).
30
41
 
31
42
  You can set an individual request's `timeout` and `open_timeout` like this:
32
43
 
@@ -40,7 +51,15 @@ You can read about `timeout` and `open_timeout` in the [Net::HTTP](https://docs.
40
51
  Now you can make requests using the resources defined in [MailChimp's docs](https://mailchimp.com/developer/marketing/api/). Resource IDs
41
52
  are specified inline and a `CRUD` (`create`, `retrieve` (or `get`), `update`, `upsert`, or `delete`) verb initiates the request. `upsert` lets you update a record, if it exists, or insert it otherwise where supported by MailChimp's API.
42
53
 
43
- ***Note*** `upsert` requires Gibbon version 2.1.0 or newer!
54
+ Each verb maps to an HTTP method:
55
+
56
+ | Gibbon verb | HTTP method | Accepts `body` |
57
+ | --------------------- | ----------- | -------------- |
58
+ | `create` | `POST` | yes |
59
+ | `retrieve` (or `get`) | `GET` | no |
60
+ | `update` | `PATCH` | yes |
61
+ | `upsert` | `PUT` | yes |
62
+ | `delete` | `DELETE` | no |
44
63
 
45
64
  You can specify `headers`, `params`, and `body` when calling a `CRUD` method. For example:
46
65
 
@@ -48,8 +67,6 @@ You can specify `headers`, `params`, and `body` when calling a `CRUD` method. Fo
48
67
  gibbon.lists.retrieve(headers: {"SomeHeader": "SomeHeaderValue"}, params: {"query_param": "query_param_value"})
49
68
  ```
50
69
 
51
- ***Note*** `get` can be substituted for `retrieve` as of Gibbon version 3.4.1 or newer!
52
-
53
70
  Of course, `body` is only supported on `create`, `update`, and `upsert` calls. Those map to HTTP `POST`, `PATCH`, and `PUT` verbs respectively.
54
71
 
55
72
  You can set `api_key`, `timeout`, `open_timeout`, `faraday_adapter`, `proxy`, `symbolize_keys`, `logger`, and `debug` globally:
@@ -76,8 +93,6 @@ You can also set the environment variable `MAILCHIMP_API_KEY` and Gibbon will us
76
93
  gibbon = Gibbon::Request.new
77
94
  ```
78
95
 
79
- ***Note*** Substitute an underscore if a resource name contains a hyphen.
80
-
81
96
  Pass `symbolize_keys: true` to use symbols (instead of strings) as hash keys in API responses.
82
97
 
83
98
  ```ruby
@@ -86,6 +101,57 @@ gibbon = Gibbon::Request.new(api_key: "your_api_key", symbolize_keys: true)
86
101
 
87
102
  MailChimp's [resource documentation](https://mailchimp.com/developer/marketing/api/) is a list of available resources.
88
103
 
104
+ ## How call chains build paths
105
+
106
+ `Gibbon::Request` defines no methods for MailChimp's resources. Any unknown method appends a segment to an internal path and returns the same object; a CRUD verb turns the accumulated path into a request. So `gibbon.lists(list_id).members.retrieve` builds `lists/<list_id>/members` and issues a `GET`.
107
+
108
+ Three consequences are worth knowing.
109
+
110
+ **Underscores become hyphens.** Segments containing a hyphen aren't valid Ruby method names, so write them with underscores and Gibbon converts:
111
+
112
+ ```ruby
113
+ gibbon.lists(list_id).members(subscriber_hash).actions.delete_permanent.create
114
+ # POST lists/<list_id>/members/<subscriber_hash>/actions/delete-permanent
115
+ ```
116
+
117
+ **`send` is special cased.** `Object#send` would otherwise shadow the path segment, so `Gibbon::Request#send` falls through to path building when called with no arguments. That's what makes sending a campaign work:
118
+
119
+ ```ruby
120
+ gibbon.campaigns(campaign_id).actions.send.create
121
+ # POST campaigns/<campaign_id>/actions/send
122
+ ```
123
+
124
+ Called *with* arguments, `send` keeps its usual Ruby meaning and invokes the named method.
125
+
126
+ **An instance is reusable only after a verb.** The path resets when a verb runs, not when a chain goes out of scope. Abandoning a chain leaves its segments in place and corrupts the next call:
127
+
128
+ ```ruby
129
+ gibbon = Gibbon::Request.new(api_key: "your_api_key")
130
+
131
+ gibbon.lists # no verb, so nothing is sent and nothing is reset
132
+ gibbon.campaigns.retrieve # GET lists/campaigns -- not what you wanted
133
+ ```
134
+
135
+ Use a fresh instance per chain unless you know the previous one ended in a verb.
136
+
137
+ ## Responses
138
+
139
+ Every CRUD verb returns a `Gibbon::Response` with two attributes:
140
+
141
+ * `body` — the parsed JSON response, as a `Hash` (or `Array`, depending on the endpoint)
142
+ * `headers` — the response headers, keyed by lower case string
143
+
144
+ ```ruby
145
+ response = gibbon.lists.retrieve
146
+
147
+ response.body["lists"] # => [{"id" => "...", "name" => "...", ...}, ...]
148
+ response.headers["content-type"] # => "application/json; charset=utf-8"
149
+ ```
150
+
151
+ `body` respects `symbolize_keys`; `headers` keys are always strings.
152
+
153
+ A response with an empty body — as with a successful `delete`, which MailChimp answers with `204 No Content` — returns `nil` rather than a `Gibbon::Response`.
154
+
89
155
  ## Debug Logging
90
156
 
91
157
  Pass `debug: true` to enable debug logging to STDOUT.
@@ -96,7 +162,7 @@ gibbon = Gibbon::Request.new(api_key: "your_api_key", debug: true)
96
162
 
97
163
  ### Custom logger
98
164
 
99
- Ruby `Logger.new` is used by default, but it can be overrided using:
165
+ Ruby `Logger.new` is used by default, but it can be overridden using:
100
166
 
101
167
  ```ruby
102
168
  gibbon = Gibbon::Request.new(api_key: "your_api_key", debug: true, logger: MyLogger.new)
@@ -268,7 +334,7 @@ campaigns = gibbon.campaigns.retrieve
268
334
  Fetch the number of opens for a campaign
269
335
 
270
336
  ```ruby
271
- email_stats = gibbon.reports(campaign_id).retrieve["opens"]
337
+ email_stats = gibbon.reports(campaign_id).retrieve.body["opens"]
272
338
  ```
273
339
 
274
340
  Create a new campaign:
@@ -337,7 +403,7 @@ gibbon.campaigns(campaign_id).actions.schedule.create(body: body)
337
403
 
338
404
  ### Interests
339
405
 
340
- Interests are a little more complicated than other parts of the API, so here's an example of how you would set interests during at subscription time or update them later. The ID of the interests you want to opt in or out of must be known ahead of time so an example of how to find interest IDs is also included.
406
+ Interests are a little more complicated than other parts of the API, so here's an example of how you would set interests at subscription time or update them later. The ID of the interests you want to opt in or out of must be known ahead of time so an example of how to find interest IDs is also included.
341
407
 
342
408
  Subscribing a member to a list with specific interests up front:
343
409
 
@@ -357,13 +423,13 @@ So how do we get the interest IDs? When you query the API for a specific list me
357
423
  gibbon.lists(list_id).members(member_id).retrieve
358
424
  ```
359
425
 
360
- The response body (i.e. `response.body`) looks someting like this (unrelated things removed):
426
+ The response body (i.e. `response.body`) looks something like this (unrelated things removed):
361
427
 
362
428
  ```ruby
363
429
  {"id"=>"...", "email_address"=>"...", ..., "interests"=>{"3def637141"=>true, "f7cc4ee841"=>false, "fcdc951b9f"=>false, "3daf3cf27d"=>true, "293a3703ed"=>false, "72370e0d1f"=>false, "d434d21a1c"=>false, "bdb1ff199f"=>false, "a54e78f203"=>false, "c4527fd018"=>false} ...}
364
430
  ```
365
431
 
366
- The API returns a map of interest ID to boolean value. Now we to get interest details so we know what these interest IDs map to. Looking at [this doc page](https://mailchimp.com/developer/marketing/api/interests/), we need to do this:
432
+ The API returns a map of interest ID to boolean value. Now we need to get interest details so we know what these interest IDs map to. Looking at [this doc page](https://mailchimp.com/developer/marketing/api/interests/), we need to do this:
367
433
 
368
434
  ```ruby
369
435
  gibbon.lists(list_id).interest_categories.retrieve
@@ -383,21 +449,6 @@ gibbon.lists(list_id).interest_categories("0ace7aa498").interests.retrieve
383
449
 
384
450
  That response gives the interest data, including the ID for the interests themselves, which we can use to update a list member's interests or set them when we call the API to subscribe her or him to a list.
385
451
 
386
- ### Error handling
387
-
388
- Gibbon raises an error when the API returns an error.
389
-
390
- `Gibbon::MailChimpError` has the following attributes: `title`, `detail`, `body`, `raw_body`, `status_code`. Some or all of these may not be
391
- available depending on the nature of the error. For example:
392
-
393
- ```ruby
394
- begin
395
- gibbon.lists(list_id).members.create(body: body)
396
- rescue Gibbon::MailChimpError => e
397
- puts "Houston, we have a problem: #{e.message} - #{e.raw_body}"
398
- end
399
- ```
400
-
401
452
  ### Other
402
453
 
403
454
  Overriding Gibbon's API endpoint (i.e. if using an access token from OAuth and have the `api_endpoint` from the [metadata](https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/)):
@@ -419,73 +470,70 @@ You can set a different [Faraday adapter](https://github.com/lostisland/faraday)
419
470
  gibbon = Gibbon::Request.new(api_key: "your_api_key", faraday_adapter: :net_http)
420
471
  ```
421
472
 
422
- ### Migrating from Gibbon 1.x
473
+ ## Error handling
423
474
 
424
- Gibbon 2.x+ exposes a different API from version 1.x. This is because Gibbon maps to MailChimp's API and because version 3 of the API is quite different from version 2. First, the name of the primary class has changed from `API` to `Request`. And the way you pass an API key during initialization is different. A few examples below.
475
+ Gibbon raises two error types, both subclasses of `StandardError`.
425
476
 
426
- #### Initialization
477
+ ### Gibbon::GibbonError
427
478
 
428
- Gibbon 1.x:
479
+ Raised *before* a request goes out, when Gibbon can't tell where to send it. In practice that means the API key is missing, or it carries no data center suffix and no `api_endpoint` was set:
429
480
 
430
481
  ```ruby
431
- gibbon = Gibbon::API.new("your_api_key")
432
- ```
433
-
434
- Gibbon 2.x+:
482
+ Gibbon::Request.new.lists.retrieve
483
+ # => Gibbon::GibbonError: You must set an api_key prior to making a call
435
484
 
436
- ```ruby
437
- gibbon = Gibbon::Request.new(api_key: "your_api_key")
485
+ Gibbon::Request.new(api_key: "your_api_key").lists.retrieve
486
+ # => Gibbon::GibbonError: You must set an api_key prior to making a call
438
487
  ```
439
488
 
440
- MailChimp API 3 is a RESTful API, so Gibbon's syntax now requires a trailing call to a verb, as described above.
489
+ A key like `your_api_key-us1` carries its data center (`us1`) and works on its own. Access tokens obtained through OAuth do not, so pass `api_endpoint` alongside them — see [Other](#other) above.
441
490
 
442
- #### Fetching Lists
491
+ ### Gibbon::MailChimpError
443
492
 
444
- Gibbon 1.x:
493
+ Raised when a request fails. It exposes `title`, `detail`, `body`, `raw_body`, and `status_code`:
445
494
 
446
495
  ```ruby
447
- gibbon.lists.list
496
+ begin
497
+ gibbon.lists(list_id).members.create(body: body)
498
+ rescue Gibbon::MailChimpError => e
499
+ puts "Houston, we have a problem: #{e.message} - #{e.raw_body}"
500
+ end
448
501
  ```
449
502
 
450
- Gibbon 2.x+:
503
+ Some or all of those attributes may be `nil` depending on the nature of the failure. MailChimp returning a `4xx` or `5xx` populates all of them from the error document. A transport level failure — a timeout, a refused connection — has no response to read, so only `message` is set.
451
504
 
452
- ```ruby
453
- gibbon.lists.retrieve
454
- ```
505
+ Gibbon also raises `Gibbon::MailChimpError` when a *successful* response body isn't valid JSON. That error has a `title` of `"UNPARSEABLE_RESPONSE"` and a `status_code` of `500`, and its message includes the raw body.
455
506
 
456
- #### Fetching List Members
507
+ ## Upgrading
457
508
 
458
- Gibbon 1.x:
509
+ Full release history lives in the [CHANGELOG](CHANGELOG.md). The breaking changes are summarized here.
459
510
 
460
- ```ruby
461
- gibbon.lists.members({:id => list_id})
462
- ```
511
+ ### Upgrading to 4.0
463
512
 
464
- Gibbon 2.x+:
513
+ Gibbon 4.0 contains no API changes. It changes what it runs on:
465
514
 
466
- ```ruby
467
- gibbon.lists(list_id).members.retrieve
468
- ```
515
+ * **Ruby 3.1 or newer is required.** Rubies 2.4 through 3.0 are EOL and are no longer supported. If you are on an older Ruby, stay on Gibbon 3.5.0.
516
+ * **TLS 1.2 is now a minimum rather than an exact pin.** Connections previously negotiated exactly TLS 1.2; they now accept TLS 1.3 as well. No code change is needed, but if you inspect the negotiated protocol in tests or middleware, expect 1.3.
469
517
 
470
- #### Subscribing a Member to a List
518
+ ### Upgrading to 3.0
471
519
 
472
- Gibbon 1.x:
520
+ `create`, `retrieve`/`get`, `update`, `upsert`, and `delete` return a `Gibbon::Response` rather than the parsed body. Add `.body`:
473
521
 
474
522
  ```ruby
475
- gibbon.lists.subscribe({:id => list_id, :email => {:email => "foo@bar.com"}, :merge_vars => {:FNAME => "Bob", :LNAME => "Smith"}})
476
- ```
477
-
478
- Gibbon 2.x+:
523
+ # Gibbon 2.x
524
+ gibbon.lists.retrieve["lists"]
479
525
 
480
- ```ruby
481
- gibbon.lists(list_id).members.create(body: {email_address: "foo@bar.com", status: "subscribed", merge_fields: {FNAME: "Bob", LNAME: "Smith"}})
526
+ # Gibbon 3.x and newer
527
+ gibbon.lists.retrieve.body["lists"]
482
528
  ```
483
529
 
530
+ In exchange you also get `.headers`, which carries MailChimp's rate limit and content metadata. See [Responses](#responses).
531
+
484
532
  ## Thanks
485
533
 
486
534
  Thanks to everyone who has [contributed](https://github.com/amro/gibbon/contributors) to Gibbon's development.
487
535
 
488
536
  ## Copyright
489
537
 
490
- * Copyright (c) 2010-2022 Amro Mousa. See LICENSE.txt for details.
491
- * MailChimp (c) 2001-2022 The Rocket Science Group.
538
+ * Copyright (c) 2010-2026 Amro Mousa. See LICENSE.txt for details.
539
+ * MailChimp (c) 2001-2026 The Rocket Science Group.
data/gibbon.gemspec CHANGED
@@ -10,9 +10,16 @@ Gem::Specification.new do |s|
10
10
  s.homepage = "https://github.com/amro/gibbon"
11
11
 
12
12
  s.summary = %q{A wrapper for MailChimp API 3.0}
13
- s.description = %q{A wrapper for MailChimp API 3.0}
13
+ s.description = %q{Gibbon is a Ruby wrapper for MailChimp's Marketing API (version 3.0). Ruby method chains map onto API resource paths, so every documented resource is reachable without waiting on a gem update.}
14
14
  s.license = "MIT"
15
15
 
16
+ s.metadata = {
17
+ "source_code_uri" => "https://github.com/amro/gibbon",
18
+ "changelog_uri" => "https://github.com/amro/gibbon/blob/master/CHANGELOG.md",
19
+ "bug_tracker_uri" => "https://github.com/amro/gibbon/issues",
20
+ "documentation_uri" => "https://rubydoc.info/gems/gibbon"
21
+ }
22
+
16
23
  s.files = `git ls-files`.split("\n")
17
24
  s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
18
25
  s.require_paths = ["lib"]
@@ -20,9 +27,13 @@ Gem::Specification.new do |s|
20
27
 
21
28
  s.add_dependency('faraday', '>= 1.0')
22
29
  s.add_dependency('multi_json', '>= 1.11.0')
30
+ # Ruby 3.1 uses multi_json <= 1.19.1, whose JSON adapter passes options
31
+ # positionally. JSON 3 requires keyword arguments.
32
+ s.add_dependency('json', '< 3.0')
23
33
 
24
34
  s.add_development_dependency 'rake'
25
35
  s.add_development_dependency "rspec", "~> 3.13"
26
36
  s.add_development_dependency 'webmock', '~> 3.8'
37
+ s.add_development_dependency 'yard', '~> 0.9'
27
38
 
28
39
  end
@@ -1,11 +1,23 @@
1
1
  module Gibbon
2
+ # Carries out the HTTP request described by a {Request}.
3
+ #
4
+ # This is an implementation detail of {Request}, which constructs one per
5
+ # call and reads its own configuration back out of it. Use {Request}'s CRUD
6
+ # verbs rather than instantiating this directly.
2
7
  class APIRequest
3
8
  include Helpers
4
9
 
10
+ # @param builder [Request] the request whose path and configuration to use
5
11
  def initialize(builder: nil)
6
12
  @request_builder = builder
7
13
  end
8
14
 
15
+ # @param params [Hash, nil] query string parameters
16
+ # @param headers [Hash, nil] additional request headers
17
+ # @param body [Hash, nil] request body, serialized to JSON
18
+ # @return [Response, nil] the response, or nil when the body is empty
19
+ # @raise [GibbonError] if no usable API key is configured
20
+ # @raise [MailChimpError] if the request fails or the response cannot be parsed
9
21
  def post(params: nil, headers: nil, body: nil)
10
22
  validate_api_key
11
23
 
@@ -19,6 +31,9 @@ module Gibbon
19
31
  end
20
32
  end
21
33
 
34
+ # @param (see #post)
35
+ # @return (see #post)
36
+ # @raise (see #post)
22
37
  def patch(params: nil, headers: nil, body: nil)
23
38
  validate_api_key
24
39
 
@@ -32,6 +47,9 @@ module Gibbon
32
47
  end
33
48
  end
34
49
 
50
+ # @param (see #post)
51
+ # @return (see #post)
52
+ # @raise (see #post)
35
53
  def put(params: nil, headers: nil, body: nil)
36
54
  validate_api_key
37
55
 
@@ -45,6 +63,10 @@ module Gibbon
45
63
  end
46
64
  end
47
65
 
66
+ # @param params [Hash, nil] query string parameters
67
+ # @param headers [Hash, nil] additional request headers
68
+ # @return (see #post)
69
+ # @raise (see #post)
48
70
  def get(params: nil, headers: nil)
49
71
  validate_api_key
50
72
 
@@ -58,6 +80,9 @@ module Gibbon
58
80
  end
59
81
  end
60
82
 
83
+ # @param (see #get)
84
+ # @return (see #post)
85
+ # @raise (see #post)
61
86
  def delete(params: nil, headers: nil)
62
87
  validate_api_key
63
88
 
@@ -1,3 +1,6 @@
1
1
  module Gibbon
2
+ # Raised before a request is sent, when Gibbon cannot determine where to send
3
+ # it: the API key is missing, or it carries no data center suffix and no
4
+ # `api_endpoint` was configured.
2
5
  class GibbonError < StandardError; end
3
6
  end
@@ -1,13 +1,20 @@
1
1
  module Gibbon
2
+ # Shared helpers mixed into {APIRequest}.
2
3
  module Helpers
4
+ # Extracts the data center prefix from an API key, e.g. `"us1."` from
5
+ # `"TESTKEY-us1"`.
6
+ #
7
+ # @param api_key [String, nil] the API key
8
+ # @return [String] the prefix including its trailing dot, or an empty string
9
+ # when the key carries no data center suffix
3
10
  def get_data_center_from_api_key(api_key)
4
11
  # Return an empty string for invalid API keys so Gibbon hits the main endpoint
5
12
  data_center = ""
6
13
 
7
14
  if api_key && api_key["-"]
8
- # Remove all non-alphanumberic characters in case someone attempts to inject
15
+ # Remove all non-alphanumeric characters in case someone attempts to inject
9
16
  # a different domain into the API key (e.g. when consuming user form-provided keys)
10
- # This approach avoids assuming a 3 letter prefix (e.g. is MC were to create
17
+ # This approach avoids assuming a 3 letter prefix (e.g. if MC were to create
11
18
  # a us10 DC, this would continue to work), and will continue to hit MC's server
12
19
  # rather than a would-be attacker's servers.
13
20
  data_center = "#{api_key.split('-').last.gsub(/[^0-9a-z ]/i, '')}."
@@ -1,7 +1,25 @@
1
1
  module Gibbon
2
+ # Raised when a request fails.
3
+ #
4
+ # A `4xx` or `5xx` from MailChimp populates every attribute from the error
5
+ # document it returns. A transport level failure, such as a timeout, has no
6
+ # response to read, so only the message is set. Gibbon also raises this when a
7
+ # successful response body is not valid JSON, with a {#title} of
8
+ # `"UNPARSEABLE_RESPONSE"` and a {#status_code} of `500`.
2
9
  class MailChimpError < StandardError
3
- attr_reader :title, :detail, :body, :raw_body, :status_code
10
+ # @return [String, nil] MailChimp's short name for the error, e.g. `"Resource Not Found"`
11
+ attr_reader :title
12
+ # @return [String, nil] MailChimp's human readable explanation of the error
13
+ attr_reader :detail
14
+ # @return [Hash, nil] the parsed error document
15
+ attr_reader :body
16
+ # @return [String, nil] the unparsed error response body
17
+ attr_reader :raw_body
18
+ # @return [Integer, nil] the HTTP status code
19
+ attr_reader :status_code
4
20
 
21
+ # @param message [String] the error message
22
+ # @param params [Hash] any of `:title`, `:detail`, `:body`, `:raw_body`, `:status_code`
5
23
  def initialize(message = "", params = {})
6
24
  @title = params[:title]
7
25
  @detail = params[:detail]
@@ -12,6 +30,7 @@ module Gibbon
12
30
  super(message)
13
31
  end
14
32
 
33
+ # @return [String] the message followed by every populated attribute
15
34
  def to_s
16
35
  super + " " + instance_variables_to_s
17
36
  end
@@ -1,11 +1,42 @@
1
1
  module Gibbon
2
+ # Builds and issues requests against MailChimp's Marketing API.
3
+ #
4
+ # `Request` defines no methods for MailChimp's resources. An unknown method
5
+ # appends a segment to an internal path and returns `self`, so
6
+ # `gibbon.lists(list_id).members` builds `lists/<list_id>/members`. A CRUD
7
+ # verb ({#create}, {#retrieve}, {#update}, {#upsert}, {#delete}) then issues
8
+ # the request and resets the path. Because of that reset, an instance is safe
9
+ # to reuse only after a verb has run.
10
+ #
11
+ # @example Fetching lists
12
+ # gibbon = Gibbon::Request.new(api_key: "your_api_key-us1")
13
+ # gibbon.lists.retrieve.body["lists"]
14
+ #
15
+ # @see https://mailchimp.com/developer/marketing/api/ MailChimp's resource documentation
2
16
  class Request
17
+ # Per-instance configuration. Each falls back to the matching class level
18
+ # setting, then to an environment variable or built-in default; see
19
+ # {#initialize} for the resolution order.
3
20
  attr_accessor :api_key, :api_endpoint, :timeout, :open_timeout, :proxy, :faraday_adapter, :symbolize_keys, :debug, :logger
4
21
 
22
+ # Default read timeout, in seconds.
5
23
  DEFAULT_TIMEOUT = 60
24
+ # Default connection open timeout, in seconds.
6
25
  DEFAULT_OPEN_TIMEOUT = 60
7
26
 
8
- def initialize(api_key: nil, api_endpoint: nil, timeout: nil, open_timeout: nil, proxy: nil, faraday_adapter: nil, symbolize_keys: false, debug: false, logger: nil)
27
+ # @param api_key [String, nil] API key or OAuth access token. Falls back to
28
+ # `Gibbon::Request.api_key`, then `ENV["MAILCHIMP_API_KEY"]`.
29
+ # @param api_endpoint [String, nil] Base URL, e.g. `"https://us1.api.mailchimp.com"`.
30
+ # Required when the key carries no data center suffix, as OAuth access
31
+ # tokens do not; otherwise it is derived from the key.
32
+ # @param timeout [Integer, nil] Read timeout in seconds. Defaults to {DEFAULT_TIMEOUT}.
33
+ # @param open_timeout [Integer, nil] Open timeout in seconds. Defaults to {DEFAULT_OPEN_TIMEOUT}.
34
+ # @param proxy [String, nil] Proxy URL. Falls back to `ENV["MAILCHIMP_PROXY"]`.
35
+ # @param faraday_adapter [Symbol, nil] Faraday adapter. Defaults to `Faraday.default_adapter`.
36
+ # @param symbolize_keys [Boolean] Whether parsed response bodies use symbol keys.
37
+ # @param debug [Boolean] Whether to log requests and responses.
38
+ # @param logger [Logger, nil] Logger used when `debug` is true. Defaults to `Logger.new(STDOUT)`.
39
+ def initialize(api_key: nil, api_endpoint: nil, timeout: nil, open_timeout: nil, proxy: nil, faraday_adapter: nil, symbolize_keys: nil, debug: nil, logger: nil)
9
40
  @path_parts = []
10
41
  @api_key = api_key || self.class.api_key || ENV['MAILCHIMP_API_KEY']
11
42
  @api_key = @api_key.strip if @api_key
@@ -14,11 +45,15 @@ module Gibbon
14
45
  @open_timeout = open_timeout || self.class.open_timeout || DEFAULT_OPEN_TIMEOUT
15
46
  @proxy = proxy || self.class.proxy || ENV['MAILCHIMP_PROXY']
16
47
  @faraday_adapter = faraday_adapter || self.class.faraday_adapter || Faraday.default_adapter
17
- @symbolize_keys = symbolize_keys || self.class.symbolize_keys || false
18
- @debug = debug || self.class.debug || false
48
+ @symbolize_keys = symbolize_keys.nil? ? (self.class.symbolize_keys || false) : symbolize_keys
49
+ @debug = debug.nil? ? (self.class.debug || false) : debug
19
50
  @logger = logger || self.class.logger || ::Logger.new(STDOUT)
20
51
  end
21
52
 
53
+ # Appends the method name to the request path, along with any arguments as
54
+ # further segments, and returns `self` so calls can be chained.
55
+ #
56
+ # @return [Request] self
22
57
  def method_missing(method, *args)
23
58
  # To support underscores, we replace them with hyphens when calling the API
24
59
  @path_parts << method.to_s.gsub("_", "-").downcase
@@ -27,10 +62,19 @@ module Gibbon
27
62
  self
28
63
  end
29
64
 
65
+ # Every method name is a potential path segment, so this always returns true.
66
+ #
67
+ # @return [true]
30
68
  def respond_to_missing?(method_name, include_private = false)
31
69
  true
32
70
  end
33
71
 
72
+ # Appends `send` as a path segment when called with no arguments, so that
73
+ # `gibbon.campaigns(id).actions.send.create` reaches
74
+ # `campaigns/<id>/actions/send`. Called with arguments, this behaves like
75
+ # `Object#send` and invokes the named method.
76
+ #
77
+ # @return [Request, Object] self, or the return value of the invoked method
34
78
  def send(*args)
35
79
  if args.length == 0
36
80
  method_missing(:send, args)
@@ -39,38 +83,75 @@ module Gibbon
39
83
  end
40
84
  end
41
85
 
86
+ # @return [String] the path accumulated so far, e.g. `"lists/abc123/members"`
42
87
  def path
43
88
  @path_parts.join('/')
44
89
  end
45
90
 
91
+ # Issues a `POST` to the accumulated path, then resets it.
92
+ #
93
+ # @param params [Hash, nil] query string parameters
94
+ # @param headers [Hash, nil] additional request headers
95
+ # @param body [Hash, nil] request body, serialized to JSON
96
+ # @return [Response, nil] the response, or nil when MailChimp returns an empty body
97
+ # @raise [GibbonError] if no usable API key is configured
98
+ # @raise [MailChimpError] if the request fails or the response cannot be parsed
46
99
  def create(params: nil, headers: nil, body: nil)
47
100
  APIRequest.new(builder: self).post(params: params, headers: headers, body: body)
48
101
  ensure
49
102
  reset
50
103
  end
51
104
 
105
+ # Issues a `PATCH` to the accumulated path, then resets it.
106
+ #
107
+ # @param (see #create)
108
+ # @return (see #create)
109
+ # @raise (see #create)
52
110
  def update(params: nil, headers: nil, body: nil)
53
111
  APIRequest.new(builder: self).patch(params: params, headers: headers, body: body)
54
112
  ensure
55
113
  reset
56
114
  end
57
115
 
116
+ # Issues a `PUT` to the accumulated path, then resets it. Updates the record
117
+ # if it exists and inserts it otherwise, where MailChimp supports it.
118
+ #
119
+ # @param (see #create)
120
+ # @return (see #create)
121
+ # @raise (see #create)
58
122
  def upsert(params: nil, headers: nil, body: nil)
59
123
  APIRequest.new(builder: self).put(params: params, headers: headers, body: body)
60
124
  ensure
61
125
  reset
62
126
  end
63
127
 
128
+ # Alias for {#retrieve}.
129
+ #
130
+ # @param (see #retrieve)
131
+ # @return (see #retrieve)
132
+ # @raise (see #retrieve)
64
133
  def get(params: nil, headers: nil)
65
134
  retrieve(params: params, headers: headers)
66
135
  end
67
136
 
137
+ # Issues a `GET` to the accumulated path, then resets it.
138
+ #
139
+ # @param params [Hash, nil] query string parameters
140
+ # @param headers [Hash, nil] additional request headers
141
+ # @return [Response, nil] the response, or nil when MailChimp returns an empty body
142
+ # @raise [GibbonError] if no usable API key is configured
143
+ # @raise [MailChimpError] if the request fails or the response cannot be parsed
68
144
  def retrieve(params: nil, headers: nil)
69
145
  APIRequest.new(builder: self).get(params: params, headers: headers)
70
146
  ensure
71
147
  reset
72
148
  end
73
149
 
150
+ # Issues a `DELETE` to the accumulated path, then resets it.
151
+ #
152
+ # @param (see #retrieve)
153
+ # @return (see #retrieve)
154
+ # @raise (see #retrieve)
74
155
  def delete(params: nil, headers: nil)
75
156
  APIRequest.new(builder: self).delete(params: params, headers: headers)
76
157
  ensure
@@ -84,12 +165,22 @@ module Gibbon
84
165
  end
85
166
 
86
167
  class << self
168
+ # Class level defaults applied to every new instance. Setting these is a
169
+ # convenient way to configure Gibbon once, e.g. from a Rails initializer.
87
170
  attr_accessor :api_key, :timeout, :open_timeout, :api_endpoint, :proxy, :faraday_adapter, :symbolize_keys, :debug, :logger
88
171
 
172
+ # Starts a call chain on a new instance built from the class level
173
+ # configuration, so `Gibbon::Request.lists.retrieve` works once
174
+ # {api_key} has been set.
175
+ #
176
+ # @return [Request] a new instance carrying the first path segment
89
177
  def method_missing(sym, *args, &block)
90
178
  new(api_key: self.api_key, api_endpoint: self.api_endpoint, timeout: self.timeout, open_timeout: self.open_timeout, faraday_adapter: self.faraday_adapter, symbolize_keys: self.symbolize_keys, debug: self.debug, proxy: self.proxy, logger: self.logger).send(sym, *args, &block)
91
179
  end
92
180
 
181
+ # Every method name is a potential path segment, so this always returns true.
182
+ #
183
+ # @return [true]
93
184
  def respond_to_missing?(method_name, include_private = false)
94
185
  true
95
186
  end
@@ -1,7 +1,20 @@
1
1
  module Gibbon
2
+ # A parsed response from MailChimp, returned by every CRUD verb on {Request}.
3
+ #
4
+ # @example
5
+ # response = gibbon.lists.retrieve
6
+ # response.body["lists"]
7
+ # response.headers["content-type"]
2
8
  class Response
3
- attr_accessor :body, :headers
4
-
9
+ # @return [Hash, Array] the parsed JSON response body. Keys are symbols when
10
+ # the request was built with `symbolize_keys: true`, strings otherwise.
11
+ attr_accessor :body
12
+
13
+ # @return [Hash] the response headers, keyed by lower case string.
14
+ attr_accessor :headers
15
+
16
+ # @param body [Hash, Array] the parsed response body
17
+ # @param headers [Hash] the response headers
5
18
  def initialize(body: {}, headers: {})
6
19
  @body = body
7
20
  @headers = headers
@@ -1,3 +1,4 @@
1
1
  module Gibbon
2
- VERSION = "4.0.0"
2
+ # The released version of the gem.
3
+ VERSION = "4.0.1"
3
4
  end
data/lib/gibbon.rb CHANGED
@@ -10,5 +10,6 @@ require 'gibbon/request'
10
10
  require 'gibbon/api_request'
11
11
  require 'gibbon/response'
12
12
 
13
+ # A Ruby wrapper for MailChimp's Marketing API. See {Gibbon::Request}.
13
14
  module Gibbon
14
15
  end
@@ -1,5 +1,6 @@
1
1
  require 'spec_helper'
2
2
  require 'webmock/rspec'
3
+ require 'stringio'
3
4
 
4
5
  describe Gibbon::APIRequest do
5
6
  let(:api_key) { "1234-us1" }
@@ -151,6 +152,49 @@ describe Gibbon::APIRequest do
151
152
  end
152
153
  end
153
154
 
155
+ context "instance overrides of class defaults" do
156
+ before do
157
+ allow(Gibbon::Request).to receive(:symbolize_keys).and_return(true)
158
+ allow(Gibbon::Request).to receive(:debug).and_return(true)
159
+ end
160
+
161
+ let(:log_output) { StringIO.new }
162
+ let(:gibbon) do
163
+ Gibbon::Request.new(api_key: api_key, symbolize_keys: false, debug: false, logger: Logger.new(log_output))
164
+ end
165
+
166
+ it "returns string keys, including nested keys, when symbolization is disabled" do
167
+ stub_request(:get, "#{@api_root}/lists")
168
+ .to_return(body: '{"lists": [{"id": "abc123"}]}')
169
+
170
+ expect(gibbon.lists.retrieve.body).to eq({ "lists" => [{ "id" => "abc123" }] })
171
+ expect(Gibbon::Request.symbolize_keys).to be true
172
+ end
173
+
174
+ it "preserves error details with string keys when symbolization is disabled" do
175
+ error_body = '{"title": "Invalid Resource", "detail": "Invalid email address"}'
176
+ stub_request(:get, "#{@api_root}/lists").to_return(status: 422, body: error_body)
177
+
178
+ expect { gibbon.lists.retrieve }.to raise_error(Gibbon::MailChimpError) { |error|
179
+ expect(error.status_code).to eq 422
180
+ expect(error.title).to eq "Invalid Resource"
181
+ expect(error.detail).to eq "Invalid email address"
182
+ expect(error.body).to eq({ "title" => "Invalid Resource", "detail" => "Invalid email address" })
183
+ expect(error.raw_body).to eq error_body
184
+ }
185
+ end
186
+
187
+ it "does not log requests or responses when debug is disabled" do
188
+ stub_request(:get, "#{@api_root}/lists")
189
+ .to_return(body: '{"email_address": "private@example.com"}')
190
+
191
+ gibbon.lists.retrieve
192
+
193
+ expect(log_output.string).to be_empty
194
+ expect(Gibbon::Request.debug).to be true
195
+ end
196
+ end
197
+
154
198
  context "configure_request" do
155
199
  it "sets params, headers, body, and timeouts on the request" do
156
200
  gibbon = Gibbon::Request.new(api_key: api_key, timeout: 45, open_timeout: 15)
@@ -219,10 +219,26 @@ describe Gibbon do
219
219
  expect(Gibbon::Request.new.symbolize_keys).to eq(Gibbon::Request.symbolize_keys)
220
220
  end
221
221
 
222
+ it "allows instance symbolize_keys false to override the class setting" do
223
+ expect(Gibbon::Request.new(symbolize_keys: false).symbolize_keys).to be false
224
+ end
225
+
226
+ it "inherits the class symbolize_keys setting when explicitly passed nil" do
227
+ expect(Gibbon::Request.new(symbolize_keys: nil).symbolize_keys).to be true
228
+ end
229
+
222
230
  it "set debug on new instances" do
223
231
  expect(Gibbon::Request.new.debug).to eq(Gibbon::Request.debug)
224
232
  end
225
233
 
234
+ it "allows instance debug false to override the class setting" do
235
+ expect(Gibbon::Request.new(debug: false).debug).to be false
236
+ end
237
+
238
+ it "inherits the class debug setting when explicitly passed nil" do
239
+ expect(Gibbon::Request.new(debug: nil).debug).to be true
240
+ end
241
+
226
242
  it "set faraday_adapter on new instances" do
227
243
  expect(Gibbon::Request.new.faraday_adapter).to eq(Gibbon::Request.faraday_adapter)
228
244
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gibbon
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amro Mousa
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: 1.11.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: json
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: rake
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -79,7 +93,23 @@ dependencies:
79
93
  - - "~>"
80
94
  - !ruby/object:Gem::Version
81
95
  version: '3.8'
82
- description: A wrapper for MailChimp API 3.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: yard
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.9'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.9'
110
+ description: Gibbon is a Ruby wrapper for MailChimp's Marketing API (version 3.0).
111
+ Ruby method chains map onto API resource paths, so every documented resource is
112
+ reachable without waiting on a gem update.
83
113
  email:
84
114
  - amromousa@gmail.com
85
115
  executables: []
@@ -88,8 +118,9 @@ extra_rdoc_files: []
88
118
  files:
89
119
  - ".document"
90
120
  - ".github/FUNDING.yml"
121
+ - ".github/workflows/ci.yml"
91
122
  - ".gitignore"
92
- - ".travis.yml"
123
+ - ".yardopts"
93
124
  - CHANGELOG.md
94
125
  - CODE_OF_CONDUCT.md
95
126
  - Gemfile
@@ -116,7 +147,11 @@ files:
116
147
  homepage: https://github.com/amro/gibbon
117
148
  licenses:
118
149
  - MIT
119
- metadata: {}
150
+ metadata:
151
+ source_code_uri: https://github.com/amro/gibbon
152
+ changelog_uri: https://github.com/amro/gibbon/blob/master/CHANGELOG.md
153
+ bug_tracker_uri: https://github.com/amro/gibbon/issues
154
+ documentation_uri: https://rubydoc.info/gems/gibbon
120
155
  rdoc_options: []
121
156
  require_paths:
122
157
  - lib
@@ -131,7 +166,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
166
  - !ruby/object:Gem::Version
132
167
  version: '0'
133
168
  requirements: []
134
- rubygems_version: 4.0.16
169
+ rubygems_version: 4.0.20
135
170
  specification_version: 4
136
171
  summary: A wrapper for MailChimp API 3.0
137
172
  test_files: []
data/.travis.yml DELETED
@@ -1,23 +0,0 @@
1
- language: ruby
2
- cache: bundler
3
- rvm:
4
- - 3.1
5
- - 3.2
6
- - 3.3
7
- - 3.4
8
- jobs:
9
- include:
10
- # rvm's stable release does not know Ruby 4.0 yet, so boot on 3.4,
11
- # then upgrade rvm to master and install 4.0 from there
12
- - name: "4.0 (via rvm master)"
13
- rvm: 3.4
14
- before_install:
15
- - rvm get master
16
- - rvm install ruby-4.0
17
- - rvm use ruby-4.0
18
- - ruby -v
19
- # Bundler 4 (shipped with Ruby 4.0) removed the --path flag that
20
- # Travis's default install step still passes
21
- install:
22
- - bundle config set --local path vendor/bundle
23
- - bundle install --jobs=3 --retry=3