├── .gitignore ├── .travis.yml ├── CONTRIBUTORS.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── mailgun.rb ├── mailgun │ ├── address.rb │ ├── base.rb │ ├── bounce.rb │ ├── client.rb │ ├── complaint.rb │ ├── domain.rb │ ├── list.rb │ ├── list │ │ └── member.rb │ ├── log.rb │ ├── mailbox.rb │ ├── mailgun_error.rb │ ├── message.rb │ ├── route.rb │ ├── secure.rb │ ├── unsubscribe.rb │ └── webhook.rb └── multimap │ ├── .gitignore │ ├── LICENSE │ ├── README.rdoc │ ├── Rakefile │ ├── benchmarks │ ├── bm_nested_multimap_construction.rb │ └── bm_nested_multimap_lookup.rb │ ├── ext │ ├── extconf.rb │ └── nested_multimap_ext.c │ ├── extras │ └── graphing.rb │ ├── lib │ ├── multimap.rb │ ├── multiset.rb │ └── nested_multimap.rb │ └── spec │ ├── enumerable_examples.rb │ ├── hash_examples.rb │ ├── multimap_spec.rb │ ├── multiset_spec.rb │ ├── nested_multimap_spec.rb │ ├── set_examples.rb │ └── spec_helper.rb ├── mailgun.gemspec └── spec ├── address_spec.rb ├── base_spec.rb ├── bounce_spec.rb ├── client_spec.rb ├── complaint_spec.rb ├── domain_spec.rb ├── helpers └── mailgun_helper.rb ├── list ├── member_spec.rb └── message_spec.rb ├── list_spec.rb ├── log_spec.rb ├── mailbox_spec.rb ├── route_spec.rb ├── secure_spec.rb ├── spec_helper.rb ├── unsubscribe_spec.rb └── webhook_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .byebug_history 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0 4 | - 2.1 5 | - 2.2 6 | - 2.3.0 7 | 8 | before_install: 9 | - gem install bundler 10 | 11 | cache: bundler 12 | 13 | script: bundle exec rspec --color 14 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | * Alan deLevie / ([@adelevie](http://github.com/adelevie)) 2 | * Sending email 3 | 4 | * Andrés Bravo / ([@andresbravog](http://github.com/andresbravog)) 5 | * Support for bounces API 6 | 7 | * [@gabriel](http://github.com/gabriel) 8 | * Fix to accept domain in options 9 | 10 | * Kaz Walker / [@KazW](http://github.com/KazW>) 11 | * Syntax highlighting for readme 12 | 13 | * [@mirzac](http://github.com/mirzac>) 14 | * Fix to accept domain in options 15 | 16 | * [@kdayton-](http://github.com/kdayton->) 17 | * Support for domains API 18 | 19 | * Scott Carleton / [@scotterc](http://github.com/scotterc) 20 | * new functionality and improvements 21 | 22 | * Yomi Colledge / [@baphled](http://github.com/baphled) 23 | * For refactoring API configuration and some documentation 24 | 25 | * Gregory Hilkert / [@EpiphanyMachine](http://github.com/EpiphanyMachine) and [@asheeshchoksi](https://github.com/asheeshchoksi) 26 | * Support for Webhooks API 27 | * Support for Address API 28 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | group :development do 4 | gem "simplecov", :require => false, :group => :test 5 | end 6 | 7 | # Specify your gem's dependencies in mailgun.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2011 Bushido Inc. 3 | 4 | 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: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | 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. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mailgun rubygem 2 | 3 | ![](https://travis-ci.org/HashNuke/mailgun.svg?branch=master) 4 | 5 | This gem allows for idiomatic Mailgun usage from within ruby. Mailgun is a kickass email-as-a-service that lets you use email as if it made sense. Check it out at http://mailgun.net 6 | 7 | Mailgun exposes the following resources: 8 | 9 | * Sending email 10 | * Mailing Lists 11 | * Mailing List Members 12 | * Mailboxes 13 | * Routes 14 | * Log 15 | * Stats 16 | * Messages 17 | * Bounces 18 | * Unsubscribes 19 | * Complaints 20 | * Domain management 21 | * Webhook management 22 | * Address Validation 23 | 24 | Patches are welcome (and easy!). 25 | 26 | ## Sending mail using ActionMailer 27 | 28 | If you simply want to send mail using Mailgun, just set the smtp settings in the Rails application like the following. Replace wherever necessary in the following snippet :) 29 | ```ruby 30 | ActionMailer::Base.smtp_settings = { 31 | :port => 587, 32 | :address => 'smtp.mailgun.org', 33 | :user_name => 'postmaster@your.mailgun.domain', 34 | :password => 'mailgun-smtp-password', 35 | :domain => 'your.mailgun.domain', 36 | :authentication => :plain, 37 | } 38 | ActionMailer::Base.delivery_method = :smtp 39 | ``` 40 | 41 | ## Usage 42 | 43 | We mimic the ActiveRecord-style interface. 44 | 45 | 46 | #### Configuration 47 | ```ruby 48 | # Initialize your Mailgun object: 49 | Mailgun.configure do |config| 50 | config.api_key = 'your-api-key' 51 | config.domain = 'your-mailgun-domain' 52 | end 53 | 54 | @mailgun = Mailgun() 55 | 56 | # or alternatively: 57 | @mailgun = Mailgun(:api_key => 'your-api-key') 58 | ``` 59 | 60 | #### Sending Email 61 | ```ruby 62 | parameters = { 63 | :to => "cooldev@your.mailgun.domain", 64 | :subject => "missing tps reports", 65 | :text => "yeah, we're gonna need you to come in on friday...yeah.", 66 | :from => "lumberg.bill@initech.mailgun.domain" 67 | } 68 | @mailgun.messages.send_email(parameters) 69 | ``` 70 | #### 71 | 72 | #### Mailing Lists 73 | ```ruby 74 | # Create a mailing list 75 | @mailgun.lists.create "devs@your.mailgun.domain" 76 | 77 | # List all Mailing lists 78 | @mailgun.lists.list 79 | 80 | # Find a mailing list 81 | @mailgun.lists.find "devs@your.mailgun.domain" 82 | 83 | # Update a mailing list 84 | @mailgun.lists.update("devs@your.mailgun.domain", "developers@your.mailgun.domain", "Developers", "Develepor Mailing List") 85 | 86 | # Delete a mailing list 87 | @mailgun.lists.delete("developers@your.mailgun.domain") 88 | ``` 89 | 90 | #### Mailing List Members 91 | ```ruby 92 | # List all members within a mailing list 93 | @mailgun.list_members.list "devs@your.mailgun.domain" 94 | 95 | # Find a particular member in a list 96 | @mailgun.list_members.find "devs@your.mailgun.domain", "bond@mi6.co.uk" 97 | 98 | # Add a member to a list 99 | @mailgun.list_members.add "devs@your.mailgun.domain", "Q@mi6.co.uk" 100 | 101 | # Add multiple mailing list members to a list (limit 1,000 per call) 102 | @mailgun.list_members.add_multi "devs@your.mailgun.domain", [{"address": "Alice ", "vars": {"age": 26}}, {"name": "Bob", "address": "bob@example.com", "vars": {"age": 34}}].to_json, {:upsert => true} 103 | 104 | # Update a member on a list 105 | @mailgun.list_members.update "devs@your.mailgun.domain", "Q@mi6.co.uk", "Q", {:gender => 'male'}.to_json, :subscribed => 'no') 106 | 107 | # Remove a member from a list 108 | @mailgun.list_members.remove "devs@your.mailgun.domain", "M@mi6.co.uk" 109 | ``` 110 | 111 | #### Mailboxes 112 | ```ruby 113 | # Create a mailbox 114 | @mailgun.mailboxes.create "new-mailbox@your-domain.com", "password" 115 | 116 | # List all mailboxes that belong to a domain 117 | @mailgun.mailboxes.list "domain.com" 118 | 119 | # Destroy a mailbox (queue bond-villian laughter) 120 | # "I'm sorry Bond, it seems your mailbox will be... destroyed!" 121 | @mailbox.mailboxes.destroy "bond@mi6.co.uk" 122 | ``` 123 | 124 | #### Bounces 125 | ```ruby 126 | # List last bounces (100 limit) 127 | @mailgun.bounces.list 128 | 129 | # Find bounces 130 | @mailgun.bounces.find "user@ema.il" 131 | 132 | # Add bounce 133 | @maligun.bounces.add "user@ema.il" 134 | 135 | # Clean user bounces 136 | @mailbox.bounces.destroy "user@ema.il" 137 | ``` 138 | 139 | #### Routes 140 | ```ruby 141 | # Initialize your Mailgun object: 142 | @mailgun = Mailgun(:api_key => 'your-api-key') 143 | 144 | # Create a route 145 | # Give it a human-readable description for later, a priority 146 | # filters, and actions 147 | @mailgun.routes.create "Description for the new route", 1, 148 | [:match_recipient, "apowers@mi5.co.uk"], 149 | [[:forward, "http://my-site.com/incoming-mail-route"], 150 | [:stop]] 151 | 152 | # List all routes that belong to a domain 153 | # limit the query to 100 routes starting from 0 154 | @mailgun.routes.list 100, 0 155 | 156 | # Get the details of a route via its id 157 | @mailgun.routes.find "4e97c1b2ba8a48567f007fb6" 158 | 159 | # Update a route via its id 160 | # (all keys are optional) 161 | @mailgun.routes.update "4e97c1b2ba8a48567f007fb6", { 162 | :priority => 2, 163 | :expression => [:match_header, :subject, "*.support"], 164 | :actions => [[:forward, "http://new-site.com/incoming-emails"]] 165 | } 166 | 167 | # Destroy a route via its id 168 | @mailbox.routes.destroy "4e97c1b2ba8a48567f007fb6" 169 | ``` 170 | 171 | Supported route filters are: `:match_header`, `:match_recipient`, and `:catch_all` 172 | 173 | Supported route actions are: `:forward`, and `:stop` 174 | 175 | 176 | #### Domains 177 | ```ruby 178 | # Add a domain 179 | @mailgun.domains.create "example.com" 180 | 181 | # List all domains that belong to the account 182 | @mailgun.domains.list 183 | 184 | # Get info for a domain 185 | @mailgun.domains.find "example.com" 186 | 187 | # Remove a domain 188 | @mailbox.domains.delete "example.com" 189 | ``` 190 | 191 | #### Webhooks 192 | ```ruby 193 | # List of currently available webhooks 194 | @mailgun.webhooks.available_ids 195 | 196 | # Returns a list of webhooks set for the specified domain 197 | @mailgun.webhooks.list 198 | 199 | # Returns details about the webhook specified 200 | @mailgun.webhooks.find(:open) 201 | 202 | # Creates a new webhook 203 | # Note: Creating an Open or Click webhook will enable Open or Click tracking 204 | @mailgun.webhooks.create(:open, "http://bin.example.com/8de4a9c4") 205 | 206 | # Updates an existing webhook 207 | @mailgun.webhooks.update(:open, "http://bin.example.com/8de4a9c4") 208 | 209 | # Deletes an existing webhook 210 | # Note: Deleting an Open or Click webhook will disable Open or Click tracking 211 | @mailgun.webhooks.delete(:open) 212 | ``` 213 | 214 | #### Address Validation 215 | Requires the `public_api_key` to be set. The Mailgun public key is available in the My Account tab of the Control Panel. 216 | ```ruby 217 | # Given an arbitrary address, validates address based off defined checks 218 | @mailgun.addresses.validate('hello@example.com') 219 | ``` 220 | 221 | ## Making Your Changes 222 | 223 | * Fork the project (Github has really good step-by-step directions) 224 | 225 | * Start a feature/bugfix branch 226 | 227 | * Commit and push until you are happy with your contribution 228 | 229 | * Make sure to add tests for it. This is important so we don't break it in a future version unintentionally. 230 | 231 | * After making your changes, be sure to run the Mailgun tests using the `rspec spec` to make sure everything works. 232 | 233 | * Submit your change as a Pull Request and update the GitHub issue to let us know it is ready for review. 234 | 235 | 236 | 237 | 238 | ## TODO 239 | 240 | * Add skip and limit functionality 241 | * Distinguish failed in logs 242 | * Distinguish delivered in logs 243 | * Tracking? 244 | * Stats? 245 | * Campaign? 246 | 247 | 248 | ## Maintainers 249 | 250 | * Hairihan / [@hairihan](http://github.com/hairihan) 251 | 252 | 253 | ## Authors 254 | 255 | * Akash Manohar / [@HashNuke](http://github.com/HashNuke) 256 | * Sean Grove / [@sgrove](http://github.com/sgrove) 257 | 258 | See CONTRIBUTORS.md file for contributor credits. 259 | 260 | ## License 261 | 262 | Released under the MIT license. See LICENSE for more details. 263 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | -------------------------------------------------------------------------------- /lib/mailgun.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "multimap/lib/multimap" 3 | require "multimap/lib/multiset" 4 | require "multimap/lib/nested_multimap" 5 | 6 | require "mailgun/mailgun_error" 7 | require "mailgun/base" 8 | require "mailgun/domain" 9 | require "mailgun/route" 10 | require "mailgun/mailbox" 11 | require "mailgun/bounce" 12 | require "mailgun/unsubscribe" 13 | require "mailgun/webhook" 14 | require "mailgun/complaint" 15 | require "mailgun/log" 16 | require "mailgun/list" 17 | require "mailgun/list/member" 18 | require "mailgun/message" 19 | require "mailgun/secure" 20 | require "mailgun/address" 21 | require "mailgun/client" 22 | 23 | #require "startup" 24 | 25 | def Mailgun(options={}) 26 | options[:api_key] = Mailgun.api_key if Mailgun.api_key 27 | options[:domain] = Mailgun.domain if Mailgun.domain 28 | options[:webhook_url] = Mailgun.webhook_url if Mailgun.webhook_url 29 | options[:public_api_key] = Mailgun.public_api_key if Mailgun.public_api_key 30 | Mailgun::Base.new(options) 31 | end 32 | -------------------------------------------------------------------------------- /lib/mailgun/address.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | # https://documentation.mailgun.com/api-email-validation.html#email-validation 3 | class Address 4 | # Used internally, called from Mailgun::Base 5 | def initialize(mailgun) 6 | @mailgun = mailgun 7 | end 8 | 9 | # Given an arbitrary address, validates address based off defined checks 10 | def validate(email) 11 | Mailgun.submit :get, address_url('validate'), {:address => email} 12 | end 13 | 14 | private 15 | 16 | def address_url(action) 17 | "#{@mailgun.public_base_url}/address/#{action}" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/mailgun/base.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Base 3 | # Options taken from 4 | # http://documentation.mailgun.net/quickstart.html#authentication 5 | # * Mailgun host - location of mailgun api servers 6 | # * Procotol - http or https [default to https] 7 | # * API key and version 8 | # * Test mode - if enabled, doesn't actually send emails (see http://documentation.mailgun.net/user_manual.html#sending-in-test-mode) 9 | # * Domain - domain to use 10 | # * Webhook URL - default url to use if one is not specified in each request 11 | # * Public API Key - used for address endpoint 12 | def initialize(options) 13 | Mailgun.mailgun_host = options.fetch(:mailgun_host) { "api.mailgun.net" } 14 | Mailgun.protocol = options.fetch(:protocol) { "https" } 15 | Mailgun.api_version = options.fetch(:api_version) { "v3" } 16 | Mailgun.test_mode = options.fetch(:test_mode) { false } 17 | Mailgun.api_key = options.fetch(:api_key) { raise ArgumentError.new(":api_key is a required argument to initialize Mailgun") if Mailgun.api_key.nil? } 18 | Mailgun.domain = options.fetch(:domain) { nil } 19 | Mailgun.webhook_url = options.fetch(:webhook_url) { nil } 20 | Mailgun.public_api_key = options.fetch(:public_api_key) { nil } 21 | end 22 | 23 | # Returns the base url used in all Mailgun API calls 24 | def base_url 25 | "#{Mailgun.protocol}://api:#{Mailgun.api_key}@#{Mailgun.mailgun_host}/#{Mailgun.api_version}" 26 | end 27 | 28 | def public_base_url 29 | "#{Mailgun.protocol}://api:#{Mailgun.public_api_key}@#{Mailgun.mailgun_host}/#{Mailgun.api_version}" 30 | end 31 | 32 | # Returns an instance of Mailgun::Mailbox configured for the current API user 33 | def mailboxes(domain = Mailgun.domain) 34 | Mailgun::Mailbox.new(self, domain) 35 | end 36 | 37 | def messages(domain = Mailgun.domain) 38 | @messages ||= Mailgun::Message.new(self, domain) 39 | end 40 | 41 | def routes 42 | @routes ||= Mailgun::Route.new(self) 43 | end 44 | 45 | def bounces(domain = Mailgun.domain) 46 | Mailgun::Bounce.new(self, domain) 47 | end 48 | 49 | def domains 50 | Mailgun::Domain.new(self) 51 | end 52 | 53 | def unsubscribes(domain = Mailgun.domain) 54 | Mailgun::Unsubscribe.new(self, domain) 55 | end 56 | 57 | def webhooks(domain = Mailgun.domain, webhook_url = Mailgun.webhook_url) 58 | Mailgun::Webhook.new(self, domain, webhook_url) 59 | end 60 | 61 | def addresses(domain = Mailgun.domain) 62 | if Mailgun.public_api_key.nil? 63 | raise ArgumentError.new(":public_api_key is a required argument to validate addresses") 64 | end 65 | Mailgun::Address.new(self) 66 | end 67 | 68 | def complaints(domain = Mailgun.domain) 69 | Mailgun::Complaint.new(self, domain) 70 | end 71 | 72 | def log(domain=Mailgun.domain) 73 | Mailgun::Log.new(self, domain) 74 | end 75 | 76 | def lists 77 | @lists ||= Mailgun::MailingList.new(self) 78 | end 79 | 80 | def list_members(address) 81 | Mailgun::MailingList::Member.new(self, address) 82 | end 83 | 84 | def secure 85 | Mailgun::Secure.new(self) 86 | end 87 | end 88 | 89 | 90 | # Submits the API call to the Mailgun server 91 | def self.submit(method, url, parameters={}) 92 | begin 93 | JSON.parse(Client.new(url).send(method, parameters)) 94 | rescue => e 95 | error_code = e.http_code 96 | error_message = begin 97 | JSON(e.http_body)["message"] 98 | rescue JSON::ParserError 99 | '' 100 | end 101 | error = Mailgun::Error.new( 102 | :code => error_code || nil, 103 | :message => error_message || nil 104 | ) 105 | if error.handle.kind_of? Mailgun::ErrorBase 106 | raise error.handle 107 | else 108 | raise error 109 | end 110 | end 111 | end 112 | 113 | # 114 | # @TODO Create root module to give this a better home 115 | # 116 | class << self 117 | attr_accessor :api_key, 118 | :api_version, 119 | :protocol, 120 | :mailgun_host, 121 | :test_mode, 122 | :domain, 123 | :webhook_url, 124 | :public_api_key 125 | 126 | def configure 127 | yield self 128 | true 129 | end 130 | alias :config :configure 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/mailgun/bounce.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | 3 | # Interface to manage bounce lists 4 | # Refer - http://documentation.mailgun.net/api-bounces.html for optional params to pass 5 | class Bounce 6 | # Used internally, called from Mailgun::Base 7 | def initialize(mailgun, domain) 8 | @mailgun = mailgun 9 | @domain = domain 10 | end 11 | 12 | # List all bounces for a given domain 13 | # 14 | # @param options [Hash] options to populate to mailgun find 15 | # @option options limit (100) [Integer] limit of results 16 | # @option options skip (0) [Integer] number of results to skip 17 | # 18 | # @returns [Array] array of bouces 19 | def list(options={}) 20 | Mailgun.submit(:get, bounce_url, options)["items"] || [] 21 | end 22 | 23 | # Find bounce events for an email address 24 | # 25 | # @returns [] found bouce 26 | def find(email) 27 | Mailgun.submit :get, bounce_url(email) 28 | end 29 | 30 | # Creates a bounce for an email address 31 | # 32 | # @param email [String] email address to bounce 33 | # @param options [Hash] options to populate to mailgun bounce creation 34 | # @option options code (550) [Integer] Error code 35 | # @option options error ('') [String] Error description 36 | # 37 | # @returns [] created bouce 38 | def add(email, options={}) 39 | Mailgun.submit :post, bounce_url, {:address => email}.merge(options) 40 | end 41 | 42 | # Cleans the bounces for an email address 43 | def destroy(email) 44 | Mailgun.submit :delete, bounce_url(email) 45 | end 46 | 47 | private 48 | 49 | # Helper method to generate the proper url for Mailgun mailbox API calls 50 | def bounce_url(address=nil) 51 | "#{@mailgun.base_url}/#{@domain}/bounces#{'/' + address if address}" 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/mailgun/client.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Client 3 | attr_reader :url 4 | 5 | def initialize(url) 6 | @url = url 7 | end 8 | 9 | def get(params = {}) 10 | request_path = path 11 | request_path += "?#{URI.encode_www_form(params)}" if params.any? 12 | 13 | request = Net::HTTP::Get.new(request_path) 14 | 15 | make_request(request) 16 | end 17 | 18 | def post(params = {}) 19 | request = Net::HTTP::Post.new(path) 20 | request.set_form_data(params) 21 | 22 | make_request(request) 23 | end 24 | 25 | def put(params = {}) 26 | request = Net::HTTP::Put.new(path) 27 | request.set_form_data(params) 28 | 29 | make_request(request) 30 | end 31 | 32 | def delete(params = {}) 33 | request = Net::HTTP::Delete.new(path) 34 | request.set_form_data(params) 35 | 36 | make_request(request) 37 | end 38 | 39 | private 40 | 41 | def make_request(request) 42 | set_auth(request) 43 | response = http_client.request(request) 44 | 45 | check_for_errors(response) 46 | 47 | response.body 48 | end 49 | 50 | def check_for_errors(response) 51 | return if response.code == '200' 52 | 53 | error = ClientError.new 54 | error.http_code = response.code.to_i 55 | error.http_body = response.body 56 | raise error 57 | end 58 | 59 | def path 60 | parsed_url.path 61 | end 62 | 63 | def parsed_url 64 | @parsed_url ||= URI.parse url 65 | end 66 | 67 | def http_client 68 | http = Net::HTTP.new(mailgun_url.host, mailgun_url.port) 69 | http.use_ssl = true 70 | http 71 | end 72 | 73 | def mailgun_url 74 | URI.parse Mailgun().base_url 75 | end 76 | 77 | def set_auth(request) 78 | request.basic_auth(parsed_url.user, parsed_url.password) 79 | end 80 | end 81 | end 82 | 83 | module Mailgun 84 | class ClientError < StandardError 85 | attr_accessor :http_code, :http_body 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/mailgun/complaint.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | 3 | # Complaints interface. Refer to http://documentation.mailgun.net/api-complaints.html 4 | class Complaint 5 | # Used internally, called from Mailgun::Base 6 | def initialize(mailgun, domain) 7 | @mailgun = mailgun 8 | @domain = domain 9 | end 10 | 11 | # List all the users who have complained 12 | def list(options={}) 13 | Mailgun.submit(:get, complaint_url, options)["items"] || [] 14 | end 15 | 16 | # Find a complaint by email 17 | def find(email) 18 | Mailgun.submit :get, complaint_url(email) 19 | end 20 | 21 | # Add an email to the complaints list 22 | def add(email) 23 | Mailgun.submit :post, complaint_url, {:address => email} 24 | end 25 | 26 | # Removes a complaint by email 27 | def destroy(email) 28 | Mailgun.submit :delete, complaint_url(email) 29 | end 30 | 31 | private 32 | 33 | # Helper method to generate the proper url for Mailgun complaints API calls 34 | def complaint_url(address=nil) 35 | "#{@mailgun.base_url}/#{@domain}/complaints#{'/' + address if address}" 36 | end 37 | 38 | end 39 | end -------------------------------------------------------------------------------- /lib/mailgun/domain.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | 3 | # Interface to manage domains 4 | class Domain 5 | # Used internally, called from Mailgun::Base 6 | def initialize(mailgun) 7 | @mailgun = mailgun 8 | end 9 | 10 | # List all domains on the account 11 | def list(options={}) 12 | Mailgun.submit(:get, domain_url, options)["items"] || [] 13 | end 14 | 15 | # Find domain by name 16 | def find(domain) 17 | Mailgun.submit :get, domain_url(domain) 18 | end 19 | 20 | # Add domain to account 21 | def create(domain, opts = {}) 22 | opts = {name: domain}.merge(opts) 23 | Mailgun.submit :post, domain_url, opts 24 | end 25 | 26 | # Remves a domain from account 27 | def delete(domain) 28 | Mailgun.submit :delete, domain_url(domain) 29 | end 30 | 31 | # Verifies a domain from account (Check DNS Records Now from Mailgun Web UI) 32 | # The method is still in beta and you will need 33 | # access from Mailgun to use it 34 | def verify(domain) 35 | Mailgun.submit :put, "#{domain_url(domain)}/verify" 36 | end 37 | 38 | private 39 | 40 | # Helper method to generate the proper url for Mailgun domain API calls 41 | def domain_url(domain = nil) 42 | "#{@mailgun.base_url}/domains#{'/' + domain if domain}" 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/mailgun/list.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | 3 | # Mailing List functionality 4 | # Refer http://documentation.mailgun.net/api-mailinglists.html for optional parameters 5 | 6 | class MailingList 7 | # Used internally, called from Mailgun::Base 8 | def initialize(mailgun) 9 | @mailgun = mailgun 10 | end 11 | 12 | # List all mailing lists 13 | def list(options={}) 14 | response = Mailgun.submit(:get, list_url, options)["items"] || [] 15 | end 16 | 17 | # List a single mailing list by a given address 18 | def find(address) 19 | Mailgun.submit :get, list_url(address) 20 | end 21 | 22 | # Create a mailing list with a given address 23 | def create(address, options={}) 24 | params = {:address => address} 25 | Mailgun.submit :post, list_url, params.merge(options) 26 | end 27 | 28 | # Update a mailing list with a given address 29 | # with an optional new address, name or description 30 | def update(address, new_address, options={}) 31 | params = {:address => new_address} 32 | Mailgun.submit :put, list_url(address), params.merge(options) 33 | end 34 | 35 | # Deletes a mailing list with a given address 36 | def delete(address) 37 | Mailgun.submit :delete, list_url(address) 38 | end 39 | 40 | 41 | private 42 | 43 | # Helper method to generate the proper url for Mailgun mailbox API calls 44 | def list_url(address=nil) 45 | "#{@mailgun.base_url}/lists#{'/' + address if address}" 46 | end 47 | 48 | end 49 | end -------------------------------------------------------------------------------- /lib/mailgun/list/member.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | # List Member functionality 3 | # Refer Mailgun docs for optional params 4 | class MailingList::Member 5 | 6 | # Used internally, called from Mailgun::Base 7 | def initialize(mailgun, address) 8 | @mailgun = mailgun 9 | @address = address 10 | end 11 | 12 | # List all mailing list members 13 | def list(options={}) 14 | response = Mailgun.submit(:get, list_member_url, options)["items"] 15 | end 16 | 17 | # List a single mailing list member by a given address 18 | def find(member_address) 19 | Mailgun.submit :get, list_member_url(member_address) 20 | end 21 | 22 | 23 | # Adds a mailing list member with a given address 24 | # NOTE Use create instead of add? 25 | def add(member_address, options={}) 26 | params = {:address => member_address} 27 | Mailgun.submit :post, list_member_url, params.merge(options) 28 | end 29 | 30 | def add_multi(members=[], options={}) 31 | params = {:members => members} 32 | Mailgun.submit :post, list_member_json, params.merge(options) 33 | end 34 | 35 | # TODO add spec? 36 | alias_method :create, :add 37 | 38 | # Update a mailing list member with a given address 39 | def update(member_address, options={}) 40 | params = {:address => member_address} 41 | Mailgun.submit :put, list_member_url(member_address), params.merge(options) 42 | end 43 | 44 | # Deletes a mailing list member with a given address 45 | def remove(member_address) 46 | Mailgun.submit :delete, list_member_url(member_address) 47 | end 48 | 49 | 50 | private 51 | 52 | # Helper method to generate the proper url for Mailgun mailbox API calls 53 | def list_member_url(member_address=nil) 54 | "#{@mailgun.base_url}/lists#{'/' + @address}/members#{'/' + member_address if member_address}" 55 | end 56 | 57 | def list_member_json 58 | "#{@mailgun.base_url}/lists#{'/' + @address}/members.json" 59 | end 60 | end 61 | end -------------------------------------------------------------------------------- /lib/mailgun/log.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Log 3 | # Used internally, called from Mailgun::Base 4 | def initialize(mailgun, domain) 5 | @mailgun = mailgun 6 | @domain = domain 7 | end 8 | 9 | # List all logs for a given domain 10 | # * domain the domain for which all complaints will listed 11 | def list(options={}) 12 | Mailgun.submit(:get, log_url, options) 13 | end 14 | 15 | private 16 | 17 | # Helper method to generate the proper url for Mailgun complaints API calls 18 | def log_url 19 | "#{@mailgun.base_url}/#{@domain}/log" 20 | end 21 | 22 | end 23 | end -------------------------------------------------------------------------------- /lib/mailgun/mailbox.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Mailbox 3 | 4 | # Used internally, called from Mailgun::Base 5 | def initialize(mailgun, domain) 6 | @mailgun = mailgun 7 | @domain = domain 8 | end 9 | 10 | # List all mailboxes for a given domain 11 | # * domain the domain for which all mailboxes will listed 12 | def list(options={}) 13 | Mailgun.submit(:get, mailbox_url, options)["items"] 14 | end 15 | 16 | 17 | # Creates a mailbox on the Mailgun server with the given password 18 | def create(mailbox_name, password) 19 | address = "#{mailbox_name}@#{@domain}" 20 | Mailgun.submit( 21 | :post, 22 | mailbox_url, 23 | { 24 | :mailbox => address, 25 | :password => password 26 | } 27 | ) 28 | end 29 | 30 | 31 | # Sets the password for a mailbox 32 | def update_password(mailbox_name, password) 33 | Mailgun.submit :put, mailbox_url(mailbox_name), :password => password 34 | end 35 | 36 | 37 | # Destroys the mailbox 38 | def destroy(mailbox_name) 39 | Mailgun.submit :delete, mailbox_url(mailbox_name) 40 | end 41 | 42 | 43 | private 44 | 45 | # Helper method to generate the proper url for Mailgun mailbox API calls 46 | def mailbox_url(mailbox_name=nil) 47 | "#{@mailgun.base_url}/#{@domain}/mailboxes#{'/' + mailbox_name if mailbox_name}" 48 | end 49 | 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/mailgun/mailgun_error.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Error 3 | attr_accessor :error 4 | 5 | def initialize(options={}) 6 | @error = 7 | case options[:code] 8 | when 200 9 | # Not an error 10 | when 404 11 | Mailgun::NotFound.new(options[:message]) 12 | when 400 13 | Mailgun::BadRequest.new(options[:message]) 14 | when 401 15 | Mailgun::Unauthorized.new(options[:message]) 16 | when 402 17 | Mailgun::ResquestFailed.new(options[:message]) 18 | when 500, 502, 503, 504 19 | Mailgun::ServerError.new(options[:message]) 20 | else 21 | Mailgun::ErrorBase.new(options[:message]) 22 | end 23 | end 24 | 25 | def handle 26 | return error.handle 27 | end 28 | end 29 | 30 | class ErrorBase < StandardError 31 | # Handles the error if needed 32 | # by default returns an error 33 | # 34 | # @return [type] [description] 35 | def handle 36 | return self 37 | end 38 | end 39 | 40 | class NotFound < ErrorBase 41 | def handle 42 | return nil 43 | end 44 | end 45 | 46 | class BadRequest < ErrorBase 47 | end 48 | 49 | class Unauthorized < ErrorBase 50 | end 51 | 52 | class ResquestFailed < ErrorBase 53 | end 54 | 55 | class ServerError < ErrorBase 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/mailgun/message.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Message 3 | def initialize(mailgun, domain) 4 | @mailgun = mailgun 5 | @domain = domain 6 | end 7 | 8 | # send email 9 | def send_email(parameters={}) 10 | # options: 11 | # :from, :to, :cc, :bcc, :subject, :text, :html 12 | # :with_attachment 13 | # :with_attachments 14 | # :at for delayed delivery time option 15 | # :in_test_mode BOOL. override the @use_test_mode setting 16 | # :tags to add tags to the email 17 | # :track BOOL 18 | Mailgun.submit(:post, messages_url, parameters) 19 | end 20 | 21 | #private 22 | 23 | # Helper method to generate the proper url for Mailgun message API calls 24 | def messages_url 25 | "#{@mailgun.base_url}/#{@domain}/messages" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mailgun/route.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Route 3 | def initialize(mailgun) 4 | @mailgun = mailgun 5 | end 6 | 7 | def list(options={}) 8 | Mailgun.submit(:get, route_url, options)["items"] || [] 9 | end 10 | 11 | def find(route_id) 12 | Mailgun.submit(:get, route_url(route_id))["route"] 13 | end 14 | 15 | def create(description, priority, filter, actions) 16 | data = ::Multimap.new 17 | 18 | data['priority'] = priority 19 | data['description'] = description 20 | data['expression'] = build_filter(filter) 21 | 22 | actions = build_actions(actions) 23 | 24 | actions.each do |action| 25 | data['action'] = action 26 | end 27 | 28 | data = data.to_hash 29 | 30 | # TODO: Raise an error or return false if unable to create route 31 | Mailgun.submit(:post, route_url, data)["route"]["id"] 32 | end 33 | 34 | def update(route_id, params) 35 | data = ::Multimap.new 36 | 37 | params = Hash[params.map{ |k, v| [k.to_s, v] }] 38 | 39 | ['priority', 'description'].each do |key| 40 | data[key] = params[key] if params.has_key?(key) 41 | end 42 | 43 | data['expression'] = build_filter(params['expression']) if params.has_key?('expression') 44 | 45 | if params.has_key?('actions') 46 | actions = build_actions(params['actions']) 47 | 48 | actions.each do |action| 49 | data['action'] = action 50 | end 51 | end 52 | 53 | data = data.to_hash 54 | 55 | Mailgun.submit(:put, route_url(route_id), data) 56 | end 57 | 58 | def destroy(route_id) 59 | Mailgun.submit(:delete, route_url(route_id))["id"] 60 | end 61 | 62 | private 63 | 64 | def route_url(route_id=nil) 65 | "#{@mailgun.base_url}/routes#{'/' + route_id if route_id}" 66 | end 67 | 68 | def build_actions(actions) 69 | _actions = [] 70 | 71 | actions.each do |action| 72 | case action.first.to_sym 73 | when :forward 74 | _actions << "forward(\"#{action.last}\")" 75 | when :stop 76 | _actions << "stop()" 77 | else 78 | raise Mailgun::Error.new("Unsupported action requested, see http://documentation.mailgun.net/user_manual.html#routes for a list of allowed actions") 79 | end 80 | end 81 | 82 | _actions 83 | end 84 | 85 | 86 | def build_filter(filter) 87 | case filter.first.to_sym 88 | when :match_recipient 89 | return "match_recipient('#{filter.last}')" 90 | when :match_header 91 | return "match_header('#{filter[1]}', '#{filter.last}')" 92 | when :catch_all 93 | return "catch_all()" 94 | else 95 | raise Mailgun::Error.new("Unsupported filter requested, see http://documentation.mailgun.net/user_manual.html#routes for a list of allowed filters") 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/mailgun/secure.rb: -------------------------------------------------------------------------------- 1 | # verifySignature: function(timestamp, token, signature, minutes_offset) { 2 | # var offset = Math.round((new Date()).getTime() / 1000) - (minutes_offset || 5) * 60; 3 | # if(timestamp < offset) 4 | # return false; 5 | # 6 | # var hmac = crypto.createHmac('sha256', api_key); 7 | # hmac.update(timestamp + token); 8 | # return signature == hmac.digest('hex'); 9 | # }, 10 | 11 | module Mailgun 12 | class Secure 13 | def initialize(mailgun) 14 | @mailgun = mailgun 15 | end 16 | 17 | # check request auth 18 | def check_request_auth(timestamp, token, signature, offset=-5) 19 | if offset != 0 20 | offset = Time.now.to_i + offset * 60 21 | return false if timestamp < offset 22 | end 23 | 24 | return signature == OpenSSL::HMAC.hexdigest( 25 | OpenSSL::Digest::Digest.new('sha256'), 26 | Mailgun.api_key, 27 | '%s%s' % [timestamp, token]) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/mailgun/unsubscribe.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | class Unsubscribe 3 | # Used internally, called from Mailgun::Base 4 | def initialize(mailgun, domain) 5 | @mailgun = mailgun 6 | @domain = domain 7 | end 8 | 9 | # List all unsubscribes for the domain 10 | def list(options={}) 11 | Mailgun.submit(:get, unsubscribe_url, options)["items"] 12 | end 13 | 14 | def find(email) 15 | Mailgun.submit :get, unsubscribe_url(email) 16 | end 17 | 18 | def add(email, tag='*') 19 | Mailgun.submit :post, unsubscribe_url, {:address => email, :tag => tag} 20 | end 21 | 22 | def remove(email) 23 | Mailgun.submit :delete, unsubscribe_url(email) 24 | end 25 | 26 | private 27 | 28 | # Helper method to generate the proper url for Mailgun unsubscribe API calls 29 | def unsubscribe_url(address=nil) 30 | "#{@mailgun.base_url}/#{@domain}/unsubscribes#{'/' + address if address}" 31 | end 32 | 33 | end 34 | end -------------------------------------------------------------------------------- /lib/mailgun/webhook.rb: -------------------------------------------------------------------------------- 1 | module Mailgun 2 | # Interface to manage webhooks 3 | # https://documentation.mailgun.com/api-webhooks.html#webhooks 4 | class Webhook 5 | attr_accessor :default_webhook_url, :domain 6 | 7 | # Used internally, called from Mailgun::Base 8 | def initialize(mailgun, domain, url) 9 | @mailgun = mailgun 10 | @domain = domain 11 | @default_webhook_url = url 12 | end 13 | 14 | # List of currently available webhooks 15 | def available_ids 16 | %w(bounce deliver drop spam unsubscribe click open).map(&:to_sym) 17 | end 18 | 19 | # Returns a list of webhooks set for the specified domain 20 | def list 21 | Mailgun.submit(:get, webhook_url)["webhooks"] || [] 22 | end 23 | 24 | # Returns details about the webhook specified 25 | def find(id) 26 | Mailgun.submit :get, webhook_url(id) 27 | end 28 | 29 | # Creates a new webhook 30 | # Note: Creating an Open or Click webhook will enable Open or Click tracking 31 | def create(id, url=default_webhook_url) 32 | params = {:id => id, :url => url} 33 | Mailgun.submit :post, webhook_url, params 34 | end 35 | 36 | # Updates an existing webhook 37 | def update(id, url=default_webhook_url) 38 | params = {:url => url} 39 | Mailgun.submit :put, webhook_url(id), params 40 | end 41 | 42 | # Deletes an existing webhook 43 | # Note: Deleting an Open or Click webhook will disable Open or Click tracking 44 | def delete(id) 45 | Mailgun.submit :delete, webhook_url(id) 46 | end 47 | 48 | private 49 | 50 | # Helper method to generate the proper url for Mailgun webhook API calls 51 | def webhook_url(id=nil) 52 | "#{@mailgun.base_url}/domains/#{domain}/webhooks#{'/' + id if id}" 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/multimap/.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle 2 | coverage/ 3 | html/ 4 | tmp/ 5 | -------------------------------------------------------------------------------- /lib/multimap/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Joshua Peek 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/multimap/README.rdoc: -------------------------------------------------------------------------------- 1 | = Multimap 2 | 3 | A Ruby multimap implementation that also includes multiset and nested multimap implementations. 4 | 5 | == Example 6 | 7 | require 'multimap' 8 | 9 | map = Multimap.new 10 | map["a"] = 100 11 | map["b"] = 200 12 | map["a"] = 300 13 | 14 | map["a"] # -> [100, 300] 15 | map["b"] # -> [200] 16 | map.keys # -> # 17 | -------------------------------------------------------------------------------- /lib/multimap/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems/specification' 2 | spec = eval(File.read('multimap.gemspec')) 3 | 4 | if spec.has_rdoc 5 | require 'rake/rdoctask' 6 | 7 | Rake::RDocTask.new { |rdoc| 8 | rdoc.options = spec.rdoc_options 9 | rdoc.rdoc_files = spec.files 10 | } 11 | end 12 | 13 | 14 | task :default => :spec 15 | 16 | require 'spec/rake/spectask' 17 | 18 | Spec::Rake::SpecTask.new do |t| 19 | t.warning = true 20 | end 21 | 22 | 23 | begin 24 | require 'rake/extensiontask' 25 | 26 | Rake::ExtensionTask.new do |ext| 27 | ext.name = 'nested_multimap_ext' 28 | ext.gem_spec = $spec 29 | end 30 | 31 | desc "Run specs using C ext" 32 | task "spec:ext" => [:compile, :spec, :clobber] 33 | rescue LoadError 34 | end 35 | -------------------------------------------------------------------------------- /lib/multimap/benchmarks/bm_nested_multimap_construction.rb: -------------------------------------------------------------------------------- 1 | $: << 'lib' 2 | require 'nested_multimap' 3 | 4 | tiny_mapping = { 5 | ["a"] => 100 6 | } 7 | 8 | medium_mapping = { 9 | ["a"] => 100, 10 | ["a", "b", "c"] => 200, 11 | ["b"] => 300, 12 | ["b", "c"] => 400, 13 | ["c"] => 500, 14 | ["c", "d"] => 600, 15 | ["c", "d", "e"] => 700, 16 | ["c", "d", "e", "f"] => 800 17 | } 18 | 19 | huge_mapping = {} 20 | alpha = ("a".."zz").to_a 21 | 100.times do |n| 22 | keys = ("a"..alpha[n % alpha.length]).to_a 23 | huge_mapping[keys] = n * 100 24 | end 25 | 26 | require 'benchmark' 27 | 28 | Benchmark.bmbm do |x| 29 | x.report("base:") { 30 | NestedMultimap.new 31 | } 32 | 33 | x.report("tiny:") { 34 | map = NestedMultimap.new 35 | tiny_mapping.each_pair { |keys, value| 36 | map[*keys] = value 37 | } 38 | } 39 | 40 | x.report("medium:") { 41 | map = NestedMultimap.new 42 | medium_mapping.each_pair { |keys, value| 43 | map[*keys] = value 44 | } 45 | } 46 | 47 | x.report("huge:") { 48 | map = NestedMultimap.new 49 | huge_mapping.each_pair { |keys, value| 50 | map[*keys] = value 51 | } 52 | } 53 | end 54 | 55 | # Pure Ruby 56 | # user system total real 57 | # base: 0.000000 0.000000 0.000000 ( 0.000014) 58 | # tiny: 0.000000 0.000000 0.000000 ( 0.000054) 59 | # medium: 0.000000 0.000000 0.000000 ( 0.000186) 60 | # huge: 0.050000 0.000000 0.050000 ( 0.051302) 61 | -------------------------------------------------------------------------------- /lib/multimap/benchmarks/bm_nested_multimap_lookup.rb: -------------------------------------------------------------------------------- 1 | $: << 'lib' 2 | require 'nested_multimap' 3 | 4 | hash = { "a" => true } 5 | 6 | map = NestedMultimap.new 7 | map["a"] = 100 8 | map["a", "b", "c"] = 200 9 | map["a", "b", "c", "d", "e", "f"] = 300 10 | 11 | require 'benchmark' 12 | 13 | TIMES = 100_000 14 | Benchmark.bmbm do |x| 15 | x.report("base:") { TIMES.times { hash["a"] } } 16 | x.report("best:") { TIMES.times { map["a"] } } 17 | x.report("average:") { TIMES.times { map["a", "b", "c"] } } 18 | x.report("worst:") { TIMES.times { map["a", "b", "c", "d", "e", "f"] } } 19 | end 20 | 21 | # Pure Ruby 22 | # user system total real 23 | # base: 0.050000 0.000000 0.050000 ( 0.049722) 24 | # best: 0.480000 0.010000 0.490000 ( 0.491012) 25 | # average: 0.770000 0.000000 0.770000 ( 0.773535) 26 | # worst: 1.120000 0.010000 1.130000 ( 1.139097) 27 | 28 | # C extension 29 | # user system total real 30 | # base: 0.050000 0.000000 0.050000 ( 0.050990) 31 | # best: 0.090000 0.000000 0.090000 ( 0.088981) 32 | # average: 0.130000 0.000000 0.130000 ( 0.132098) 33 | # worst: 0.150000 0.000000 0.150000 ( 0.158293) 34 | -------------------------------------------------------------------------------- /lib/multimap/ext/extconf.rb: -------------------------------------------------------------------------------- 1 | if RUBY_PLATFORM == 'java' 2 | File.open('Makefile', 'w') { |f| f.puts("install:\n\t$(echo Skipping native extensions)") } 3 | else 4 | require 'mkmf' 5 | create_makefile('nested_multimap_ext') 6 | end 7 | -------------------------------------------------------------------------------- /lib/multimap/ext/nested_multimap_ext.c: -------------------------------------------------------------------------------- 1 | #include "ruby.h" 2 | 3 | VALUE cNestedMultimap; 4 | 5 | static VALUE rb_nested_multimap_aref(int argc, VALUE *argv, VALUE self) 6 | { 7 | int i; 8 | VALUE r, h; 9 | 10 | for (i = 0, r = self; rb_obj_is_kind_of(r, cNestedMultimap) == Qtrue; i++) { 11 | h = rb_funcall(r, rb_intern("_internal_hash"), 0); 12 | Check_Type(h, T_HASH); 13 | r = (i < argc) ? rb_hash_aref(h, argv[i]) : RHASH(h)->ifnone; 14 | } 15 | 16 | return r; 17 | } 18 | 19 | void Init_nested_multimap_ext() { 20 | cNestedMultimap = rb_const_get(rb_cObject, rb_intern("NestedMultimap")); 21 | // rb_funcall(cNestedMultimap, rb_intern("remove_method"), 1, rb_intern("[]")); 22 | rb_eval_string("NestedMultimap.send(:remove_method, :[])"); 23 | rb_define_method(cNestedMultimap, "[]", rb_nested_multimap_aref, -1); 24 | } 25 | -------------------------------------------------------------------------------- /lib/multimap/extras/graphing.rb: -------------------------------------------------------------------------------- 1 | require 'graphviz' 2 | 3 | class Object 4 | def to_graph_node 5 | "node#{object_id}" 6 | end 7 | 8 | def to_graph_label 9 | inspect.dot_escape 10 | end 11 | 12 | def add_to_graph(graph) 13 | graph.add_node(to_graph_node, :label => to_graph_label) 14 | end 15 | end 16 | 17 | class Array 18 | def to_graph_label 19 | "{#{map { |e| e.to_graph_label }.join('|')}}" 20 | end 21 | end 22 | 23 | class String 24 | DOT_ESCAPE = %w( \\ < > { } ) 25 | DOT_ESCAPE_REGEXP = Regexp.compile("(#{Regexp.union(*DOT_ESCAPE).source})") 26 | 27 | def dot_escape 28 | gsub(DOT_ESCAPE_REGEXP) {|s| "\\#{s}" } 29 | end 30 | end 31 | 32 | class Multimap 33 | def to_graph_label 34 | label = [] 35 | @hash.each_key do |key| 36 | label << "<#{key.to_graph_node}> #{key.to_graph_label}" 37 | end 38 | "#{label.join('|')}|" 39 | end 40 | 41 | def add_to_graph(graph) 42 | hash_node = super 43 | 44 | @hash.each_pair do |key, container| 45 | node = container.add_to_graph(graph) 46 | graph.add_edge("#{hash_node.name}:#{key.to_graph_node}", node) 47 | end 48 | 49 | unless default.nil? 50 | node = default.add_to_graph(graph) 51 | graph.add_edge("#{hash_node.name}:default", node) 52 | end 53 | 54 | hash_node 55 | end 56 | 57 | def to_graph 58 | g = GraphViz::new('G') 59 | g[:nodesep] = '.05' 60 | g[:rankdir] = 'LR' 61 | 62 | g.node[:shape] = 'record' 63 | g.node[:width] = '.1' 64 | g.node[:height] = '.1' 65 | 66 | add_to_graph(g) 67 | 68 | g 69 | end 70 | 71 | def open_graph! 72 | to_graph.output(:png => '/tmp/graph.png') 73 | system('open /tmp/graph.png') 74 | end 75 | end 76 | 77 | if __FILE__ == $0 78 | $: << 'lib' 79 | require 'multimap' 80 | 81 | map = Multimap['a' => 100, 'b' => [200, 300]] 82 | map.open_graph! 83 | end 84 | -------------------------------------------------------------------------------- /lib/multimap/lib/multimap.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'multimap/lib/multiset' 3 | 4 | # Multimap is a generalization of a map or associative array 5 | # abstract data type in which more than one value may be associated 6 | # with and returned for a given key. 7 | # 8 | # == Example 9 | # 10 | # require 'multimap' 11 | # map = Multimap.new 12 | # map["a"] = 100 13 | # map["b"] = 200 14 | # map["a"] = 300 15 | # map["a"] # -> [100, 300] 16 | # map["b"] # -> [200] 17 | # map.keys # -> # 18 | class Multimap 19 | extend Forwardable 20 | 21 | include Enumerable 22 | 23 | # call-seq: 24 | # Multimap[ [key =>|, value]* ] => multimap 25 | # 26 | # Creates a new multimap populated with the given objects. 27 | # 28 | # Multimap["a", 100, "b", 200] #=> {"a"=>[100], "b"=>[200]} 29 | # Multimap["a" => 100, "b" => 200] #=> {"a"=>[100], "b"=>[200]} 30 | def self.[](*args) 31 | default = [] 32 | 33 | if args.size == 2 && args.last.is_a?(Hash) 34 | default = args.shift 35 | elsif !args.first.is_a?(Hash) && args.size % 2 == 1 36 | default = args.shift 37 | end 38 | 39 | if args.size == 1 && args.first.is_a?(Hash) 40 | args[0] = args.first.inject({}) { |hash, (key, value)| 41 | unless value.is_a?(default.class) 42 | value = (default.dup << value) 43 | end 44 | hash[key] = value 45 | hash 46 | } 47 | else 48 | index = 0 49 | args.map! { |value| 50 | unless index % 2 == 0 || value.is_a?(default.class) 51 | value = (default.dup << value) 52 | end 53 | index += 1 54 | value 55 | } 56 | end 57 | 58 | map = new 59 | map.instance_variable_set(:@hash, Hash[*args]) 60 | map.default = default 61 | map 62 | end 63 | 64 | # call-seq: 65 | # Multimap.new => multimap 66 | # Multimap.new(default) => multimap 67 | # 68 | # Returns a new, empty multimap. 69 | # 70 | # map = Multimap.new(Set.new) 71 | # h["a"] = 100 72 | # h["b"] = 200 73 | # h["a"] #=> [100].to_set 74 | # h["c"] #=> [].to_set 75 | def initialize(default = []) 76 | @hash = Hash.new(default) 77 | end 78 | 79 | def initialize_copy(original) #:nodoc: 80 | @hash = Hash.new(original.default.dup) 81 | original._internal_hash.each_pair do |key, container| 82 | @hash[key] = container.dup 83 | end 84 | end 85 | 86 | def_delegators :@hash, :clear, :default, :default=, :empty?, 87 | :fetch, :has_key?, :key? 88 | 89 | # Retrieves the value object corresponding to the 90 | # *keys object. 91 | def [](key) 92 | @hash[key] 93 | end 94 | 95 | # call-seq: 96 | # map[key] = value => value 97 | # map.store(key, value) => value 98 | # 99 | # Associates the value given by value with the key 100 | # given by key. Unlike a regular hash, multiple can be 101 | # assoicated with the same value. 102 | # 103 | # map = Multimap["a" => 100, "b" => 200] 104 | # map["a"] = 9 105 | # map["c"] = 4 106 | # map #=> {"a" => [100, 9], "b" => [200], "c" => [4]} 107 | def store(key, value) 108 | update_container(key) do |container| 109 | container << value 110 | container 111 | end 112 | end 113 | alias_method :[]=, :store 114 | 115 | # call-seq: 116 | # map.delete(key, value) => value 117 | # map.delete(key) => value 118 | # 119 | # Deletes and returns a key-value pair from map. If only 120 | # key is given, all the values matching that key will be 121 | # deleted. 122 | # 123 | # map = Multimap["a" => 100, "b" => [200, 300]] 124 | # map.delete("b", 300) #=> 300 125 | # map.delete("a") #=> [100] 126 | def delete(key, value = nil) 127 | if value 128 | @hash[key].delete(value) 129 | else 130 | @hash.delete(key) 131 | end 132 | end 133 | 134 | # call-seq: 135 | # map.each { |key, value| block } => map 136 | # 137 | # Calls block for each key/value pair in map, passing 138 | # the key and value to the block as a two-element array. 139 | # 140 | # map = Multimap["a" => 100, "b" => [200, 300]] 141 | # map.each { |key, value| puts "#{key} is #{value}" } 142 | # 143 | # produces: 144 | # 145 | # a is 100 146 | # b is 200 147 | # b is 300 148 | def each 149 | each_pair do |key, value| 150 | yield [key, value] 151 | end 152 | end 153 | 154 | # call-seq: 155 | # map.each_association { |key, container| block } => map 156 | # 157 | # Calls block once for each key/container in map, passing 158 | # the key and container to the block as parameters. 159 | # 160 | # map = Multimap["a" => 100, "b" => [200, 300]] 161 | # map.each_association { |key, container| puts "#{key} is #{container}" } 162 | # 163 | # produces: 164 | # 165 | # a is [100] 166 | # b is [200, 300] 167 | def each_association(&block) 168 | @hash.each_pair(&block) 169 | end 170 | 171 | # call-seq: 172 | # map.each_container { |container| block } => map 173 | # 174 | # Calls block for each container in map, passing the 175 | # container as a parameter. 176 | # 177 | # map = Multimap["a" => 100, "b" => [200, 300]] 178 | # map.each_container { |container| puts container } 179 | # 180 | # produces: 181 | # 182 | # [100] 183 | # [200, 300] 184 | def each_container 185 | each_association do |_, container| 186 | yield container 187 | end 188 | end 189 | 190 | # call-seq: 191 | # map.each_key { |key| block } => map 192 | # 193 | # Calls block for each key in hsh, passing the key 194 | # as a parameter. 195 | # 196 | # map = Multimap["a" => 100, "b" => [200, 300]] 197 | # map.each_key { |key| puts key } 198 | # 199 | # produces: 200 | # 201 | # a 202 | # b 203 | # b 204 | def each_key 205 | each_pair do |key, _| 206 | yield key 207 | end 208 | end 209 | 210 | # call-seq: 211 | # map.each_pair { |key_value_array| block } => map 212 | # 213 | # Calls block for each key/value pair in map, 214 | # passing the key and value as parameters. 215 | # 216 | # map = Multimap["a" => 100, "b" => [200, 300]] 217 | # map.each_pair { |key, value| puts "#{key} is #{value}" } 218 | # 219 | # produces: 220 | # 221 | # a is 100 222 | # b is 200 223 | # b is 300 224 | def each_pair 225 | each_association do |key, values| 226 | values.each do |value| 227 | yield key, value 228 | end 229 | end 230 | end 231 | 232 | # call-seq: 233 | # map.each_value { |value| block } => map 234 | # 235 | # Calls block for each key in map, passing the 236 | # value as a parameter. 237 | # 238 | # map = Multimap["a" => 100, "b" => [200, 300]] 239 | # map.each_value { |value| puts value } 240 | # 241 | # produces: 242 | # 243 | # 100 244 | # 200 245 | # 300 246 | def each_value 247 | each_pair do |_, value| 248 | yield value 249 | end 250 | end 251 | 252 | def ==(other) #:nodoc: 253 | case other 254 | when Multimap 255 | @hash == other._internal_hash 256 | else 257 | @hash == other 258 | end 259 | end 260 | 261 | def eql?(other) #:nodoc: 262 | case other 263 | when Multimap 264 | @hash.eql?(other._internal_hash) 265 | else 266 | @hash.eql?(other) 267 | end 268 | end 269 | 270 | def freeze #:nodoc: 271 | each_container { |container| container.freeze } 272 | default.freeze 273 | super 274 | end 275 | 276 | # call-seq: 277 | # map.has_value?(value) => true or false 278 | # map.value?(value) => true or false 279 | # 280 | # Returns true if the given value is present for any key 281 | # in map. 282 | # 283 | # map = Multimap["a" => 100, "b" => [200, 300]] 284 | # map.has_value?(300) #=> true 285 | # map.has_value?(999) #=> false 286 | def has_value?(value) 287 | values.include?(value) 288 | end 289 | alias_method :value?, :has_value? 290 | 291 | # call-seq: 292 | # map.index(value) => key 293 | # 294 | # Returns the key for a given value. If not found, returns 295 | # nil. 296 | # 297 | # map = Multimap["a" => 100, "b" => [200, 300]] 298 | # map.index(100) #=> "a" 299 | # map.index(200) #=> "b" 300 | # map.index(999) #=> nil 301 | def index(value) 302 | invert[value] 303 | end 304 | 305 | # call-seq: 306 | # map.delete_if {| key, value | block } -> map 307 | # 308 | # Deletes every key-value pair from map for which block 309 | # evaluates to true. 310 | # 311 | # map = Multimap["a" => 100, "b" => [200, 300]] 312 | # map.delete_if {|key, value| value >= 300 } 313 | # #=> Multimap["a" => 100, "b" => 200] 314 | # 315 | def delete_if 316 | each_association do |key, container| 317 | container.delete_if do |value| 318 | yield [key, value] 319 | end 320 | end 321 | self 322 | end 323 | 324 | # call-seq: 325 | # map.reject {| key, value | block } -> map 326 | # 327 | # Same as Multimap#delete_if, but works on (and returns) a 328 | # copy of the map. Equivalent to 329 | # map.dup.delete_if. 330 | # 331 | def reject(&block) 332 | dup.delete_if(&block) 333 | end 334 | 335 | # call-seq: 336 | # map.reject! {| key, value | block } -> map or nil 337 | # 338 | # Equivalent to Multimap#delete_if, but returns 339 | # nil if no changes were made. 340 | # 341 | def reject!(&block) 342 | old_size = size 343 | delete_if(&block) 344 | old_size == size ? nil : self 345 | end 346 | 347 | # call-seq: 348 | # map.replace(other_map) => map 349 | # 350 | # Replaces the contents of map with the contents of 351 | # other_map. 352 | # 353 | # map = Multimap["a" => 100, "b" => 200] 354 | # map.replace({ "c" => 300, "d" => 400 }) 355 | # #=> Multimap["c" => 300, "d" => 400] 356 | def replace(other) 357 | case other 358 | when Array 359 | @hash.replace(self.class[self.default, *other]) 360 | when Hash 361 | @hash.replace(self.class[self.default, other]) 362 | when self.class 363 | @hash.replace(other) 364 | else 365 | raise ArgumentError 366 | end 367 | end 368 | 369 | # call-seq: 370 | # map.invert => multimap 371 | # 372 | # Returns a new multimap created by using map's values as keys, 373 | # and the keys as values. 374 | # 375 | # map = Multimap["n" => 100, "m" => 100, "d" => [200, 300]] 376 | # map.invert #=> Multimap[100 => ["n", "m"], 200 => "d", 300 => "d"] 377 | def invert 378 | h = self.class.new(default.dup) 379 | each_pair { |key, value| h[value] = key } 380 | h 381 | end 382 | 383 | # call-seq: 384 | # map.keys => multiset 385 | # 386 | # Returns a new +Multiset+ populated with the keys from this hash. See also 387 | # Multimap#values and Multimap#containers. 388 | # 389 | # map = Multimap["a" => 100, "b" => [200, 300], "c" => 400] 390 | # map.keys #=> Multiset.new(["a", "b", "b", "c"]) 391 | def keys 392 | keys = Multiset.new 393 | each_key { |key| keys << key } 394 | keys 395 | end 396 | 397 | # Returns true if the given key is present in Multimap. 398 | def include?(key) 399 | keys.include?(key) 400 | end 401 | alias_method :member?, :include? 402 | 403 | # call-seq: 404 | # map.length => fixnum 405 | # map.size => fixnum 406 | # 407 | # Returns the number of key-value pairs in the map. 408 | # 409 | # map = Multimap["a" => 100, "b" => [200, 300], "c" => 400] 410 | # map.length #=> 4 411 | # map.delete("a") #=> 100 412 | # map.length #=> 3 413 | def size 414 | values.size 415 | end 416 | alias_method :length, :size 417 | 418 | # call-seq: 419 | # map.merge(other_map) => multimap 420 | # 421 | # Returns a new multimap containing the contents of other_map and 422 | # the contents of map. 423 | # 424 | # map1 = Multimap["a" => 100, "b" => 200] 425 | # map2 = Multimap["a" => 254, "c" => 300] 426 | # map2.merge(map2) #=> Multimap["a" => 100, "b" => [200, 254], "c" => 300] 427 | # map1 #=> Multimap["a" => 100, "b" => 200] 428 | def merge(other) 429 | dup.update(other) 430 | end 431 | 432 | # call-seq: 433 | # map.merge!(other_map) => multimap 434 | # map.update(other_map) => multimap 435 | # 436 | # Adds each pair from other_map to map. 437 | # 438 | # map1 = Multimap["a" => 100, "b" => 200] 439 | # map2 = Multimap["b" => 254, "c" => 300] 440 | # 441 | # map1.merge!(map2) 442 | # #=> Multimap["a" => 100, "b" => [200, 254], "c" => 300] 443 | def update(other) 444 | case other 445 | when self.class 446 | other.each_pair { |key, value| store(key, value) } 447 | when Hash 448 | update(self.class[self.default, other]) 449 | else 450 | raise ArgumentError 451 | end 452 | self 453 | end 454 | alias_method :merge!, :update 455 | 456 | # call-seq: 457 | # map.select { |key, value| block } => multimap 458 | # 459 | # Returns a new Multimap consisting of the pairs for which the 460 | # block returns true. 461 | # 462 | # map = Multimap["a" => 100, "b" => 200, "c" => 300] 463 | # map.select { |k,v| k > "a" } #=> Multimap["b" => 200, "c" => 300] 464 | # map.select { |k,v| v < 200 } #=> Multimap["a" => 100] 465 | def select 466 | inject(self.class.new) { |map, (key, value)| 467 | map[key] = value if yield([key, value]) 468 | map 469 | } 470 | end 471 | 472 | # call-seq: 473 | # map.to_a => array 474 | # 475 | # Converts map to a nested array of [key, 476 | # value] arrays. 477 | # 478 | # map = Multimap["a" => 100, "b" => [200, 300], "c" => 400] 479 | # map.to_a #=> [["a", 100], ["b", 200], ["b", 300], ["c", 400]] 480 | def to_a 481 | ary = [] 482 | each_pair do |key, value| 483 | ary << [key, value] 484 | end 485 | ary 486 | end 487 | 488 | # call-seq: 489 | # map.to_hash => hash 490 | # 491 | # Converts map to a basic hash. 492 | # 493 | # map = Multimap["a" => 100, "b" => [200, 300]] 494 | # map.to_hash #=> { "a" => [100], "b" => [200, 300] } 495 | def to_hash 496 | @hash.dup 497 | end 498 | 499 | # call-seq: 500 | # map.containers => array 501 | # 502 | # Returns a new array populated with the containers from map. See 503 | # also Multimap#keys and Multimap#values. 504 | # 505 | # map = Multimap["a" => 100, "b" => [200, 300]] 506 | # map.containers #=> [[100], [200, 300]] 507 | def containers 508 | containers = [] 509 | each_container { |container| containers << container } 510 | containers 511 | end 512 | 513 | # call-seq: 514 | # map.values => array 515 | # 516 | # Returns a new array populated with the values from map. See 517 | # also Multimap#keys and Multimap#containers. 518 | # 519 | # map = Multimap["a" => 100, "b" => [200, 300]] 520 | # map.values #=> [100, 200, 300] 521 | def values 522 | values = [] 523 | each_value { |value| values << value } 524 | values 525 | end 526 | 527 | # Return an array containing the values associated with the given keys. 528 | def values_at(*keys) 529 | @hash.values_at(*keys) 530 | end 531 | 532 | def marshal_dump #:nodoc: 533 | @hash 534 | end 535 | 536 | def marshal_load(hash) #:nodoc: 537 | @hash = hash 538 | end 539 | 540 | def to_yaml(opts = {}) #:nodoc: 541 | YAML::quick_emit(self, opts) do |out| 542 | out.map(taguri, to_yaml_style) do |map| 543 | @hash.each do |k, v| 544 | map.add(k, v) 545 | end 546 | map.add('__default__', @hash.default) 547 | end 548 | end 549 | end 550 | 551 | def yaml_initialize(tag, val) #:nodoc: 552 | default = val.delete('__default__') 553 | @hash = val 554 | @hash.default = default 555 | self 556 | end 557 | 558 | protected 559 | def _internal_hash #:nodoc: 560 | @hash 561 | end 562 | 563 | def update_container(key) #:nodoc: 564 | container = @hash[key] 565 | container = container.dup if container.equal?(default) 566 | container = yield(container) 567 | @hash[key] = container 568 | end 569 | end 570 | -------------------------------------------------------------------------------- /lib/multimap/lib/multiset.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | # Multiset implements a collection of unordered values and 4 | # allows duplicates. 5 | # 6 | # == Example 7 | # 8 | # require 'multiset' 9 | # s1 = Multiset.new [1, 2] # -> # 10 | # s1.add(2) # -> # 11 | # s1.merge([2, 6]) # -> # 12 | # s1.multiplicity(2) # -> 3 13 | # s1.multiplicity(3) # -> 1 14 | class Multiset < Set 15 | def initialize(*args, &block) #:nodoc: 16 | @hash = Hash.new(0) 17 | super 18 | end 19 | 20 | # Returns the number of times an element belongs to the multiset. 21 | def multiplicity(e) 22 | @hash[e] 23 | end 24 | 25 | # Returns the total number of elements in a multiset, including 26 | # repeated memberships 27 | def cardinality 28 | @hash.inject(0) { |s, (e, m)| s += m } 29 | end 30 | alias_method :size, :cardinality 31 | alias_method :length, :cardinality 32 | 33 | # Converts the set to an array. The order of elements is uncertain. 34 | def to_a 35 | inject([]) { |ary, (key, _)| ary << key } 36 | end 37 | 38 | # Returns true if the set is a superset of the given set. 39 | def superset?(set) 40 | set.is_a?(self.class) or raise ArgumentError, "value must be a set" 41 | return false if cardinality < set.cardinality 42 | set.all? { |o| set.multiplicity(o) <= multiplicity(o) } 43 | end 44 | 45 | # Returns true if the set is a proper superset of the given set. 46 | def proper_superset?(set) 47 | set.is_a?(self.class) or raise ArgumentError, "value must be a set" 48 | return false if cardinality <= set.cardinality 49 | set.all? { |o| set.multiplicity(o) <= multiplicity(o) } 50 | end 51 | 52 | # Returns true if the set is a subset of the given set. 53 | def subset?(set) 54 | set.is_a?(self.class) or raise ArgumentError, "value must be a set" 55 | return false if set.cardinality < cardinality 56 | all? { |o| multiplicity(o) <= set.multiplicity(o) } 57 | end 58 | 59 | # Returns true if the set is a proper subset of the given set. 60 | def proper_subset?(set) 61 | set.is_a?(self.class) or raise ArgumentError, "value must be a set" 62 | return false if set.cardinality <= cardinality 63 | all? { |o| multiplicity(o) <= set.multiplicity(o) } 64 | end 65 | 66 | # Calls the given block once for each element in the set, passing 67 | # the element as parameter. Returns an enumerator if no block is 68 | # given. 69 | def each 70 | @hash.each_pair do |key, multiplicity| 71 | multiplicity.times do 72 | yield(key) 73 | end 74 | end 75 | self 76 | end 77 | 78 | # Adds the given object to the set and returns self. Use +merge+ to 79 | # add many elements at once. 80 | def add(o) 81 | @hash[o] ||= 0 82 | @hash[o] += 1 83 | self 84 | end 85 | alias << add 86 | 87 | undef :add? 88 | 89 | # Deletes all the identical object from the set and returns self. 90 | # If +n+ is given, it will remove that amount of identical objects 91 | # from the set. Use +subtract+ to delete many different items at 92 | # once. 93 | def delete(o, n = nil) 94 | if n 95 | @hash[o] ||= 0 96 | @hash[o] -= n if @hash[o] > 0 97 | @hash.delete(o) if @hash[o] == 0 98 | else 99 | @hash.delete(o) 100 | end 101 | self 102 | end 103 | 104 | undef :delete? 105 | 106 | # Deletes every element of the set for which block evaluates to 107 | # true, and returns self. 108 | def delete_if 109 | each { |o| delete(o) if yield(o) } 110 | self 111 | end 112 | 113 | # Merges the elements of the given enumerable object to the set and 114 | # returns self. 115 | def merge(enum) 116 | enum.each { |o| add(o) } 117 | self 118 | end 119 | 120 | # Deletes every element that appears in the given enumerable object 121 | # and returns self. 122 | def subtract(enum) 123 | enum.each { |o| delete(o, 1) } 124 | self 125 | end 126 | 127 | # Returns a new set containing elements common to the set and the 128 | # given enumerable object. 129 | def &(enum) 130 | s = dup 131 | n = self.class.new 132 | enum.each { |o| 133 | if s.include?(o) 134 | s.delete(o, 1) 135 | n.add(o) 136 | end 137 | } 138 | n 139 | end 140 | alias intersection & 141 | 142 | # Returns a new set containing elements exclusive between the set 143 | # and the given enumerable object. (set ^ enum) is equivalent to 144 | # ((set | enum) - (set & enum)). 145 | def ^(enum) 146 | n = self.class.new(enum) 147 | each { |o| n.include?(o) ? n.delete(o, 1) : n.add(o) } 148 | n 149 | end 150 | 151 | # Returns true if two sets are equal. Two multisets are equal if 152 | # they have the same cardinalities and each element has the same 153 | # multiplicity in both sets. The equality of each element inside 154 | # the multiset is defined according to Object#eql?. 155 | def eql?(set) 156 | return true if equal?(set) 157 | set = self.class.new(set) unless set.is_a?(self.class) 158 | return false unless cardinality == set.cardinality 159 | superset?(set) && subset?(set) 160 | end 161 | alias_method :==, :eql? 162 | 163 | def marshal_dump #:nodoc: 164 | @hash 165 | end 166 | 167 | def marshal_load(hash) #:nodoc: 168 | @hash = hash 169 | end 170 | 171 | def to_yaml(opts = {}) #:nodoc: 172 | YAML::quick_emit(self, opts) do |out| 173 | out.map(taguri, to_yaml_style) do |map| 174 | @hash.each do |k, v| 175 | map.add(k, v) 176 | end 177 | end 178 | end 179 | end 180 | 181 | def yaml_initialize(tag, val) #:nodoc: 182 | @hash = val 183 | self 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /lib/multimap/lib/nested_multimap.rb: -------------------------------------------------------------------------------- 1 | require 'multimap/lib/multimap' 2 | 3 | # NestedMultimap allows values to be assoicated with a nested 4 | # set of keys. 5 | class NestedMultimap < Multimap 6 | # call-seq: 7 | # multimap[*keys] = value => value 8 | # multimap.store(*keys, value) => value 9 | # 10 | # Associates the value given by value with multiple key 11 | # given by keys. 12 | # 13 | # map = NestedMultimap.new 14 | # map["a"] = 100 15 | # map["a", "b"] = 101 16 | # map["a"] = 102 17 | # map #=> {"a"=>{"b"=>[100, 101, 102], default => [100, 102]}} 18 | def store(*args) 19 | keys = args 20 | value = args.pop 21 | 22 | raise ArgumentError, 'wrong number of arguments (1 for 2)' unless value 23 | 24 | if keys.length > 1 25 | update_container(keys.shift) do |container| 26 | container = self.class.new(container) unless container.is_a?(self.class) 27 | container[*keys] = value 28 | container 29 | end 30 | elsif keys.length == 1 31 | super(keys.first, value) 32 | else 33 | self << value 34 | end 35 | end 36 | alias_method :[]=, :store 37 | 38 | # call-seq: 39 | # multimap << obj => multimap 40 | # 41 | # Pushes the given object on to the end of all the containers. 42 | # 43 | # map = NestedMultimap["a" => [100], "b" => [200, 300]] 44 | # map << 300 45 | # map["a"] #=> [100, 300] 46 | # map["c"] #=> [300] 47 | def <<(value) 48 | @hash.each_value { |container| container << value } 49 | self.default << value 50 | self 51 | end 52 | 53 | # call-seq: 54 | # multimap[*keys] => value 55 | # multimap[key1, key2, key3] => value 56 | # 57 | # Retrieves the value object corresponding to the 58 | # *keys object. 59 | def [](*keys) 60 | i, l, r, k = 0, keys.length, self, self.class 61 | while r.is_a?(k) 62 | r = i < l ? r._internal_hash[keys[i]] : r.default 63 | i += 1 64 | end 65 | r 66 | end 67 | 68 | # call-seq: 69 | # multimap.each_association { |key, container| block } => multimap 70 | # 71 | # Calls block once for each key/container in map, passing 72 | # the key and container to the block as parameters. 73 | # 74 | # map = NestedMultimap.new 75 | # map["a"] = 100 76 | # map["a", "b"] = 101 77 | # map["a"] = 102 78 | # map["c"] = 200 79 | # map.each_association { |key, container| puts "#{key} is #{container}" } 80 | # 81 | # produces: 82 | # 83 | # ["a", "b"] is [100, 101, 102] 84 | # "c" is [200] 85 | def each_association 86 | super() do |key, container| 87 | if container.respond_to?(:each_association) 88 | container.each_association do |nested_key, value| 89 | yield [key, nested_key].flatten, value 90 | end 91 | else 92 | yield key, container 93 | end 94 | end 95 | end 96 | 97 | # call-seq: 98 | # multimap.each_container_with_default { |container| block } => map 99 | # 100 | # Calls block for every container in map including 101 | # the default, passing the container as a parameter. 102 | # 103 | # map = NestedMultimap.new 104 | # map["a"] = 100 105 | # map["a", "b"] = 101 106 | # map["a"] = 102 107 | # map.each_container_with_default { |container| puts container } 108 | # 109 | # produces: 110 | # 111 | # [100, 101, 102] 112 | # [100, 102] 113 | # [] 114 | def each_container_with_default(&block) 115 | @hash.each_value do |container| 116 | iterate_over_container(container, &block) 117 | end 118 | iterate_over_container(default, &block) 119 | self 120 | end 121 | 122 | # call-seq: 123 | # multimap.containers_with_default => array 124 | # 125 | # Returns a new array populated with all the containers from 126 | # map including the default. 127 | # 128 | # map = NestedMultimap.new 129 | # map["a"] = 100 130 | # map["a", "b"] = 101 131 | # map["a"] = 102 132 | # map.containers_with_default #=> [[100, 101, 102], [100, 102], []] 133 | def containers_with_default 134 | containers = [] 135 | each_container_with_default { |container| containers << container } 136 | containers 137 | end 138 | 139 | def inspect #:nodoc: 140 | super.gsub(/\}$/, ", default => #{default.inspect}}") 141 | end 142 | 143 | private 144 | def iterate_over_container(container) 145 | if container.respond_to?(:each_container_with_default) 146 | container.each_container_with_default do |value| 147 | yield value 148 | end 149 | else 150 | yield container 151 | end 152 | end 153 | end 154 | 155 | begin 156 | require 'nested_multimap_ext' 157 | rescue LoadError 158 | end 159 | -------------------------------------------------------------------------------- /lib/multimap/spec/enumerable_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for Enumerable, Multimap, "with inital values {'a' => [100], 'b' => [200, 300]}" do 2 | it "should check all key/value pairs for condition" do 3 | expect(@map.all? { |key, value| key =~ /\w/ }).to be_true 4 | expect(@map.all? { |key, value| key =~ /\d/ }).to be_false 5 | expect(@map.all? { |key, value| value > 0 }).to be_true 6 | expect(@map.all? { |key, value| value > 200 }).to be_false 7 | end 8 | 9 | it "should check any key/value pairs for condition" do 10 | expect(@map.any? { |key, value| key == "a" }).to be_true 11 | expect(@map.any? { |key, value| key == "z" }).to be_false 12 | expect(@map.any? { |key, value| value == 100 }).to be_true 13 | expect(@map.any? { |key, value| value > 1000 }).to be_false 14 | end 15 | 16 | it "should collect key/value pairs" do 17 | expect(@map.collect { |key, value| [key, value] }).to sorted_eql([["a", 100], ["b", 200], ["b", 300]]) 18 | expect(@map.map { |key, value| [key, value] }).to sorted_eql([["a", 100], ["b", 200], ["b", 300]]) 19 | end 20 | 21 | it "should detect key/value pair" do 22 | expect(@map.detect { |key, value| value > 200 }).to eql(["b", 300]) 23 | expect(@map.find { |key, value| value > 200 }).to eql(["b", 300]) 24 | end 25 | 26 | it "should return entries" do 27 | expect(@map.entries).to sorted_eql([["a", 100], ["b", 200], ["b", 300]]) 28 | expect(@map.to_a).to sorted_eql([["a", 100], ["b", 200], ["b", 300]]) 29 | end 30 | 31 | it "should find all key/value pairs" do 32 | expect(@map.find_all { |key, value| value >= 200 }).to eql([["b", 200], ["b", 300]]) 33 | expect(@map.select { |key, value| value >= 200 }).to eql(Multimap["b", [200, 300]]) 34 | end 35 | 36 | it "should combine key/value pairs with inject" do 37 | expect(@map.inject(0) { |sum, (key, value)| sum + value }).to eql(600) 38 | 39 | @map.inject(0) { |memo, (key, value)| 40 | memo > value ? memo : value 41 | expect(}).to eql(300) 42 | end 43 | 44 | it "should check for key membership" do 45 | expect(@map.member?("a")).to be_true 46 | expect(@map.include?("a")).to be_true 47 | expect(@map.member?("z")).to be_false 48 | expect(@map.include?("z")).to be_false 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/multimap/spec/hash_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for Hash, Multimap, "with inital values {'a' => [100], 'b' => [200, 300]}" do 2 | before do 3 | @container ||= Array 4 | end 5 | 6 | it "should be equal to another Multimap if they contain the same keys and values" do 7 | map2 = Multimap.new(@container.new) 8 | map2["a"] = 100 9 | map2["b"] = 200 10 | map2["b"] = 300 11 | expect(@map).to eql(map2) 12 | end 13 | 14 | it "should not be equal to another Multimap if they contain different values" do 15 | expect(@map).to_not == Multimap["a" => [100], "b" => [200]] 16 | end 17 | 18 | it "should retrieve container of values for key" do 19 | expect(@map["a"]).to eql(@container.new([100])) 20 | expect(@map["b"]).to eql(@container.new([200, 300])) 21 | expect(@map["z"]).to eql(@container.new) 22 | end 23 | 24 | it "should append values to container at key" do 25 | @map["a"] = 400 26 | @map.store("b", 500) 27 | expect(@map["a"]).to eql(@container.new([100, 400])) 28 | expect(@map["b"]).to eql(@container.new([200, 300, 500])) 29 | end 30 | 31 | it "should clear all key/values" do 32 | @map.clear 33 | expect(@map).to be_empty 34 | end 35 | 36 | it "should be the class of the container" do 37 | expect(@map.default.class).to eql(@container) 38 | end 39 | 40 | it "should delete all values at key" do 41 | @map.delete("a") 42 | expect(@map["a"]).to eql(@container.new) 43 | end 44 | 45 | it "should delete single value at key" do 46 | @map.delete("b", 200) 47 | expect(@map["b"]).to eql(@container.new([300])) 48 | end 49 | 50 | it "should delete if key condition is matched" do 51 | expect(@map.delete_if { |key, value| key >= "b" }).to eql(@map) 52 | expect(@map["a"]).to eql(@container.new([100])) 53 | expect(@map["b"]).to eql(@container.new) 54 | 55 | expect(@map.delete_if { |key, value| key > "z" }).to eql(@map) 56 | end 57 | 58 | it "should delete if value condition is matched" do 59 | expect(@map.delete_if { |key, value| value >= 300 }).to eql(@map) 60 | expect(@map["a"]).to eql(@container.new([100])) 61 | expect(@map["b"]).to eql(@container.new([200])) 62 | end 63 | 64 | it "should duplicate the containers" do 65 | map2 = @map.dup 66 | expect(map2).to_not equal(@map) 67 | expect(map2).to eql(@map) 68 | expect(map2["a"]).to_not equal(@map["a"]) 69 | expect(map2["b"]).to_not equal(@map["b"]) 70 | expect(map2.default).to_not equal(@map.default) 71 | expect(map2.default).to eql(@map.default) 72 | end 73 | 74 | it "should freeze containers" do 75 | @map.freeze 76 | expect(@map).to be_frozen 77 | expect(@map["a"]).to be_frozen 78 | expect(@map["b"]).to be_frozen 79 | end 80 | 81 | it "should iterate over each key/value pair and yield an array" do 82 | a = [] 83 | @map.each { |pair| a << pair } 84 | expect(a).to sorted_eql([["a", 100], ["b", 200], ["b", 300]]) 85 | end 86 | 87 | it "should iterate over each container" do 88 | a = [] 89 | @map.each_container { |container| a << container } 90 | expect(a).to sorted_eql([@container.new([100]), @container.new([200, 300])]) 91 | end 92 | 93 | it "should iterate over each key/container" do 94 | a = [] 95 | @map.each_association { |key, container| a << [key, container] } 96 | expect(a).to sorted_eql([["a", @container.new([100])], ["b", @container.new([200, 300])]]) 97 | end 98 | 99 | it "should iterate over each key" do 100 | a = [] 101 | @map.each_key { |key| a << key } 102 | expect(a).to sorted_eql(["a", "b", "b"]) 103 | end 104 | 105 | it "should iterate over each key/value pair and yield the pair" do 106 | h = {} 107 | @map.each_pair { |key, value| (h[key] ||= []) << value } 108 | expect(h).to eql({ "a" => [100], "b" => [200, 300] }) 109 | end 110 | 111 | it "should iterate over each value" do 112 | a = [] 113 | @map.each_value { |value| a << value } 114 | expect(a).to sorted_eql([100, 200, 300]) 115 | end 116 | 117 | it "should be empty if there are no key/value pairs" do 118 | @map.clear 119 | expect(@map).to be_empty 120 | end 121 | 122 | it "should not be empty if there are any key/value pairs" do 123 | expect(@map).to_not be_empty 124 | end 125 | 126 | it "should fetch container of values for key" do 127 | expect(@map.fetch("a")).to eql(@container.new([100])) 128 | expect(@map.fetch("b")).to eql(@container.new([200, 300])) 129 | expect(lambda { @map.fetch("z") }).to raise_error(IndexError) 130 | end 131 | 132 | it "should check if key is present" do 133 | expect(@map.has_key?("a")).to be_true 134 | expect(@map.key?("a")).to be_true 135 | expect(@map.has_key?("z")).to be_false 136 | expect(@map.key?("z")).to be_false 137 | end 138 | 139 | it "should check containers when looking up by value" do 140 | expect(@map.has_value?(100)).to be_true 141 | expect(@map.value?(100)).to be_true 142 | expect(@map.has_value?(999)).to be_false 143 | expect(@map.value?(999)).to be_false 144 | end 145 | 146 | it "it should return the index for value" do 147 | if @map.respond_to?(:index) 148 | expect(@map.index(200)).to eql(@container.new(["b"])) 149 | expect(@map.index(999)).to eql(@container.new) 150 | end 151 | end 152 | 153 | it "should replace the contents of hash" do 154 | @map.replace({ "c" => @container.new([300]), "d" => @container.new([400]) }) 155 | expect(@map["a"]).to eql(@container.new) 156 | expect(@map["c"]).to eql(@container.new([300])) 157 | end 158 | 159 | it "should return an inverted Multimap" do 160 | if @map.respond_to?(:invert) 161 | map2 = Multimap.new(@container.new) 162 | map2[100] = "a" 163 | map2[200] = "b" 164 | map2[300] = "b" 165 | expect(@map.invert).to eql(map2) 166 | end 167 | end 168 | 169 | it "should return array of keys" do 170 | expect(@map.keys).to eql(["a", "b", "b"]) 171 | end 172 | 173 | it "should return the number of key/value pairs" do 174 | expect(@map.length).to eql(3) 175 | expect(@map.size).to eql(3) 176 | end 177 | 178 | it "should duplicate map and with merged values" do 179 | map = @map.merge("b" => 254, "c" => @container.new([300])) 180 | expect(map["a"]).to eql(@container.new([100])) 181 | expect(map["b"]).to eql(@container.new([200, 300, 254])) 182 | expect(map["c"]).to eql(@container.new([300])) 183 | 184 | expect(@map["a"]).to eql(@container.new([100])) 185 | expect(@map["b"]).to eql(@container.new([200, 300])) 186 | expect(@map["c"]).to eql(@container.new) 187 | end 188 | 189 | it "should update map" do 190 | @map.update("b" => 254, "c" => @container.new([300])) 191 | expect(@map["a"]).to eql(@container.new([100])) 192 | expect(@map["b"]).to eql(@container.new([200, 300, 254])) 193 | expect(@map["c"]).to eql(@container.new([300])) 194 | 195 | klass = @map.class 196 | @map.update(klass[@container.new, {"a" => @container.new([400, 500]), "c" => 600}]) 197 | expect(@map["a"]).to eql(@container.new([100, 400, 500])) 198 | expect(@map["b"]).to eql(@container.new([200, 300, 254])) 199 | expect(@map["c"]).to eql(@container.new([300, 600])) 200 | end 201 | 202 | it "should reject key pairs on copy of the map" do 203 | map = @map.reject { |key, value| key >= "b" } 204 | expect(map["b"]).to eql(@container.new) 205 | expect(@map["b"]).to eql(@container.new([200, 300])) 206 | end 207 | 208 | it "should reject value pairs on copy of the map" do 209 | map = @map.reject { |key, value| value >= 300 } 210 | expect(map["b"]).to eql(@container.new([200])) 211 | expect(@map["b"]).to eql(@container.new([200, 300])) 212 | end 213 | 214 | it "should reject key pairs" do 215 | expect(@map.reject! { |key, value| key >= "b" }).to eql(@map) 216 | expect(@map["a"]).to eql(@container.new([100])) 217 | expect(@map["b"]).to eql(@container.new) 218 | 219 | expect(@map.reject! { |key, value| key >= "z" }).to eql(nil) 220 | end 221 | 222 | it "should reject value pairs" do 223 | expect(@map.reject! { |key, value| value >= 300 }).to eql(@map) 224 | expect(@map["a"]).to eql(@container.new([100])) 225 | expect(@map["b"]).to eql(@container.new([200])) 226 | 227 | expect(@map.reject! { |key, value| key >= "z" }).to eql(nil) 228 | end 229 | 230 | it "should select key/value pairs" do 231 | expect(@map.select { |k, v| k > "a" }).to eql(Multimap["b", [200, 300]]) 232 | expect(@map.select { |k, v| v < 200 }).to eql(Multimap["a", 100]) 233 | end 234 | 235 | it "should convert to hash" do 236 | expect(@map.to_hash["a"]).to eql(@container.new([100])) 237 | expect(@map.to_hash["b"]).to eql(@container.new([200, 300])) 238 | expect(@map.to_hash).to_not equal(@map) 239 | end 240 | 241 | it "should return all containers" do 242 | expect(@map.containers).to sorted_eql([@container.new([100]), @container.new([200, 300])]) 243 | end 244 | 245 | it "should return all values" do 246 | expect(@map.values).to sorted_eql([100, 200, 300]) 247 | end 248 | 249 | it "should return return values at keys" do 250 | expect(@map.values_at("a", "b")).to eql([@container.new([100]), @container.new([200, 300])]) 251 | end 252 | 253 | it "should marshal hash" do 254 | data = Marshal.dump(@map) 255 | expect(Marshal.load(data)).to eql(@map) 256 | end 257 | 258 | it "should dump yaml" do 259 | require 'yaml' 260 | 261 | data = YAML.dump(@map) 262 | expect(YAML.load(data)).to eql(@map) 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /lib/multimap/spec/multimap_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Multimap, "with inital values {'a' => [100], 'b' => [200, 300]}" do 4 | it_should_behave_like "Enumerable Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 5 | it_should_behave_like "Hash Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 6 | 7 | before do 8 | @map = Multimap["a" => 100, "b" => [200, 300]] 9 | end 10 | end 11 | 12 | describe Multimap, "with inital values {'a' => [100], 'b' => [200, 300]}" do 13 | it_should_behave_like "Enumerable Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 14 | it_should_behave_like "Hash Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 15 | 16 | before do 17 | @map = Multimap["a", 100, "b", [200, 300]] 18 | end 19 | end 20 | 21 | describe Multimap, "with", Set do 22 | it_should_behave_like "Enumerable Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 23 | it_should_behave_like "Hash Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 24 | 25 | before do 26 | @container = Set 27 | @map = Multimap.new(@container.new) 28 | @map["a"] = 100 29 | @map["b"] = 200 30 | @map["b"] = 300 31 | end 32 | end 33 | 34 | describe Multimap, "with", MiniArray do 35 | it_should_behave_like "Enumerable Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 36 | it_should_behave_like "Hash Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 37 | 38 | before do 39 | @container = MiniArray 40 | @map = Multimap.new(@container.new) 41 | @map["a"] = 100 42 | @map["b"] = 200 43 | @map["b"] = 300 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/multimap/spec/multiset_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Multiset do 4 | it_should_behave_like "Set" 5 | 6 | it "should return the multiplicity of the element" do 7 | set = Multiset.new([:a, :a, :b, :b, :b, :c]) 8 | expect(set.multiplicity(:a)).to eql(2) 9 | expect(set.multiplicity(:b)).to eql(3) 10 | expect(set.multiplicity(:c)).to eql(1) 11 | end 12 | 13 | it "should return the cardinality of the set" do 14 | set = Multiset.new([:a, :a, :b, :b, :b, :c]) 15 | expect(set.cardinality).to eql(6) 16 | end 17 | 18 | it "should be eql" do 19 | s1 = Multiset.new([:a, :b]) 20 | s2 = Multiset.new([:b, :a]) 21 | expect(s1).to eql(s2) 22 | 23 | s1 = Multiset.new([:a, :a]) 24 | s2 = Multiset.new([:a]) 25 | expect(s1).to_not eql(s2) 26 | end 27 | 28 | it "should replace the contents of the set" do 29 | set = Multiset[:a, :b, :b, :c] 30 | ret = set.replace(Multiset[:a, :a, :b, :b, :b, :c]) 31 | 32 | expect(set).to equal(ret) 33 | expect(set).to eql(Multiset[:a, :a, :b, :b, :b, :c]) 34 | 35 | set = Multiset[:a, :b, :b, :c] 36 | ret = set.replace([:a, :a, :b, :b, :b, :c]) 37 | 38 | expect(set).to equal(ret) 39 | expect(set).to eql(Multiset[:a, :a, :b, :b, :b, :c]) 40 | end 41 | 42 | it "should return true if the set is a superset of the given set" do 43 | set = Multiset[1, 2, 2, 3] 44 | 45 | expect(set.superset?(Multiset[])).to be_true 46 | expect(set.superset?(Multiset[1, 2])).to be_true 47 | expect(set.superset?(Multiset[1, 2, 3])).to be_true 48 | expect(set.superset?(Multiset[1, 2, 2, 3])).to be_true 49 | expect(set.superset?(Multiset[1, 2, 2, 2])).to be_false 50 | expect(set.superset?(Multiset[1, 2, 3, 4])).to be_false 51 | expect(set.superset?(Multiset[1, 4])).to be_false 52 | end 53 | 54 | it "should return true if the set is a proper superset of the given set" do 55 | set = Multiset[1, 2, 2, 3, 3] 56 | 57 | expect(set.proper_superset?(Multiset[])).to be_true 58 | expect(set.proper_superset?(Multiset[1, 2])).to be_true 59 | expect(set.proper_superset?(Multiset[1, 2, 3])).to be_true 60 | expect(set.proper_superset?(Multiset[1, 2, 2, 3, 3])).to be_false 61 | expect(set.proper_superset?(Multiset[1, 2, 2, 2])).to be_false 62 | expect(set.proper_superset?(Multiset[1, 2, 3, 4])).to be_false 63 | expect(set.proper_superset?(Multiset[1, 4])).to be_false 64 | end 65 | 66 | it "should return true if the set is a subset of the given set" do 67 | set = Multiset[1, 2, 2, 3] 68 | 69 | expect(set.subset?(Multiset[1, 2, 2, 3, 4])).to be_true 70 | expect(set.subset?(Multiset[1, 2, 2, 3, 3])).to be_true 71 | expect(set.subset?(Multiset[1, 2, 2, 3])).to be_true 72 | expect(set.subset?(Multiset[1, 2, 3])).to be_false 73 | expect(set.subset?(Multiset[1, 2, 2])).to be_false 74 | expect(set.subset?(Multiset[1, 2, 3])).to be_false 75 | expect(set.subset?(Multiset[])).to be_false 76 | end 77 | 78 | it "should return true if the set is a proper subset of the given set" do 79 | set = Multiset[1, 2, 2, 3, 3] 80 | 81 | expect(set.proper_subset?(Multiset[1, 2, 2, 3, 3, 4])).to be_true 82 | expect(set.proper_subset?(Multiset[1, 2, 2, 3, 3])).to be_false 83 | expect(set.proper_subset?(Multiset[1, 2, 3])).to be_false 84 | expect(set.proper_subset?(Multiset[1, 2, 2])).to be_false 85 | expect(set.proper_subset?(Multiset[1, 2, 3])).to be_false 86 | expect(set.proper_subset?(Multiset[])).to be_false 87 | end 88 | 89 | it "should delete the objects from the set and return self" do 90 | set = Multiset[1, 2, 2, 3] 91 | 92 | ret = set.delete(4) 93 | expect(set).to equal(ret) 94 | expect(set).to eql(Multiset[1, 2, 2, 3]) 95 | 96 | ret = set.delete(2) 97 | expect(set).to eql(ret) 98 | expect(set).to eql(Multiset[1, 3]) 99 | end 100 | 101 | it "should delete the number objects from the set and return self" do 102 | set = Multiset[1, 2, 2, 3] 103 | 104 | ret = set.delete(2, 1) 105 | expect(set).to eql(ret) 106 | expect(set).to eql(Multiset[1, 2, 3]) 107 | end 108 | 109 | it "should merge the elements of the given enumerable object to the set and return self" do 110 | set = Multiset[1, 2, 3] 111 | ret = set.merge([2, 4, 5]) 112 | expect(set).to equal(ret) 113 | expect(set).to eql(Multiset[1, 2, 2, 3, 4, 5]) 114 | 115 | set = Multiset[1, 2, 3] 116 | ret = set.merge(Multiset[2, 4, 5]) 117 | expect(set).to equal(ret) 118 | expect(set).to eql(Multiset[1, 2, 2, 3, 4, 5]) 119 | end 120 | 121 | it "should delete every element that appears in the given enumerable object and return self" do 122 | set = Multiset[1, 2, 2, 3] 123 | ret = set.subtract([2, 4, 6]) 124 | expect(set).to equal(ret) 125 | expect(set).to eql(Multiset[1, 2, 3]) 126 | end 127 | 128 | it "should return a new set containing elements common to the set and the given enumerable object" do 129 | set = Multiset[1, 2, 2, 3, 4] 130 | 131 | ret = set & [2, 2, 4, 5] 132 | expect(set).to_not equal(ret) 133 | expect(ret).to eql(Multiset[2, 2, 4]) 134 | 135 | set = Multiset[1, 2, 3] 136 | 137 | ret = set & [1, 2, 2, 2] 138 | expect(set).to_not equal(ret) 139 | expect(ret).to eql(Multiset[1, 2]) 140 | end 141 | 142 | it "should return a new set containing elements exclusive between the set and the given enumerable object" do 143 | set = Multiset[1, 2, 3, 4, 5] 144 | ret = set ^ [2, 4, 5, 5] 145 | expect(set).to_not equal(ret) 146 | expect(ret).to eql(Multiset[1, 3, 5]) 147 | 148 | set = Multiset[1, 2, 4, 5, 5] 149 | ret = set ^ [2, 3, 4, 5] 150 | expect(set).to_not equal(ret) 151 | expect(ret).to eql(Multiset[1, 3, 5]) 152 | end 153 | 154 | it "should marshal set" do 155 | set = Multiset[1, 2, 3, 4, 5] 156 | data = Marshal.dump(set) 157 | expect(Marshal.load(data)).to eql(set) 158 | end 159 | 160 | it "should dump yaml" do 161 | require 'yaml' 162 | 163 | set = Multiset[1, 2, 3, 4, 5] 164 | data = YAML.dump(set) 165 | expect(YAML.load(data)).to eql(set) 166 | end 167 | end 168 | 169 | describe Multiset, "with inital values" do 170 | it_should_behave_like "Set with inital values [1, 2]" 171 | 172 | before do 173 | @set = Multiset.new([1, 2]) 174 | end 175 | 176 | it "should return the multiplicity of the element" do 177 | expect(@set.multiplicity(1)).to eql(1) 178 | expect(@set.multiplicity(2)).to eql(1) 179 | end 180 | 181 | it "should return the cardinality of the set" do 182 | expect(@set.cardinality).to eql(2) 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/multimap/spec/nested_multimap_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NestedMultimap, "with inital values" do 4 | it_should_behave_like "Enumerable Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 5 | it_should_behave_like "Hash Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 6 | 7 | before do 8 | @map = NestedMultimap["a" => [100], "b" => [200, 300]] 9 | end 10 | 11 | it "should set value at nested key" do 12 | @map["foo", "bar", "baz"] = 100 13 | expect(@map["foo", "bar", "baz"]).to eql([100]) 14 | end 15 | 16 | it "should allow nil keys to be set" do 17 | @map["b", nil] = 400 18 | @map["b", "c"] = 500 19 | 20 | expect(@map["a"]).to eql([100]) 21 | expect(@map["b"]).to eql([200, 300]) 22 | expect(@map["b", nil]).to eql([200, 300, 400]) 23 | expect(@map["b", "c"]).to eql([200, 300, 500]) 24 | end 25 | 26 | it "should treat missing keys as append to all" do 27 | @map[] = 400 28 | expect(@map["a"]).to eql([100, 400]) 29 | expect(@map["b"]).to eql([200, 300, 400]) 30 | expect(@map["c"]).to eql([400]) 31 | expect(@map[nil]).to eql([400]) 32 | end 33 | 34 | it "should append the value to default containers" do 35 | @map << 400 36 | expect(@map["a"]).to eql([100, 400]) 37 | expect(@map["b"]).to eql([200, 300, 400]) 38 | expect(@map["c"]).to eql([400]) 39 | expect(@map[nil]).to eql([400]) 40 | end 41 | 42 | it "should append the value to all containers" do 43 | @map << 500 44 | expect(@map["a"]).to eql([100, 500]) 45 | expect(@map["b"]).to eql([200, 300, 500]) 46 | expect(@map[nil]).to eql([500]) 47 | end 48 | 49 | it "default values should be copied to new containers" do 50 | @map << 300 51 | @map["x"] = 100 52 | expect(@map["x"]).to eql([300, 100]) 53 | end 54 | 55 | it "should list all containers" do 56 | expect(@map.containers).to sorted_eql([[100], [200, 300]]) 57 | end 58 | 59 | it "should list all values" do 60 | expect(@map.values).to sorted_eql([100, 200, 300]) 61 | end 62 | end 63 | 64 | describe NestedMultimap, "with nested values" do 65 | before do 66 | @map = NestedMultimap.new 67 | @map["a"] = 100 68 | @map["b"] = 200 69 | @map["b", "c"] = 300 70 | @map["c", "e"] = 400 71 | @map["c"] = 500 72 | end 73 | 74 | it "should retrieve container of values for key" do 75 | expect(@map["a"]).to eql([100]) 76 | expect(@map["b"]).to eql([200]) 77 | expect(@map["c"]).to eql([500]) 78 | expect(@map["a", "b"]).to eql([100]) 79 | expect(@map["b", "c"]).to eql([200, 300]) 80 | expect(@map["c", "e"]).to eql([400, 500]) 81 | end 82 | 83 | it "should append the value to default containers" do 84 | @map << 600 85 | expect(@map["a"]).to eql([100, 600]) 86 | expect(@map["b"]).to eql([200, 600]) 87 | expect(@map["c"]).to eql([500, 600]) 88 | expect(@map["a", "b"]).to eql([100, 600]) 89 | expect(@map["b", "c"]).to eql([200, 300, 600]) 90 | expect(@map["c", "e"]).to eql([400, 500, 600]) 91 | expect(@map[nil]).to eql([600]) 92 | end 93 | 94 | it "should duplicate the containers" do 95 | map2 = @map.dup 96 | expect(map2).to_not equal(@map) 97 | expect(map2).to eql(@map) 98 | 99 | expect(map2["a"]).to eql([100]) 100 | expect(map2["b"]).to eql([200]) 101 | expect(map2["c"]).to eql([500]) 102 | expect(map2["a", "b"]).to eql([100]) 103 | expect(map2["b", "c"]).to eql([200, 300]) 104 | expect(map2["c", "e"]).to eql([400, 500]) 105 | 106 | expect(map2["a"]).to_not equal(@map["a"]) 107 | expect(map2["b"]).to_not equal(@map["b"]) 108 | expect(map2["c"]).to_not equal(@map["c"]) 109 | expect(map2["a", "b"]).to_not equal(@map["a", "b"]) 110 | expect(map2["b", "c"]).to_not equal(@map["b", "c"]) 111 | expect(map2["c", "e"]).to_not equal(@map["c", "e"]) 112 | 113 | expect(map2.default).to_not equal(@map.default) 114 | expect(map2.default).to eql(@map.default) 115 | end 116 | 117 | it "should iterate over each key/value pair and yield an array" do 118 | a = [] 119 | @map.each { |pair| a << pair } 120 | expect(a).to sorted_eql([ 121 | ["a", 100], 122 | [["b", "c"], 200], 123 | [["b", "c"], 300], 124 | [["c", "e"], 400], 125 | [["c", "e"], 500] 126 | ]) 127 | end 128 | 129 | it "should iterate over each key/container" do 130 | a = [] 131 | @map.each_association { |key, container| a << [key, container] } 132 | expect(a).to sorted_eql([ 133 | ["a", [100]], 134 | [["b", "c"], [200, 300]], 135 | [["c", "e"], [400, 500]] 136 | ]) 137 | end 138 | 139 | it "should iterate over each container plus the default" do 140 | a = [] 141 | @map.each_container_with_default { |container| a << container } 142 | expect(a).to sorted_eql([ 143 | [100], 144 | [200, 300], 145 | [200], 146 | [400, 500], 147 | [500], 148 | [] 149 | ]) 150 | end 151 | 152 | it "should iterate over each key" do 153 | a = [] 154 | @map.each_key { |key| a << key } 155 | expect(a).to sorted_eql(["a", ["b", "c"], ["b", "c"], ["c", "e"], ["c", "e"]]) 156 | end 157 | 158 | it "should iterate over each key/value pair and yield the pair" do 159 | h = {} 160 | @map.each_pair { |key, value| (h[key] ||= []) << value } 161 | expect(h).to eql({ 162 | "a" => [100], 163 | ["c", "e"] => [400, 500], 164 | ["b", "c"] => [200, 300] 165 | }) 166 | end 167 | 168 | it "should iterate over each value" do 169 | a = [] 170 | @map.each_value { |value| a << value } 171 | expect(a).to sorted_eql([100, 200, 300, 400, 500]) 172 | end 173 | 174 | it "should list all containers" do 175 | expect(@map.containers).to sorted_eql([[100], [200, 300], [400, 500]]) 176 | end 177 | 178 | it "should list all containers plus the default" do 179 | expect(@map.containers_with_default).to sorted_eql([[100], [200, 300], [200], [400, 500], [500], []]) 180 | end 181 | 182 | it "should return array of keys" do 183 | expect(@map.keys).to eql(["a", ["b", "c"], ["b", "c"], ["c", "e"], ["c", "e"]]) 184 | end 185 | 186 | it "should list all values" do 187 | expect(@map.values).to sorted_eql([100, 200, 300, 400, 500]) 188 | end 189 | end 190 | 191 | describe NestedMultimap, "with", Set do 192 | it_should_behave_like "Enumerable Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 193 | it_should_behave_like "Hash Multimap with inital values {'a' => [100], 'b' => [200, 300]}" 194 | 195 | before do 196 | @container = Set 197 | @map = NestedMultimap.new(@container.new) 198 | @map["a"] = 100 199 | @map["b"] = 200 200 | @map["b"] = 300 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /lib/multimap/spec/set_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for Set do 2 | it "should create a new set containing the given objects" do 3 | Multiset[] 4 | Multiset[nil] 5 | Multiset[1, 2, 3] 6 | 7 | expect(Multiset[].size).to eql(0) 8 | expect(Multiset[nil].size).to eql(1) 9 | expect(Multiset[[]].size).to eql(1) 10 | expect(Multiset[[nil]].size).to eql(1) 11 | 12 | set = Multiset[2, 4, 6, 4] 13 | expect(Multiset.new([2, 4, 6])).to_not eql(set) 14 | 15 | set = Multiset[2, 4, 6, 4] 16 | expect(Multiset.new([2, 4, 6, 4])).to eql(set) 17 | end 18 | 19 | it "should create a new set containing the elements of the given enumerable object" do 20 | Multiset.new() 21 | Multiset.new(nil) 22 | Multiset.new([]) 23 | Multiset.new([1, 2]) 24 | Multiset.new('a'..'c') 25 | 26 | expect(lambda { Multiset.new(false) }).to raise_error 27 | expect(lambda { Multiset.new(1) }).to raise_error 28 | expect(lambda { Multiset.new(1, 2) }).to raise_error 29 | 30 | expect(Multiset.new().size).to eql(0) 31 | expect(Multiset.new(nil).size).to eql(0) 32 | expect(Multiset.new([]).size).to eql(0) 33 | expect(Multiset.new([nil]).size).to eql(1) 34 | 35 | ary = [2, 4, 6, 4] 36 | set = Multiset.new(ary) 37 | ary.clear 38 | expect(set).to_not be_empty 39 | expect(set.size).to eql(4) 40 | 41 | ary = [1, 2, 3] 42 | 43 | s = Multiset.new(ary) { |o| o * 2 } 44 | expect([2, 4, 6]).to eql(s.sort) 45 | end 46 | 47 | it "should duplicate set" do 48 | set1 = Multiset[1, 2] 49 | set2 = set1.dup 50 | 51 | expect(set1).to_not equal(set2) 52 | 53 | expect(set1).to eql(set2) 54 | 55 | set1.add(3) 56 | 57 | expect(set1).to_not eql(set2) 58 | end 59 | 60 | it "should return the number of elements" do 61 | expect(Multiset[].size).to eql(0) 62 | expect(Multiset[1, 2].size).to eql(2) 63 | expect(Multiset[1, 2, 1].size).to eql(3) 64 | end 65 | 66 | it "should return true if the set contains no elements" do 67 | expect(Multiset[]).to be_empty 68 | expect(Multiset[1, 2]).to_not be_empty 69 | end 70 | 71 | it "should remove all elements and returns self" do 72 | set = Multiset[1, 2] 73 | ret = set.clear 74 | 75 | expect(set).to equal(ret) 76 | expect(set).to be_empty 77 | end 78 | 79 | it "should replaces the contents of the set with the contents of the given enumerable object and returns self" do 80 | set = Multiset[1, 2] 81 | ret = set.replace('a'..'c') 82 | 83 | expect(set).to equal(ret) 84 | expect(set).to eql(Multiset['a', 'b', 'c']) 85 | end 86 | 87 | it "should convert the set to an array" do 88 | set = Multiset[1, 2, 3, 2] 89 | ary = set.to_a 90 | 91 | expect(ary.sort).to eql([1, 2, 2, 3]) 92 | end 93 | 94 | it "should return true if the set contains the given object" do 95 | set = Multiset[1, 2, 3] 96 | 97 | expect(set.include?(1)).to be_true 98 | expect(set.include?(2)).to be_true 99 | expect(set.include?(3)).to be_true 100 | expect(set.include?(0)).to be_false 101 | expect(set.include?(nil)).to be_false 102 | 103 | set = Multiset["1", nil, "2", nil, "0", "1", false] 104 | expect(set.include?(nil)).to be_true 105 | expect(set.include?(false)).to be_true 106 | expect(set.include?("1")).to be_true 107 | expect(set.include?(0)).to be_false 108 | expect(set.include?(true)).to be_false 109 | end 110 | 111 | it "should return true if the set is a superset of the given set" do 112 | set = Multiset[1, 2, 3] 113 | 114 | expect(lambda { set.superset?() }).to raise_error 115 | expect(lambda { set.superset?(2) }).to raise_error 116 | expect(lambda { set.superset?([2]) }).to raise_error 117 | 118 | expect(set.superset?(Multiset[])).to be_true 119 | expect(set.superset?(Multiset[1, 2])).to be_true 120 | expect(set.superset?(Multiset[1, 2, 3])).to be_true 121 | expect(set.superset?(Multiset[1, 2, 3, 4])).to be_false 122 | expect(set.superset?(Multiset[1, 4])).to be_false 123 | 124 | expect(Multiset[].superset?(Multiset[])).to be_true 125 | end 126 | 127 | it "should return true if the set is a proper superset of the given set" do 128 | set = Multiset[1, 2, 3] 129 | 130 | expect(lambda { set.proper_superset?() }).to raise_error 131 | expect(lambda { set.proper_superset?(2) }).to raise_error 132 | expect(lambda { set.proper_superset?([2]) }).to raise_error 133 | 134 | expect(set.proper_superset?(Multiset[])).to be_true 135 | expect(set.proper_superset?(Multiset[1, 2])).to be_true 136 | expect(set.proper_superset?(Multiset[1, 2, 3])).to be_false 137 | expect(set.proper_superset?(Multiset[1, 2, 3, 4])).to be_false 138 | expect(set.proper_superset?(Multiset[1, 4])).to be_false 139 | 140 | expect(Multiset[].proper_superset?(Multiset[])).to be_false 141 | end 142 | 143 | it "should return true if the set is a subset of the given set" do 144 | set = Multiset[1, 2, 3] 145 | 146 | expect(lambda { set.subset?() }).to raise_error 147 | expect(lambda { set.subset?(2) }).to raise_error 148 | expect(lambda { set.subset?([2]) }).to raise_error 149 | 150 | expect(set.subset?(Multiset[1, 2, 3, 4])).to be_true 151 | expect(set.subset?(Multiset[1, 2, 3])).to be_true 152 | expect(set.subset?(Multiset[1, 2])).to be_false 153 | expect(set.subset?(Multiset[])).to be_false 154 | 155 | expect(Multiset[].subset?(Multiset[1])).to be_true 156 | expect(Multiset[].subset?(Multiset[])).to be_true 157 | end 158 | 159 | it "should return true if the set is a proper subset of the given set" do 160 | set = Multiset[1, 2, 3] 161 | 162 | expect(lambda { set.proper_subset?() }).to raise_error 163 | expect(lambda { set.proper_subset?(2) }).to raise_error 164 | expect(lambda { set.proper_subset?([2]) }).to raise_error 165 | 166 | expect(set.proper_subset?(Multiset[1, 2, 3, 4])).to be_true 167 | expect(set.proper_subset?(Multiset[1, 2, 3])).to be_false 168 | expect(set.proper_subset?(Multiset[1, 2])).to be_false 169 | expect(set.proper_subset?(Multiset[])).to be_false 170 | 171 | expect(Multiset[].proper_subset?(Multiset[])).to be_false 172 | end 173 | 174 | it "should add the given object to the set and return self" do 175 | set = Multiset[1, 2, 3] 176 | 177 | ret = set.add(2) 178 | expect(set).to equal(ret) 179 | expect(set).to eql(Multiset[1, 2, 2, 3]) 180 | 181 | ret = set.add(4) 182 | expect(set).to equal(ret) 183 | expect(set).to eql(Multiset[1, 2, 2, 3, 4]) 184 | end 185 | 186 | it "should delete the given object from the set and return self" do 187 | set = Multiset[1, 2, 3] 188 | 189 | ret = set.delete(4) 190 | expect(set).to equal(ret) 191 | expect(set).to eql(Multiset[1, 2, 3]) 192 | 193 | ret = set.delete(2) 194 | expect(set).to eql(ret) 195 | expect(set).to eql(Multiset[1, 3]) 196 | end 197 | 198 | it "should delete every element of the set for which block evaluates to true, and return self" do 199 | set = Multiset.new(1..10) 200 | ret = set.delete_if { |i| i > 10 } 201 | expect(set).to equal(ret) 202 | expect(set).to eql(Multiset.new(1..10)) 203 | 204 | set = Multiset.new(1..10) 205 | ret = set.delete_if { |i| i % 3 == 0 } 206 | expect(set).to equal(ret) 207 | expect(set).to eql(Multiset[1, 2, 4, 5, 7, 8, 10]) 208 | end 209 | 210 | it "should deletes every element of the set for which block evaluates to true but return nil if no changes were made" do 211 | set = Multiset.new(1..10) 212 | 213 | ret = set.reject! { |i| i > 10 } 214 | expect(ret).to be_nil 215 | expect(set).to eql(Multiset.new(1..10)) 216 | 217 | ret = set.reject! { |i| i % 3 == 0 } 218 | expect(set).to equal(ret) 219 | expect(set).to eql(Multiset[1, 2, 4, 5, 7, 8, 10]) 220 | end 221 | 222 | it "should merge the elements of the given enumerable object to the set and return self" do 223 | set = Multiset[1, 2, 3] 224 | 225 | ret = set.merge([2, 4, 6]) 226 | expect(set).to equal(ret) 227 | expect(set).to eql(Multiset[1, 2, 2, 3, 4, 6]) 228 | end 229 | 230 | it "should delete every element that appears in the given enumerable object and return self" do 231 | set = Multiset[1, 2, 3] 232 | 233 | ret = set.subtract([2, 4, 6]) 234 | expect(set).to equal(ret) 235 | expect(set).to eql(Multiset[1, 3]) 236 | end 237 | 238 | it "should return a new set built by merging the set and the elements of the given enumerable object" do 239 | set = Multiset[1, 2, 3] 240 | 241 | ret = set + [2, 4, 6] 242 | expect(set).to_not equal(ret) 243 | expect(ret).to eql(Multiset[1, 2, 2, 3, 4, 6]) 244 | end 245 | 246 | it "should return a new set built by duplicating the set, removing every element that appears in the given enumerable object" do 247 | set = Multiset[1, 2, 3] 248 | 249 | ret = set - [2, 4, 6] 250 | expect(set).to_not equal(ret) 251 | expect(ret).to eql(Multiset[1, 3]) 252 | end 253 | 254 | it "should return a new set containing elements common to the set and the given enumerable object" do 255 | set = Multiset[1, 2, 3, 4] 256 | 257 | ret = set & [2, 4, 6] 258 | expect(set).to_not equal(ret) 259 | expect(ret).to eql(Multiset[2, 4]) 260 | end 261 | 262 | it "should return a new set containing elements exclusive between the set and the given enumerable object" do 263 | set = Multiset[1, 2, 3, 4] 264 | ret = set ^ [2, 4, 5] 265 | expect(set).to_not equal(ret) 266 | expect(ret).to eql(Multiset[1, 3, 5]) 267 | end 268 | end 269 | 270 | shared_examples_for Set, "with inital values [1, 2]" do 271 | it "should add element to set" do 272 | @set.add("foo") 273 | 274 | expect(@set.include?(1)).to be_true 275 | expect(@set.include?(2)).to be_true 276 | expect(@set.include?("foo")).to be_true 277 | end 278 | 279 | it "should merge elements into the set" do 280 | @set.merge([2, 6]) 281 | 282 | expect(@set.include?(1)).to be_true 283 | expect(@set.include?(2)).to be_true 284 | expect(@set.include?(2)).to be_true 285 | expect(@set.include?(6)).to be_true 286 | end 287 | 288 | it "should iterate over all the values in the set" do 289 | a = [] 290 | @set.each { |o| a << o } 291 | expect(a).to eql([1, 2]) 292 | end 293 | 294 | it "should convert to an array" do 295 | expect(@set.to_a).to eql([1, 2]) 296 | end 297 | 298 | it "should convert to a set" do 299 | expect(@set.to_set.to_a).to eql([1, 2]) 300 | end 301 | end 302 | -------------------------------------------------------------------------------- /lib/multimap/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'multiset' 2 | require 'multimap' 3 | require 'nested_multimap' 4 | 5 | require 'enumerable_examples' 6 | require 'hash_examples' 7 | require 'set_examples' 8 | 9 | # Rubinius Hash isn't ordered by insert order 10 | Spec::Matchers.define :sorted_eql do |expected| 11 | if defined? Rubinius 12 | sorter = lambda { |a, b| a.hash <=> b.hash } 13 | match do |actual| 14 | expect(actual.sort(&sorter)).to eql(expected.sort(&sorter)) 15 | end 16 | else 17 | match do |actual| 18 | expect(actual).to eql(expected) 19 | end 20 | end 21 | end 22 | 23 | require 'set' 24 | 25 | if defined? Rubinius 26 | class Set 27 | def <=>(other) 28 | to_a <=> other.to_a 29 | end 30 | end 31 | end 32 | 33 | require 'forwardable' 34 | 35 | class MiniArray 36 | extend Forwardable 37 | 38 | attr_accessor :data 39 | 40 | def initialize(data = []) 41 | @data = data 42 | end 43 | 44 | def initialize_copy(orig) 45 | @data = orig.data.dup 46 | end 47 | 48 | def_delegators :@data, :<<, :each, :delete, :delete_if 49 | 50 | def ==(other) 51 | other.is_a?(self.class) && @data == other.data 52 | end 53 | 54 | def eql?(other) 55 | other.is_a?(self.class) && @data.eql?(other.data) 56 | end 57 | 58 | if defined? Rubinius 59 | def hash 60 | @data.hash 61 | end 62 | 63 | def <=>(other) 64 | @data <=> other.data 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /mailgun.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |gem| 4 | gem.authors = ["Akash Manohar J", "Sean Grove"] 5 | gem.email = ["akash@akash.im"] 6 | gem.description = %q{Mailgun library for Ruby} 7 | gem.summary = %q{Idiomatic library for using the mailgun API from within ruby} 8 | gem.homepage = "http://github.com/HashNuke/mailgun" 9 | 10 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 11 | gem.files = `git ls-files`.split("\n") 12 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 13 | gem.name = "mailgun" 14 | gem.require_paths = ["lib"] 15 | gem.version = "0.11" 16 | 17 | gem.add_development_dependency(%q, [">= 2"]) 18 | gem.add_development_dependency(%q, [">= 0"]) 19 | gem.add_development_dependency(%q, [">= 2"]) 20 | end 21 | -------------------------------------------------------------------------------- /spec/address_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Address do 4 | 5 | before :each do 6 | @sample = "foo@mailgun.net" 7 | end 8 | 9 | describe "validate an address" do 10 | it "should require a public api key" do 11 | mailgun = Mailgun({:api_key => "api-key"}) 12 | expect { mailgun.addresses }.to raise_error(ArgumentError, ":public_api_key is a required argument to validate addresses") 13 | end 14 | it "should make a GET request with correct params to find a given webhook" do 15 | mailgun = Mailgun({:api_key => "api-key", :public_api_key => "public-api-key"}) 16 | 17 | sample_response = "{\"is_valid\":true,\"address\":\"foo@mailgun.net\",\"parts\":{\"display_name\":null,\"local_part\":\"foo\",\"domain\":\"mailgun.net\"},\"did_you_mean\":null}" 18 | validate_url = mailgun.addresses.send(:address_url, 'validate') 19 | 20 | expect(Mailgun).to receive(:submit). 21 | with(:get, validate_url, {:address => @sample}). 22 | and_return(sample_response) 23 | 24 | mailgun.addresses.validate(@sample) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Base do 4 | 5 | it "should raise an error if the api_key has not been set" do 6 | Mailgun.config { |c| c.api_key = nil } 7 | expect do 8 | Mailgun() 9 | end.to raise_error ArgumentError 10 | end 11 | 12 | it "can be called directly if the api_key has been set via Mailgun.configure" do 13 | Mailgun.config { |c| c.api_key = "some-junk-string" } 14 | expect do 15 | Mailgun() 16 | end.not_to raise_error() 17 | end 18 | 19 | it "can be instanced with the api_key as a param" do 20 | expect do 21 | Mailgun({:api_key => "some-junk-string"}) 22 | end.not_to raise_error() 23 | end 24 | 25 | describe "Mailgun.new" do 26 | it "Mailgun() method should return a new Mailgun object" do 27 | mailgun = Mailgun({:api_key => "some-junk-string"}) 28 | expect(mailgun).to be_kind_of(Mailgun::Base) 29 | end 30 | end 31 | 32 | describe "resources" do 33 | before :each do 34 | @mailgun = Mailgun({:api_key => "some-junk-string"}) 35 | end 36 | 37 | it "Mailgun#mailboxes should return an instance of Mailgun::Mailbox" do 38 | expect(@mailgun.mailboxes).to be_kind_of(Mailgun::Mailbox) 39 | end 40 | 41 | it "Mailgun#routes should return an instance of Mailgun::Route" do 42 | expect(@mailgun.routes).to be_kind_of(Mailgun::Route) 43 | end 44 | end 45 | 46 | describe "internal helper methods" do 47 | before :each do 48 | @mailgun = Mailgun({:api_key => "some-junk-string"}) 49 | end 50 | 51 | describe "Mailgun#base_url" do 52 | it "should return https url if use_https is true" do 53 | expect(@mailgun.base_url).to eq "https://api:#{Mailgun.api_key}@#{Mailgun.mailgun_host}/#{Mailgun.api_version}" 54 | end 55 | end 56 | 57 | describe "Mailgun.submit" do 58 | let(:client_double) { double(Mailgun::Client) } 59 | 60 | it "should send method and arguments to Mailgun::Client" do 61 | expect(Mailgun::Client).to receive(:new) 62 | .with('/') 63 | .and_return(client_double) 64 | expect(client_double).to receive(:test_method) 65 | .with({:arg1=>"val1"}) 66 | .and_return('{}') 67 | 68 | Mailgun.submit :test_method, '/', :arg1=>"val1" 69 | end 70 | end 71 | end 72 | 73 | describe "configuration" do 74 | describe "default settings" do 75 | it "api_version is v3" do 76 | expect(Mailgun.api_version).to eql 'v3' 77 | end 78 | it "should use https by default" do 79 | expect(Mailgun.protocol).to eq "https" 80 | end 81 | it "mailgun_host is 'api.mailgun.net'" do 82 | expect(Mailgun.mailgun_host).to eql 'api.mailgun.net' 83 | end 84 | 85 | it "test_mode is false" do 86 | expect(Mailgun.test_mode).to eql false 87 | end 88 | 89 | it "domain is not set" do 90 | expect(Mailgun.domain).to be_nil 91 | end 92 | end 93 | 94 | describe "setting configurations" do 95 | before(:each) do 96 | Mailgun.configure do |c| 97 | c.api_key = 'some-api-key' 98 | c.api_version = 'v2' 99 | c.protocol = 'https' 100 | c.mailgun_host = 'api.mailgun.net' 101 | c.test_mode = false 102 | c.domain = 'some-domain' 103 | end 104 | end 105 | 106 | after(:each) { Mailgun.configure { |c| c.domain = nil } } 107 | 108 | it "allows me to set my API key easily" do 109 | expect(Mailgun.api_key).to eql 'some-api-key' 110 | end 111 | 112 | it "allows me to set the api_version attribute" do 113 | expect(Mailgun.api_version).to eql 'v2' 114 | end 115 | 116 | it "allows me to set the protocol attribute" do 117 | expect(Mailgun.protocol).to eql 'https' 118 | end 119 | 120 | it "allows me to set the mailgun_host attribute" do 121 | expect(Mailgun.mailgun_host).to eql 'api.mailgun.net' 122 | end 123 | it "allows me to set the test_mode attribute" do 124 | expect(Mailgun.test_mode).to eql false 125 | end 126 | 127 | it "allows me to set my domain easily" do 128 | expect(Mailgun.domain).to eql 'some-domain' 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/bounce_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Bounce do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :name => "test", 11 | :domain => "sample.mailgun.org" 12 | } 13 | end 14 | 15 | describe "list bounces" do 16 | it "should make a GET request with the right params" do 17 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 18 | bounces_url = @mailgun.bounces(@sample[:domain]).send(:bounce_url) 19 | 20 | expect(Mailgun).to receive(:submit). 21 | with(:get, bounces_url, {}). 22 | and_return(sample_response) 23 | 24 | @mailgun.bounces(@sample[:domain]).list 25 | end 26 | end 27 | 28 | describe "find bounces" do 29 | it "should make a GET request with correct params to find given email address" do 30 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 31 | bounces_url = @mailgun.bounces(@sample[:domain]).send(:bounce_url, @sample[:email]) 32 | 33 | expect(Mailgun).to receive(:submit). 34 | with(:get, bounces_url). 35 | and_return(sample_response) 36 | 37 | @mailgun.bounces(@sample[:domain]).find(@sample[:email]) 38 | end 39 | end 40 | 41 | describe "add bounces" do 42 | it "should make a POST request with correct params to add a given email address" do 43 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 44 | bounces_url = @mailgun.bounces(@sample[:domain]).send(:bounce_url) 45 | 46 | expect(Mailgun).to receive(:submit). 47 | with(:post, bounces_url, {:address => @sample[:email]} ). 48 | and_return(sample_response) 49 | 50 | @mailgun.bounces(@sample[:domain]).add(@sample[:email]) 51 | end 52 | end 53 | 54 | describe "destroy bounces" do 55 | it "should make DELETE request with correct params to remove a given email address" do 56 | sample_response = "{\"message\"=>\"Bounced address has been removed\", \"address\"=>\"postmaster@bsample.mailgun.org\"}" 57 | bounces_url = @mailgun.bounces(@sample[:domain]).send(:bounce_url, @sample[:email]) 58 | 59 | expect(Mailgun).to receive(:submit). 60 | with(:delete, bounces_url). 61 | and_return(sample_response) 62 | 63 | @mailgun.bounces(@sample[:domain]).destroy(@sample[:email]) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'webmock/rspec' 4 | 5 | describe Mailgun::Client do 6 | subject { described_class.new(url) } 7 | 8 | describe '#get' do 9 | context 'without query params' do 10 | let(:url) { 'https://api:key@api.mailgun.net/v3/routes' } 11 | 12 | it 'sends a GET request to the given path' do 13 | stub = stub_request(:get, 'https://api.mailgun.net/v3/routes') 14 | .with(basic_auth: ['api', 'key']) 15 | 16 | subject.get 17 | 18 | expect(stub).to have_been_requested 19 | end 20 | end 21 | 22 | context 'with query params' do 23 | let(:url) { 'https://api:key@api.mailgun.net/v3/routes' } 24 | 25 | it 'sends a GET request to the given path with the params' do 26 | stub = stub_request(:get, 'https://api.mailgun.net/v3/routes?limit=10') 27 | .with(basic_auth: ['api', 'key']) 28 | 29 | subject.get(limit: 10) 30 | 31 | expect(stub).to have_been_requested 32 | end 33 | end 34 | 35 | context 'when an error happens' do 36 | let(:url) { 'https://api:key@api.mailgun.net/v3/routes/123' } 37 | let(:error_body) { { "message" => "Expression is missing" }.to_json } 38 | 39 | before do 40 | stub_request(:get, 'https://api.mailgun.net/v3/routes/123') 41 | .with(basic_auth: ['api', 'key']) 42 | .to_return(status: [400, "Bad Request"], 43 | body: error_body) 44 | end 45 | 46 | it 'raises exception that contains the error code and body' do 47 | begin 48 | subject.get 49 | rescue => e 50 | @exception = e 51 | end 52 | 53 | expect(@exception.http_code).to eq(400) 54 | expect(@exception.http_body).to eq(error_body) 55 | end 56 | end 57 | end 58 | 59 | describe '#post' do 60 | let(:url) { 'https://api:key@api.mailgun.net/v3/routes' } 61 | let(:params) { { action: ['forward', 'stop'], description: 'yolo' } } 62 | let(:response) do 63 | { 64 | "message" => "Route has been created", 65 | "route" => { 66 | "actions" => [ 67 | "forward(\"stefan@metrilo.com\")", 68 | "stop()" 69 | ], 70 | "created_at" => "Wed, 15 Jun 2016 07:10:09 GMT", 71 | "description" => "Sample route", 72 | "expression" => "match_recipient(\".*@metrilo.com\")", 73 | "id" => "5760ff5163badc3a756f9d2c", 74 | "priority" => 5 75 | } 76 | } 77 | end 78 | 79 | before do 80 | stub_request(:post, 'https://api.mailgun.net/v3/routes') 81 | .with(basic_auth: ['api', 'key'], 82 | body: 'action=forward&action=stop&description=yolo') 83 | .to_return(body: response.to_json) 84 | end 85 | 86 | it 'sends a POST request with the params form-encoded' do 87 | expect(subject.post(params)).to eq(response.to_json) 88 | end 89 | end 90 | 91 | describe '#put' do 92 | let(:url) { 'https://api:key@api.mailgun.net/v3/routes/123' } 93 | 94 | it 'sends a PUT request with the params form-encoded' do 95 | stub = stub_request(:put, 'https://api.mailgun.net/v3/routes/123') 96 | .with(basic_auth: ['api', 'key'], 97 | body: 'action=forward&action=stop&description=yolo') 98 | 99 | subject.put(action: ['forward', 'stop'], 100 | description: 'yolo') 101 | 102 | expect(stub).to have_been_requested 103 | end 104 | end 105 | 106 | describe '#delete' do 107 | let(:url) { 'https://api:key@api.mailgun.net/v3/routes/123' } 108 | 109 | it 'sends a DELETE request with the params form-encoded' do 110 | stub = stub_request(:delete, 'https://api.mailgun.net/v3/routes/123') 111 | .with(basic_auth: ['api', 'key']) 112 | 113 | subject.delete 114 | 115 | expect(stub).to have_been_requested 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/complaint_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Complaint do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :name => "test", 11 | :domain => "sample.mailgun.org" 12 | } 13 | end 14 | 15 | describe "list complaints" do 16 | it "should make a GET request with the right params" do 17 | sample_response = < @sample[:email]}) 54 | .and_return(sample_response) 55 | 56 | @mailgun.complaints(@sample[:domain]).add(@sample[:email]) 57 | end 58 | end 59 | 60 | 61 | describe "find complaint" do 62 | it "should make a GET request with the right params to find given email address" do 63 | sample_response = < "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :name => "test", 11 | :domain => "sample.mailgun.org" 12 | } 13 | end 14 | 15 | describe "list domains" do 16 | it "should make a GET request with the right params" do 17 | 18 | sample_response = "{\"total_count\": 1, \"items\": [{\"created_at\": \"Tue, 12 Feb 2013 20:13:49 GMT\", \"smtp_login\": \"postmaster@sample.mailgun.org\", \"name\": \"sample.mailgun.org\", \"smtp_password\": \"67bw67bz7w\" }]}" 19 | domains_url = @mailgun.domains.send(:domain_url) 20 | 21 | expect(Mailgun).to receive(:submit). 22 | with(:get, domains_url, {}). 23 | and_return(sample_response) 24 | 25 | @mailgun.domains.list 26 | end 27 | end 28 | 29 | describe "find domains" do 30 | it "should make a GET request with correct params to find given domain" do 31 | sample_response = "{\"domain\": {\"created_at\": \"Tue, 12 Feb 2013 20:13:49 GMT\", \"smtp_login\": \"postmaster@bample.mailgun.org\", \"name\": \"sample.mailgun.org\", \"smtp_password\": \"67bw67bz7w\" }, \"receiving_dns_records\": [], \"sending_dns_records\": []}" 32 | domains_url = @mailgun.domains.send(:domain_url, @sample[:domain]) 33 | 34 | expect(Mailgun).to receive(:submit). 35 | with(:get, domains_url). 36 | and_return(sample_response) 37 | 38 | @mailgun.domains.find(@sample[:domain]) 39 | end 40 | end 41 | 42 | describe "add domains" do 43 | it "should make a POST request with correct params to add a domain" do 44 | sample_response = "{\"domain\": {\"created_at\": \"Tue, 12 Feb 2013 20:13:49 GMT\", \"smtp_login\": \"postmaster@sample.mailgun.org\",\"name\": \"sample.mailgun.org\",\"smtp_password\": \"67bw67bz7w\"}, \"message\": \"Domain has been created\"}" 45 | domains_url = @mailgun.domains.send(:domain_url) 46 | 47 | expect(Mailgun).to receive(:submit). 48 | with(:post, domains_url, {:name => @sample[:domain]} ). 49 | and_return(sample_response) 50 | 51 | @mailgun.domains.create(@sample[:domain]) 52 | end 53 | end 54 | 55 | describe "delete domain" do 56 | it "should make a DELETE request with correct params" do 57 | sample_response = "{\"message\": \"Domain has been deleted\"}" 58 | domains_url = @mailgun.domains.send(:domain_url, @sample[:domain]) 59 | 60 | expect(Mailgun).to receive(:submit). 61 | with(:delete, domains_url). 62 | and_return(sample_response) 63 | 64 | @mailgun.domains.delete(@sample[:domain]) 65 | end 66 | end 67 | 68 | describe 'verify domain' do 69 | it 'should make a PUT request to verify with correct params' do 70 | sample_response = "{\"domain\": {\"created_at\": \"Tue, 12 Feb 2013 20:13:49 GMT\", \"smtp_login\": \"postmaster@bample.mailgun.org\", \"name\": \"sample.mailgun.org\", \"smtp_password\": \"67bw67bz7w\", \"state\": \"active\"}, \"message\": \"Domain DNS records have been updated\", \"receiving_dns_records\": [], \"sending_dns_records\": []}" 71 | verify_domain_url = "#{@mailgun.domains.send(:domain_url, @sample[:domain])}/verify" 72 | 73 | expect(Mailgun).to receive(:submit) 74 | .with(:put, verify_domain_url) 75 | .and_return(sample_response) 76 | 77 | @mailgun.domains.verify(@sample[:domain]) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/helpers/mailgun_helper.rb: -------------------------------------------------------------------------------- 1 | module MailgunHelper 2 | def generate_request_auth(api_key, offset=0) 3 | timestamp = Time.now.to_i + offset * 60 4 | token = ([nil]*50).map { ((48..57).to_a+(65..90).to_a+(97..122).to_a).sample.chr }.join 5 | signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), api_key, '%s%s' % [timestamp, token]) 6 | 7 | return timestamp, token, signature 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/list/member_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::MailingList::Member do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :list_email => "test_list@sample.mailgun.org", 11 | :name => "test", 12 | :domain => "sample.mailgun.org" 13 | } 14 | end 15 | 16 | describe "list members" do 17 | it "should make a GET request with the right params" do 18 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 19 | mailing_list_members_url = @mailgun.list_members(@sample[:list_email]).send(:list_member_url) 20 | 21 | expect(Mailgun).to receive(:submit). 22 | with(:get, mailing_list_members_url, {}). 23 | and_return(sample_response) 24 | 25 | @mailgun.list_members(@sample[:list_email]).list 26 | end 27 | end 28 | 29 | describe "find member in list" do 30 | it "should make a GET request with correct params to find given email address" do 31 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 32 | mailing_list_members_url = @mailgun.list_members(@sample[:list_email]).send(:list_member_url, @sample[:email]) 33 | 34 | expect(Mailgun).to receive(:submit). 35 | with(:get, mailing_list_members_url). 36 | and_return(sample_response) 37 | 38 | @mailgun.list_members(@sample[:list_email]).find(@sample[:email]) 39 | end 40 | end 41 | 42 | describe "add member to list" do 43 | it "should make a POST request with correct params to add a given email address" do 44 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 45 | mailing_list_members_url = @mailgun.list_members(@sample[:list_email]).send(:list_member_url) 46 | 47 | expect(Mailgun).to receive(:submit). 48 | with(:post, mailing_list_members_url, { 49 | :address => @sample[:email] 50 | }). 51 | and_return(sample_response) 52 | 53 | @mailgun.list_members(@sample[:list_email]).add(@sample[:email]) 54 | end 55 | end 56 | 57 | describe "update member in list" do 58 | it "should make a PUT request with correct params" do 59 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 60 | expect(Mailgun).to receive(:submit). 61 | with(:put, "#{@mailgun.list_members(@sample[:list_email]).send(:list_member_url, @sample[:email])}", { 62 | :address => @sample[:email] 63 | }). 64 | and_return(sample_response) 65 | 66 | @mailgun.list_members(@sample[:list_email]).update(@sample[:email]) 67 | end 68 | end 69 | 70 | describe "delete member from list" do 71 | it "should make a DELETE request with correct params" do 72 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 73 | mailing_list_members_url = @mailgun.list_members(@sample[:list_email]).send(:list_member_url, @sample[:email]) 74 | expect(Mailgun).to receive(:submit). 75 | with(:delete, mailing_list_members_url). 76 | and_return(sample_response) 77 | 78 | @mailgun.list_members(@sample[:list_email]).remove(@sample[:email]) 79 | end 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /spec/list/message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Log do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :name => "test", 11 | :domain => "sample.mailgun.org" 12 | } 13 | end 14 | 15 | describe "send email" do 16 | it "should make a POST request to send an email" do 17 | 18 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 19 | expect(Mailgun).to receive(:submit) 20 | .with(:get, "#{@mailgun.lists.send(:list_url, @sample[:list_email])}") 21 | .and_return(sample_response) 22 | 23 | @mailgun.lists.find(@sample[:list_email]) 24 | 25 | sample_response = "{\"message\": \"Queued. Thank you.\",\"id\": \"<20111114174239.25659.5817@samples.mailgun.org>\"}" 26 | parameters = { 27 | :to => "cooldev@your.mailgun.domain", 28 | :subject => "missing tps reports", 29 | :text => "yeah, we're gonna need you to come in on friday...yeah.", 30 | :from => "lumberg.bill@initech.mailgun.domain" 31 | } 32 | expect(Mailgun).to receive(:submit) \ 33 | .with(:post, @mailgun.messages.messages_url, parameters) \ 34 | .and_return(sample_response) 35 | 36 | @mailgun.messages.send_email(parameters) 37 | end 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /spec/list_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::MailingList do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :list_email => "dev@samples.mailgun.org", 11 | :name => "test", 12 | :domain => "sample.mailgun.org" 13 | } 14 | end 15 | 16 | describe "list mailing lists" do 17 | it "should make a GET request with the right params" do 18 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 19 | expect(Mailgun).to receive(:submit) 20 | .with(:get, "#{@mailgun.lists.send(:list_url)}", {}).and_return(sample_response) 21 | 22 | @mailgun.lists.list 23 | end 24 | end 25 | 26 | describe "find list adress" do 27 | it "should make a GET request with correct params to find given email address" do 28 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 29 | expect(Mailgun).to receive(:submit) 30 | .with(:get, "#{@mailgun.lists.send(:list_url, @sample[:list_email])}") 31 | .and_return(sample_response) 32 | 33 | @mailgun.lists.find(@sample[:list_email]) 34 | end 35 | end 36 | 37 | describe "create list" do 38 | it "should make a POST request with correct params to add a given email address" do 39 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 40 | expect(Mailgun).to receive(:submit) 41 | .with(:post, "#{@mailgun.lists.send(:list_url)}", {:address => @sample[:list_email]}) 42 | .and_return(sample_response) 43 | 44 | @mailgun.lists.create(@sample[:list_email]) 45 | end 46 | end 47 | 48 | describe "update list" do 49 | it "should make a PUT request with correct params" do 50 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 51 | expect(Mailgun).to receive(:submit) 52 | .with(:put, "#{@mailgun.lists.send(:list_url, @sample[:list_email])}", {:address => @sample[:email]}) 53 | .and_return(sample_response) 54 | 55 | @mailgun.lists.update(@sample[:list_email], @sample[:email]) 56 | end 57 | end 58 | 59 | describe "delete list" do 60 | it "should make a DELETE request with correct params" do 61 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 62 | expect(Mailgun).to receive(:submit) 63 | .with(:delete, "#{@mailgun.lists.send(:list_url, @sample[:list_email])}") 64 | .and_return(sample_response) 65 | 66 | @mailgun.lists.delete(@sample[:list_email]) 67 | end 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /spec/log_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Log do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :name => "test", 11 | :domain => "sample.mailgun.org" 12 | } 13 | end 14 | 15 | describe "list log" do 16 | it "should make a GET request with the right params" do 17 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 18 | log_url = @mailgun.log(@sample[:domain]).send(:log_url) 19 | expect(Mailgun).to receive(:submit). 20 | with(:get, log_url, {}). 21 | and_return(sample_response) 22 | 23 | @mailgun.log(@sample[:domain]).list 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /spec/mailbox_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Mailbox do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :mailbox_name => "test", 11 | :domain => "sample.mailgun.org" 12 | } 13 | end 14 | 15 | describe "list mailboxes" do 16 | it "should make a GET request with the right params" do 17 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 18 | mailboxes_url = @mailgun.mailboxes(@sample[:domain]).send(:mailbox_url) 19 | 20 | expect(Mailgun).to receive(:submit). 21 | with(:get,mailboxes_url, {}). 22 | and_return(sample_response) 23 | 24 | @mailgun.mailboxes(@sample[:domain]).list 25 | end 26 | end 27 | 28 | describe "create mailbox" do 29 | it "should make a POST request with the right params" do 30 | mailboxes_url = @mailgun.mailboxes(@sample[:domain]).send(:mailbox_url) 31 | expect(Mailgun).to receive(:submit) 32 | .with(:post, mailboxes_url, 33 | :mailbox => @sample[:email], 34 | :password => @sample[:password]) 35 | .and_return({}) 36 | 37 | @mailgun.mailboxes(@sample[:domain]).create(@sample[:mailbox_name], @sample[:password]) 38 | end 39 | end 40 | 41 | describe "update mailbox" do 42 | it "should make a PUT request with the right params" do 43 | mailboxes_url = @mailgun.mailboxes(@sample[:domain]).send(:mailbox_url, @sample[:mailbox_name]) 44 | expect(Mailgun).to receive(:submit) 45 | .with(:put, mailboxes_url, :password => @sample[:password]) 46 | .and_return({}) 47 | 48 | @mailgun.mailboxes(@sample[:domain]). 49 | update_password(@sample[:mailbox_name], @sample[:password]) 50 | end 51 | end 52 | 53 | describe "destroy mailbox" do 54 | it "should make a DELETE request with the right params" do 55 | mailboxes_url = @mailgun.mailboxes(@sample[:domain]).send(:mailbox_url, @sample[:name]) 56 | expect(Mailgun).to receive(:submit) 57 | .with(:delete, mailboxes_url) 58 | .and_return({}) 59 | 60 | @mailgun.mailboxes(@sample[:domain]).destroy(@sample[:name]) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/route_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Mailgun::Route do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | @sample_route_id = "a45cd" 8 | end 9 | 10 | describe "list routes" do 11 | before :each do 12 | sample_response = < ["test_route"] }) 87 | .and_return("{\"id\": \"#{@sample_route_id}\"}") 88 | @mailgun.routes.update @sample_route_id, options 89 | end 90 | end 91 | 92 | describe "delete route" do 93 | it "should make a DELETE request with the right params" do 94 | expect(Mailgun).to receive(:submit). 95 | with(:delete, "#{@mailgun.routes.send(:route_url, @sample_route_id)}"). 96 | and_return("{\"id\": \"#{@sample_route_id}\"}") 97 | @mailgun.routes.destroy @sample_route_id 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/secure_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require './spec/helpers/mailgun_helper.rb' 4 | 5 | RSpec.configure do |c| 6 | c.include MailgunHelper 7 | end 8 | 9 | describe Mailgun::Secure do 10 | 11 | before :each do 12 | @mailgun = Mailgun({:api_key => "some-api-key"}) # used to get the default values 13 | end 14 | 15 | it "generate_request_auth helper should generate a timestamp, a token and a signature" do 16 | timestamp, token, signature = generate_request_auth("some-api-key") 17 | 18 | expect(timestamp).to_not be_nil 19 | expect(token.length).to eq 50 20 | expect(signature.length).to eq 64 21 | end 22 | 23 | it "check_request_auth should return true for a recently generated authentication" do 24 | timestamp, token, signature = generate_request_auth("some-api-key") 25 | 26 | result = @mailgun.secure.check_request_auth(timestamp, token, signature) 27 | 28 | expect(result).to be true 29 | end 30 | 31 | it "check_request_auth should return false for an authentication generated more than 5 minutes ago" do 32 | timestamp, token, signature = generate_request_auth("some-api-key", -6) 33 | 34 | result = @mailgun.secure.check_request_auth(timestamp, token, signature) 35 | 36 | expect(result).to be false 37 | end 38 | 39 | it "check_request_auth should return true for an authentication generated any time when the check offset is 0" do 40 | timestamp, token, signature = generate_request_auth("some-api-key", -6) 41 | 42 | result = @mailgun.secure.check_request_auth(timestamp, token, signature, 0) 43 | 44 | expect(result).to be true 45 | end 46 | 47 | it "check_request_auth should return false for a different api key, token or signature" do 48 | timestamp, token, signature = generate_request_auth("some-different-api-key") 49 | 50 | result = @mailgun.secure.check_request_auth(timestamp, token, signature) 51 | 52 | expect(result).to be false 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path("../lib", File.dirname(__FILE__)) 2 | require 'simplecov' 3 | SimpleCov.start 4 | 5 | require "rspec" 6 | require "pry" 7 | require "mailgun" 8 | 9 | RSpec.configure do |config| 10 | end 11 | -------------------------------------------------------------------------------- /spec/unsubscribe_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Unsubscribe do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key"}) # used to get the default values 7 | 8 | @sample = { 9 | :email => "test@sample.mailgun.org", 10 | :name => "test", 11 | :domain => "sample.mailgun.org", 12 | :tag => 'tag1' 13 | } 14 | end 15 | 16 | describe "list unsubscribes" do 17 | it "should make a GET request with the right params" do 18 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 19 | unsubscribes_url = @mailgun.unsubscribes(@sample[:domain]).send(:unsubscribe_url) 20 | expect(Mailgun).to receive(:submit). 21 | with(:get, unsubscribes_url, {}). 22 | and_return(sample_response) 23 | 24 | @mailgun.unsubscribes(@sample[:domain]).list 25 | end 26 | end 27 | 28 | describe "find unsubscribe" do 29 | it "should make a GET request with the right params to find given email address" do 30 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 31 | unsubscribes_url = @mailgun.unsubscribes(@sample[:domain]).send(:unsubscribe_url, @sample[:email]) 32 | 33 | expect(Mailgun).to receive(:submit) 34 | .with(:get, unsubscribes_url) 35 | .and_return(sample_response) 36 | 37 | @mailgun.unsubscribes(@sample[:domain]).find(@sample[:email]) 38 | end 39 | end 40 | 41 | describe "delete unsubscribe" do 42 | it "should make a DELETE request with correct params to remove a given email address" do 43 | response_message = "{\"message\"=>\"Unsubscribe event has been removed\", \"address\"=>\"#{@sample[:email]}\"}" 44 | unsubscribes_url = @mailgun.unsubscribes(@sample[:domain]).send(:unsubscribe_url, @sample[:email]) 45 | 46 | expect(Mailgun).to receive(:submit) 47 | .with(:delete, unsubscribes_url) 48 | .and_return(response_message) 49 | 50 | @mailgun.unsubscribes(@sample[:domain]).remove(@sample[:email]) 51 | end 52 | end 53 | 54 | describe "add unsubscribe" do 55 | context "to tag" do 56 | it "should make a POST request with correct params to add a given email address to unsubscribe from a tag" do 57 | response_message = "{\"message\"=>\"Address has been added to the unsubscribes table\", \"address\"=>\"#{@sample[:email]}\"}" 58 | expect(Mailgun).to receive(:submit) 59 | .with(:post, "#{@mailgun.unsubscribes(@sample[:domain]).send(:unsubscribe_url)}",{:address=>@sample[:email], :tag=>@sample[:tag]}) 60 | .and_return(response_message) 61 | 62 | @mailgun.unsubscribes(@sample[:domain]).add(@sample[:email], @sample[:tag]) 63 | end 64 | end 65 | 66 | context "on all" do 67 | it "should make a POST request with correct params to add a given email address to unsubscribe from all tags" do 68 | sample_response = "{\"items\": [{\"size_bytes\": 0, \"mailbox\": \"postmaster@bsample.mailgun.org\" } ]}" 69 | unsubscribes_url = @mailgun.unsubscribes(@sample[:domain]).send(:unsubscribe_url) 70 | 71 | expect(Mailgun).to receive(:submit) 72 | .with(:post, unsubscribes_url, { 73 | :address => @sample[:email], :tag => '*' 74 | }) 75 | .and_return(sample_response) 76 | 77 | @mailgun.unsubscribes(@sample[:domain]).add(@sample[:email]) 78 | end 79 | end 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /spec/webhook_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mailgun::Webhook do 4 | 5 | before :each do 6 | @mailgun = Mailgun({:api_key => "api-key", :webhook_url => "http://postbin.heroku.com/860bcd65"}) 7 | 8 | @sample = { 9 | :id => "click", 10 | :url => "http://postbin.heroku.com/860bcd65" 11 | } 12 | end 13 | 14 | describe "list avabilable webhook ids" do 15 | it "should return the correct ids" do 16 | expect(@mailgun.webhooks.available_ids).to match_array %i(bounce deliver drop spam unsubscribe click open) 17 | end 18 | end 19 | 20 | describe "list webhooks" do 21 | it "should make a GET request with the correct params" do 22 | 23 | sample_response = "{\"total_count\": 1, \"items\": [{\"webhooks\":{\"open\":{\"url\":\"http://postbin.heroku.com/860bcd65\"},\"click\":{\"url\":\"http://postbin.heroku.com/860bcd65\"}}}]}" 24 | webhooks_url = @mailgun.webhooks.send(:webhook_url) 25 | 26 | expect(Mailgun).to receive(:submit). 27 | with(:get, webhooks_url). 28 | and_return(sample_response) 29 | 30 | @mailgun.webhooks.list 31 | end 32 | end 33 | 34 | describe "find a webhook" do 35 | it "should make a GET request with correct params to find a given webhook" do 36 | sample_response = "{\"webhook\": {\"url\":\"http://postbin.heroku.com/860bcd65\"}" 37 | webhooks_url = @mailgun.webhooks.send(:webhook_url, @sample[:id]) 38 | 39 | expect(Mailgun).to receive(:submit). 40 | with(:get, webhooks_url). 41 | and_return(sample_response) 42 | 43 | @mailgun.webhooks.find(@sample[:id]) 44 | end 45 | end 46 | 47 | describe "add a webhook" do 48 | context "using the default webhook url" do 49 | it "should make a POST request with correct params to add a webhook" do 50 | sample_response = "{\"message\":\"Webhook has been created\",\"webhook\":{\"url\":\"http://postbin.heroku.com/860bcd65\"}}" 51 | webhooks_url = @mailgun.webhooks.send(:webhook_url) 52 | 53 | expect(Mailgun).to receive(:submit). 54 | with(:post, webhooks_url, {:id => @sample[:id], :url => @sample[:url]}). 55 | and_return(sample_response) 56 | 57 | @mailgun.webhooks.create(@sample[:id]) 58 | end 59 | end 60 | context "overwriting the default webhook url" do 61 | it "should make a POST request with correct params to add a webhook" do 62 | sample_response = "{\"message\":\"Webhook has been created\",\"webhook\":{\"url\":\"http://postbin.heroku.com/860bcd65\"}}" 63 | webhooks_url = @mailgun.webhooks.send(:webhook_url) 64 | overwritten_url = 'http://mailgun.net/webhook' 65 | 66 | expect(Mailgun).to receive(:submit). 67 | with(:post, webhooks_url, {:id => @sample[:id], :url => overwritten_url}). 68 | and_return(sample_response) 69 | 70 | @mailgun.webhooks.create(@sample[:id], overwritten_url) 71 | end 72 | end 73 | end 74 | 75 | describe "update a webhook" do 76 | context "using the default webhook url" do 77 | it "should make a POST request with correct params to add a webhook" do 78 | sample_response = "{\"message\":\"Webhook has been updated\",\"webhook\":{\"url\":\"http://postbin.heroku.com/860bcd65\"}}" 79 | webhooks_url = @mailgun.webhooks.send(:webhook_url, @sample[:id]) 80 | 81 | expect(Mailgun).to receive(:submit). 82 | with(:put, webhooks_url, {:url => @sample[:url]}). 83 | and_return(sample_response) 84 | 85 | @mailgun.webhooks.update(@sample[:id]) 86 | end 87 | end 88 | context "overwriting the default webhook url" do 89 | it "should make a POST request with correct params to add a webhook" do 90 | sample_response = "{\"message\":\"Webhook has been updated\",\"webhook\":{\"url\":\"http://postbin.heroku.com/860bcd65\"}}" 91 | webhooks_url = @mailgun.webhooks.send(:webhook_url, @sample[:id]) 92 | overwritten_url = 'http://mailgun.net/webhook' 93 | 94 | expect(Mailgun).to receive(:submit). 95 | with(:put, webhooks_url, {:url => overwritten_url}). 96 | and_return(sample_response) 97 | 98 | @mailgun.webhooks.update(@sample[:id], overwritten_url) 99 | end 100 | end 101 | end 102 | 103 | describe "delete a webhook" do 104 | it "should make a DELETE request with correct params" do 105 | sample_response = "{\"message\":\"Webhook has been deleted\",\"webhook\":{\"url\":\"http://postbin.heroku.com/860bcd65\"}}" 106 | webhooks_url = @mailgun.webhooks.send(:webhook_url, @sample[:id]) 107 | 108 | expect(Mailgun).to receive(:submit). 109 | with(:delete, webhooks_url). 110 | and_return(sample_response) 111 | 112 | @mailgun.webhooks.delete(@sample[:id]) 113 | end 114 | end 115 | end 116 | --------------------------------------------------------------------------------