~tim/mailchimp3

479e11434faed419ce0e04a87cc03661307011fc — Tim Morgan 6 years ago
Initial commit
A  => .gitignore +2 -0
@@ 1,2 @@
Gemfile.lock
*.gem

A  => CHANGELOG.md +3 -0
@@ 1,3 @@
# 1.0.0 (?)

Initial release.

A  => Gemfile +2 -0
@@ 1,2 @@
source 'https://rubygems.org'
gemspec

A  => LICENSE +17 -0
@@ 1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

A  => README.md +203 -0
@@ 1,203 @@
# Mailchimp3

[![Circle CI](https://circleci.com/gh/seven1m/mailchimp3/tree/master.svg?style=svg)](https://circleci.com/gh/seven1m/mailchimp3/tree/master)

`mailchimp3` is a Rubygem that provides a very thin, simple wrapper around the MailChimp RESTful JSON API.

## Installation

```
gem install mailchimp3 (eventually)
```

## Usage

1. Create a new API object, passing in your credentials (either HTTP Basic or OAuth2 access token):

    ```ruby
    # authenticate with HTTP Basic:
    api = Mailchimp3.new(basic_auth_key: 'my key')
    # ...or authenticate with an OAuth2 access token (use the 'oauth2' gem to obtain the token)
    api = Mailchimp3.new(oauth_access_token: 'token')
    ```

2. Call a method on the api object to build the endpoint path.

    ```ruby
    api.lists
    # /lists
    ```

3. For IDs, treat the object like a hash (use square brackets), and chain method calls together.

    ```ruby
    api.lists[1].members
    # /lists/1/members
    ```

4. To execute the request, use `get`, `post`, `patch`, or `delete`, optionally passing arguments.

    ```ruby
    api.lists.get(count: 25)
    # GET /lists?count=25
    api.lists[1].members[2].get
    # GET /lists/1/members/2
    ```

## Example

```ruby
require 'mailchimp3'

api = Mailchimp3.new(basic_auth_key: 'abc123abc123abc123abc123abc123ab-us2')
api.lists.post(
  name: 'Church.IO',
  email_type_option: false,
  permission_reminder: 'signed up on https://church.io'
  contact: {
    company: 'TJRM',
    address1: '123 N. Main',
    city: 'Tulsa',
    state: 'OK',
    zip: '74137',
    country: 'US'
  },
  campaign_defaults: {
    from_name: 'Tim Morgan',
    from_email: 'tim@timmorgan.org',
    subject: 'Hello World',
    language: 'English'
  },
)
```

...which returns something like:

```ruby
{
  "id"   => "abc123abc1",
  "name" => "Church.IO",
  "contact" => {
    "company"  => "TJRM",
    "address1" => "123 N. Main",
    "address2" => "",
    "city"     => "Tulsa",
    "state"    => "OK",
    "zip"      => "74137",
    "country"  => "US",
    "phone"    => ""
  },
  "campaign_defaults" => {
    "from_name"  => "Tim Morgan",
    "from_email" => "tim@timmorgan.org",
    "subject"    => "test",
    "language"   => "English"
  },
  # ...
  "stats" => {
    "member_count" => 0,
    # ...
  },
  "_links" => [
    {
      "rel"          => "self",
      "href"         => "https://us2.api.mailchimp.com/3.0/lists/abc123abc1",
      "method"       => "GET",
      "targetSchema" => "https://us2.api.mailchimp.com/schema/3.0/Lists/Instance.json"
    },
    # ...
  ]
}
```

## get()

`get()` works for a collection (index) and a single resource (show).

```ruby
# collection
api.lists[1].members.get(count: 25)
# => { members: array_of_resources }

# single resource
api.lists[1].members[2].get
# => resource_hash
```

## post()

`post()` sends a POST request to create a new resource.

```ruby
api.lists[1].members.post(...)
# => resource_hash
```

## patch()

`patch()` sends a PATCH request to update an existing resource.

```ruby
api.lists[1].members[2].patch(...)
# => resource_hash
```

## delete()

`delete()` sends a DELETE request to delete an existing resource. This method returns `true` if the delete was successful.

```ruby
api.lists[1].members[2].delete
# => true
```

## Errors

The following errors may be raised by the library, depending on the API response status code.

| HTTP Status Codes   | Error Class                                                                   |
| ------------------- | ----------------------------------------------------------------------------- |
| 400                 | `Mailchimp3::Errors::BadRequest` < `Mailchimp3::Errors::ClientError`          |
| 401                 | `Mailchimp3::Errors::Unauthorized` < `Mailchimp3::Errors::ClientError`        |
| 403                 | `Mailchimp3::Errors::Forbidden` < `Mailchimp3::Errors::ClientError`           |
| 404                 | `Mailchimp3::Errors::NotFound` < `Mailchimp3::Errors::ClientError`            |
| 405                 | `Mailchimp3::Errors::MethodNotAllowed` < `Mailchimp3::Errors::ClientError`    |
| 422                 | `Mailchimp3::Errors::UnprocessableEntity` < `Mailchimp3::Errors::ClientError` |
| other 4xx errors    | `Mailchimp3::Errors::ClientError`                                             |
| 500                 | `Mailchimp3::Errors::InternalServerError` < `Mailchimp3::Errors::ServerError` |
| other 5xx errors    | `Mailchimp3::Errors::ServerError`                                             |

The exception object has the following methods:

| Method  | Content                                  |
| ------- | ---------------------------------------- |
| status  | HTTP status code returned by the server  |
| message | the message returned by the API          |
| details | the full response returned by the server |

The `message` should be a simple string given by the API, e.g. "Resource Not Found".

`details` is a Ruby hash containing all the details given by the server, and looks like this:

```ruby
{
  "type"     => "http://kb.mailchimp.com/api/error-docs/400-invalid-resource",
  "title"    => "Invalid Resource",
  "status"   => 400,
  "detail"   => "The resource submitted could not be validated. For field-specific details, see the 'errors' array.",
  "instance" => "286179fe-f3dc-4c03-8c14-1021cf0191a2",
  "errors" => [
    {
      "field"   => "",
      "message" => "Required fields were not provided: permission_reminder, campaign_defaults"
    }
  ]
}
```

Alternatively, you may rescue `Mailchimp3::Errors::BaseError` and branch your code based on
the status code returned by calling `error.status`.

## Copyright & License

Copyright 2015, Tim Morgan. Licensed MIT.

A  => circle.yml +3 -0
@@ 1,3 @@
machine:
  ruby:
    version: 2.2.2

A  => lib/mailchimp3.rb +9 -0
@@ 1,9 @@
require_relative 'mailchimp3/endpoint'
require_relative 'mailchimp3/errors'

module Mailchimp3
  module_function
  def new(*args)
    Endpoint.new(*args)
  end
end

A  => lib/mailchimp3/endpoint.rb +132 -0
@@ 1,132 @@
require 'faraday'
require 'faraday_middleware'

module Mailchimp3
  class Endpoint
    attr_reader :url, :last_result

    def initialize(oauth_access_token: nil, basic_auth_key: nil, connection: nil, url: nil)
      @oauth_access_token = oauth_access_token
      @basic_auth_key = basic_auth_key
      @url = url || _build_url
      @connection = connection || _build_connection
      @cache = {}
    end

    def method_missing(method_name, *_args)
      _build_endpoint(method_name.to_s)
    end

    def [](id)
      _build_endpoint(id.to_s)
    end

    def respond_to?(method_name)
      endpoint = _build_endpoint(method_name.to_s)
      begin
        endpoint.get
      rescue Errors::NotFound
        false
      else
        true
      end
    end

    def get(params = {})
      @last_result = @connection.get(@url, params)
      _build_response(@last_result)
    end

    def post(body = {})
      @last_result = @connection.post(@url) do |req|
        req.body = _build_body(body)
      end
      _build_response(@last_result)
    end

    def patch(body = {})
      @last_result = @connection.patch(@url) do |req|
        req.body = _build_body(body)
      end
      _build_response(@last_result)
    end

    def delete
      @last_result = @connection.delete(@url)
      if @last_result.status == 204
        true
      else
        _build_response(@last_result)
      end
    end

    private

    def _build_response(result)
      case result.status
      when 200..299
        result.body
      when 400
        fail Errors::BadRequest, result
      when 401
        fail Errors::Unauthorized, result
      when 403
        fail Errors::Forbidden, result
      when 404
        fail Errors::NotFound, result
      when 405
        fail Errors::MethodNotAllowed, result
      when 422
        fail Errors::UnprocessableEntity, result
      when 400..499
        fail Errors::ClientError, result
      when 500
        fail Errors::InternalServerError, result
      when 500..599
        fail Errors::ServerError, result
      else
        fail "unknown status #{result.status}"
      end
    end

    def _build_body(body)
      if _needs_url_encoded?
        Faraday::Utils.build_nested_query(body)
      else
        body.to_json
      end
    end

    def _needs_url_encoded?
      @url =~ /oauth\/[a-z]+\z/
    end

    def _build_endpoint(path)
      @cache[path] ||= begin
        self.class.new(
          url: File.join(url, path.to_s),
          connection: @connection
        )
      end
    end

    def _build_url
      data_center = @basic_auth_key.split('-').last
      "https://#{data_center}.api.mailchimp.com/3.0"
    end

    def _build_connection
      Faraday.new(url: url) do |faraday|
        faraday.adapter :excon
        faraday.response :json, content_type: /\bjson$/
        if @basic_auth_key
          faraday.basic_auth '', @basic_auth_key
        elsif @oauth_access_token
          faraday.headers['Authorization'] = "Bearer #{@oauth_access_token}"
        else
          fail Errors::AuthRequiredError, "You must specify either HTTP basic auth credentials or an OAuth2 access token."
        end
      end
    end
  end
end

A  => lib/mailchimp3/errors.rb +38 -0
@@ 1,38 @@
module Mailchimp3
  module Errors
    class AuthRequiredError < StandardError; end

    class BaseError < StandardError
      attr_reader :status, :details

      def initialize(response)
        @status = response.status
        @message = if response.body.is_a?(Hash)
                     "#{response.body['title']}: #{response.body['detail']}"
                   else
                     response.body.to_s
                   end
        @details = response.body if response.body.is_a?(Hash)
      end

      def to_s
        @message
      end

      def inspect
        "<#{self.class.name} status=#{@status} message=#{@message}>"
      end
    end

    class ClientError              < BaseError;   end # 400..499
    class BadRequest               < ClientError; end # 400
    class Unauthorized             < ClientError; end # 401
    class Forbidden                < ClientError; end # 403
    class NotFound                 < ClientError; end # 404
    class MethodNotAllowed         < ClientError; end # 405
    class UnprocessableEntity      < ClientError; end # 422

    class ServerError              < BaseError;   end # 500..599
    class InternalServerError      < ServerError; end # 500
  end
end

A  => lib/mailchimp3/version.rb +3 -0
@@ 1,3 @@
module Mailchimp3
  VERSION = '0.1.0'
end

A  => mailchimp3.gemspec +26 -0
@@ 1,26 @@
$:.push File.expand_path('../lib', __FILE__)

require 'mailchimp3/version'

Gem::Specification.new do |s|
  s.name        = "mailchimp3"
  s.version     = Mailchimp3::VERSION
  s.homepage    = "https://github.com/seven1m/mailchimp3"
  s.summary     = "wrapper for MailChimp's 3.0 API"
  s.description = "mailchimp3 is a gem for working with MailChimp's RESTful JSON API documented at http://kb.mailchimp.com/api/ using HTTP basic auth or OAuth 2.0. This library can talk to any endpoint the API provides, since it is written to build endpoint URLs dynamically using method_missing."
  s.author      = "Tim Morgan"
  s.license     = "MIT"
  s.email       = "tim@timmorgan.org"

  s.required_ruby_version = '>= 2.0.0'

  s.files = Dir["lib/**/*", "README.md"]
  s.test_files = Dir["spec/**/*"]

  s.add_dependency "faraday", "~> 0.9.1"
  s.add_dependency "faraday_middleware", "~> 0.9.1"
  s.add_dependency "excon", "~> 0.45.3"
  s.add_development_dependency "rspec", "~> 3.2"
  s.add_development_dependency "webmock", "~> 1.21"
  s.add_development_dependency "pry", "~> 0.10"
end

A  => spec/mailchimp3/endpoint_spec.rb +194 -0
@@ 1,194 @@
require_relative '../spec_helper'
require 'json'

describe Mailchimp3::Endpoint do
  let(:base) { described_class.new(basic_auth_key: 'key-us2') }

  subject { base }

  describe '#method_missing' do
    before do
      @result = subject.lists
    end

    it 'returns a wrapper object with updated url' do
      expect(@result).to be_a(described_class)
      expect(@result.url).to match(%r{/lists$})
    end
  end

  describe '#[]' do
    before do
      @result = subject.lists[1]
    end

    it 'returns a wrapper object with updated url' do
      expect(@result).to be_a(described_class)
      expect(@result.url).to match(%r{/lists/1$})
    end
  end

  describe '#get' do
    context 'given a good URL' do
      subject { base.lists }

      let(:result) do
        {
          'id'   => 'e8bcf09f6f',
          'name' => 'My List'
        }
      end

      before do
        stub_request(:get, 'https://us2.api.mailchimp.com/3.0/lists')
          .to_return(status: 200, body: { lists: result }.to_json, headers: { 'Content-Type' => 'application/json; charset=utf-8' })
        @result = subject.get
      end

      it 'returns the result of making a GET request to the endpoint' do
        expect(@result).to be_a(Hash)
        expect(@result['lists']).to eq(result)
      end
    end

    context 'given a non-existent URL' do
      subject { base.non_existent }

      let(:result) do
        {
          'status'  => 404,
          'title'  => 'Resource Not Found',
          'detail' => 'The requested resource could not be found.'
        }
      end

      before do
        stub_request(:get, 'https://us2.api.mailchimp.com/3.0/non_existent')
          .to_return(status: 404, body: result.to_json, headers: { 'Content-Type' => 'application/json; charset=utf-8' })
      end

      it 'raises a NotFound error' do
        error = begin
                  subject.get
                rescue Mailchimp3::Errors::NotFound => e
                  e
                end
        expect(error.status).to eq(404)
        expect(error.message).to eq('Resource Not Found: The requested resource could not be found.')
      end
    end

    context 'given a client error' do
      subject { base.error }

      let(:result) do
        {
          'status'  => 400,
          'title' => 'Bad request'
        }
      end

      before do
        stub_request(:get, 'https://us2.api.mailchimp.com/3.0/error')
          .to_return(status: 400, body: result.to_json, headers: { 'Content-Type' => 'application/json; charset=utf-8' })
      end

      it 'raises a ClientError error' do
        expect {
          subject.get
        }.to raise_error(Mailchimp3::Errors::ClientError)
      end
    end

    context 'given a server error' do
      subject { base.error }

      let(:result) do
        {
          'status'  => 500,
          'title' => 'System error has occurred'
        }
      end

      before do
        stub_request(:get, 'https://us2.api.mailchimp.com/3.0/error')
          .to_return(status: 500, body: result.to_json, headers: { 'Content-Type' => 'application/json; charset=utf-8' })
      end

      it 'raises a ServerError error' do
        expect {
          subject.get
        }.to raise_error(Mailchimp3::Errors::ServerError)
      end
    end
  end

  describe '#post' do
    subject { base.lists }

    let(:resource) do
      {
        'name' => 'Foo'
      }
    end

    let(:result) do
      {
        'id'   => 'd3ed40bd7c',
        'name' => 'Foo'
      }
    end

    before do
      stub_request(:post, 'https://us2.api.mailchimp.com/3.0/lists')
        .to_return(status: 201, body: result.to_json, headers: { 'Content-Type' => 'application/json; charset=utf-8' })
      @result = subject.post(resource)
    end

    it 'returns the result of making a POST request to the endpoint' do
      expect(@result).to eq(result)
    end
  end

  describe '#patch' do
    subject { base.lists['d3ed40bd7c'] }

    let(:resource) do
      {
        'id'   => 'd3ed40bd7c',
        'name' => 'Foo'
      }
    end

    let(:result) do
      {
        'id'   => 'd3ed40bd7c',
        'name' => 'Foo'
      }
    end

    before do
      stub_request(:patch, 'https://us2.api.mailchimp.com/3.0/lists/d3ed40bd7c')
        .to_return(status: 200, body: result.to_json, headers: { 'Content-Type' => 'application/json; charset=utf-8' })
      @result = subject.patch(resource)
    end

    it 'returns the result of making a PATCH request to the endpoint' do
      expect(@result).to eq(result)
    end
  end

  describe '#delete' do
    subject { base.lists['d3ed40bd7c'] }

    before do
      stub_request(:delete, 'https://us2.api.mailchimp.com/3.0/lists/d3ed40bd7c')
        .to_return(status: 204, body: '')
      @result = subject.delete
    end

    it 'returns true' do
      expect(@result).to eq(true)
    end
  end
end

A  => spec/spec_helper.rb +18 -0
@@ 1,18 @@
require 'webmock/rspec'

require_relative '../lib/mailchimp3'

RSpec.configure do |config|
  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true
  end

  config.color = true
  config.order = 'random'
  config.filter_run focus: true
  config.run_all_when_everything_filtered = true
end