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
+
+[](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