├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── rubocop-analysis.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin └── publish_to_rubygems.sh ├── gemfiles ├── Gemfile.rails-4.2.x ├── Gemfile.rails-5.0.x ├── Gemfile.rails-5.1.x └── Gemfile.rails-5.2.x ├── lib ├── magicbell.rb └── magicbell │ ├── action_mailer_extension.rb │ ├── api_operations.rb │ ├── api_resource.rb │ ├── api_resource_collection.rb │ ├── api_resources │ ├── notification.rb │ ├── user.rb │ ├── user_notification.rb │ ├── user_notification_preferences.rb │ ├── user_notification_read.rb │ ├── user_notification_unread.rb │ ├── user_notifications.rb │ ├── user_notifications_read.rb │ └── user_notifications_seen.rb │ ├── client.rb │ ├── config.rb │ ├── railtie.rb │ ├── singleton_api_resource.rb │ └── version.rb ├── magicbell.gemspec └── spec ├── magicbell ├── action_mailer_extension_spec.rb ├── client_spec.rb └── config_spec.rb ├── magicbell_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Change description 2 | 3 | > Description here 4 | 5 | ## Type of change 6 | - [ ] Bug fix (fixes an issue) 7 | - [ ] New feature (adds functionality) 8 | 9 | ## Related issues 10 | 11 | > Fix [#1]() 12 | 13 | ## Checklists 14 | 15 | ### Development 16 | 17 | - [ ] Lint rules pass locally 18 | - [ ] Application changes have been tested thoroughly 19 | - [ ] Automated tests covering modified code pass 20 | 21 | ### Security 22 | 23 | - [ ] Security impact of change has been considered 24 | - [ ] Code follows company security practices and guidelines 25 | 26 | ### Code review 27 | 28 | - [ ] Pull request has a descriptive title and context useful to a reviewer. Screenshots or screencasts are attached as necessary 29 | - [ ] "Ready for review" label attached and reviewers assigned 30 | - [ ] Changes have been reviewed by at least one other contributor 31 | - [ ] Pull request linked to task tracker where applicable 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run all tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby: 15 | - 2.4 16 | - 2.5 17 | - 2.6 18 | - 2.7 19 | gemfile: 20 | - Gemfile 21 | - gemfiles/Gemfile.rails-4.2.x 22 | - gemfiles/Gemfile.rails-5.0.x 23 | - gemfiles/Gemfile.rails-5.1.x 24 | - gemfiles/Gemfile.rails-5.2.x 25 | exclude: 26 | - gemfile: gemfiles/Gemfile.rails-4.2.x 27 | ruby: 2.7 28 | 29 | name: Run gem tests 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Setup ruby ${{ matrix.ruby }} 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby }} 36 | - name: Cache gems 37 | uses: actions/cache@v1 38 | with: 39 | path: vendor/bundle 40 | key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.gemfile }} 41 | restore-keys: | 42 | ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.gemfile }} 43 | - name: Install bundler 1.17.3 44 | run: gem install bundler -v 1.17.3 45 | - name: Install gems 46 | run: | 47 | bundle _1.17.3_ config path vendor/bundle 48 | bundle _1.17.3_ install --gemfile=${{ matrix.gemfile }} \ 49 | --jobs 4 --retry 3 50 | - name: Run tests 51 | run: | 52 | bundle _1.17.3_ exec --gemfile=${{ matrix.gemfile }} \ 53 | rspec 54 | -------------------------------------------------------------------------------- /.github/workflows/rubocop-analysis.yml: -------------------------------------------------------------------------------- 1 | # pulled from repo 2 | name: "Rubocop" 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | # The branches below must be a subset of the branches above 9 | branches: [ main ] 10 | schedule: 11 | - cron: '24 18 * * 5' 12 | 13 | jobs: 14 | rubocop: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v2 22 | 23 | # If running on a self-hosted runner, check it meets the requirements 24 | # listed at https://github.com/ruby/setup-ruby#using-self-hosted-runners 25 | - name: Set up Ruby 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: 2.6 29 | 30 | # This step is not necessary if you add the gem to your Gemfile 31 | - name: Install Code Scanning integration 32 | run: bundle add code-scanning-rubocop --version 0.5.0 --skip-install 33 | 34 | - name: Install dependencies 35 | run: bundle install 36 | 37 | - name: Rubocop run 38 | run: | 39 | bash -c " 40 | bundle exec rubocop --require code_scanning --format CodeScanning::SarifFormatter -o rubocop.sarif 41 | [[ $? -ne 2 ]] 42 | " 43 | 44 | - name: Upload Sarif output 45 | uses: github/codeql-action/upload-sarif@v1 46 | with: 47 | sarif_file: rubocop.sarif 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/db/*.sqlite3-* 7 | test/dummy/log/*.log 8 | test/dummy/storage/ 9 | test/dummy/tmp/ 10 | 11 | # Bundler 12 | vendor/bundle 13 | Gemfile.lock 14 | 15 | *.gem 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --require magicbell 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.4 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Declare your gem's dependencies in magicbell.gemspec. 5 | # Bundler will treat runtime dependencies like base dependencies, and 6 | # development dependencies will be added by default to the :development group. 7 | gemspec 8 | 9 | # Declare any dependencies that are still in development here instead of in 10 | # your gemspec. These might include edge Rails or gems from your path or 11 | # Git. Remember to move these dependencies to your gemspec before releasing 12 | # your gem to rubygems.org. 13 | 14 | # To use a debugger 15 | # gem 'byebug', group: [:development, :test] 16 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 MagicBell 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. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MagicBell Ruby Library 2 | 3 | This library provides convenient access to the [MagicBell REST API](https://magicbell.com/docs/rest-api/overview) 4 | from applications written in Ruby. It includes helpers for creating 5 | notifications, fetching them, and calculating the HMAC for a user. 6 | 7 | [MagicBell](https://magicbell.com) is the notification inbox for your web and 8 | mobile applications. You may find it helpful to familiarize yourself with the 9 | [core concepts of MagicBell](https://magicbell.com/docs/core-concepts). 10 | 11 | MagicBell Notification Inbox 12 | 13 | ## Installation 14 | 15 | First, [sign up for a MagicBell account](https://magicbell.com) and grab your 16 | MagicBell project's API key and secret from the "Settings" section of your 17 | MagicBell dashboard. 18 | 19 | If you just want to use the package, run: 20 | 21 | ``` 22 | gem install magicbell 23 | ``` 24 | 25 | ### Bundler 26 | 27 | If you are installing via bundler, add the gem to your app's Gemfile: 28 | 29 | ```ruby 30 | # Gemfile 31 | source 'https://rubygems.org' 32 | 33 | gem 'magicbell' 34 | ``` 35 | 36 | and run `bundle install` a usual. 37 | 38 | ## Configuration 39 | 40 | The library needs to be configured with your MagicBell project's API key and 41 | secret. 42 | 43 | ### Global configuration 44 | 45 | By default, this library will automatically pick your MagicBell project's API 46 | key and secret from the `MAGICBELL_API_KEY` and `MAGICBELL_API_SECRET` 47 | environment variables, respectively. 48 | 49 | Alternatively, you can configure your MagicBell manually. For example, for a 50 | rails project, create an initializer file for MagicBell and set your project's 51 | keys: 52 | 53 | ```ruby 54 | # config/initializers/magicbell.rb 55 | 56 | MagicBell.configure do |config| 57 | config.api_key = 'MAGICBELL_API_KEY' 58 | config.api_secret = 'MAGICBELL_API_SECRET' 59 | end 60 | ``` 61 | 62 | ### Per-request configuration 63 | 64 | For apps that need to use multiple keys during the lifetime of a process, 65 | provide the specific keys when you create instances of `MagicBell::Client`: 66 | 67 | ```ruby 68 | require 'magicbell' 69 | 70 | magicbell = MagicBell::Client.new( 71 | api_key: 'MAGICBELL_PROJECT_API_KEY', 72 | api_secret: 'MAGICBELL_PROJECT_API_SECRET' 73 | ) 74 | ``` 75 | 76 | Please keep in mind that any instance of `MagicBell::Client` will default to the 77 | global configuration unless an API key and secret are provided. 78 | 79 | ## Usage 80 | 81 | ### Create a notification 82 | 83 | You can send a notification to one or many users by identifying them by their 84 | email address: 85 | 86 | ```ruby 87 | require 'magicbell' 88 | 89 | magicbell = MagicBell::Client.new 90 | magicbell.create_notification( 91 | title: 'Rob assigned a task to you', 92 | recipients: [{ 93 | email: 'joe@example.com' 94 | }, { 95 | email: 'mary@example.com' 96 | }] 97 | ) 98 | ``` 99 | 100 | Or you can identify users by an `external_id` (their ID in your database, for example): 101 | 102 | ```ruby 103 | require 'magicbell' 104 | 105 | magicbell = MagicBell::Client.new 106 | magicbell.create_notification( 107 | title: 'Rob assigned a task to you', 108 | recipients: [{ 109 | external_id: 'DATABASE_ID' 110 | }] 111 | ) 112 | ``` 113 | 114 | This method has the benefit of allowing users to access their notifications when 115 | their email address changes. Make sure you identify users by their `externalID` 116 | when you [initialize the notification inbox](https://magicbell.com/docs/react/identifying-users), too. 117 | 118 | You can also provide other data accepted by [our API](https://magicbell.com/docs/rest-api/reference): 119 | 120 | ```ruby 121 | require 'magicbell' 122 | 123 | magicbell = MagicBell::Client.new 124 | magicbell.create_notification( 125 | title: 'Rob assigned to a task to you', 126 | content: 'Hey Joe, can give this customer a demo of our app?', 127 | action_url: 'https://example.com/task_path', 128 | custom_attributes: { 129 | recipient: { 130 | first_name: 'Joe', 131 | last_name: 'Smith' 132 | } 133 | }, 134 | overrides: { 135 | channels: { 136 | web_push: { 137 | title: 'New task assigned' 138 | } 139 | } 140 | }, 141 | recipients: [{ 142 | email: 'joe@example.com' 143 | }], 144 | ) 145 | ``` 146 | 147 | ### Fetch a user's notifications 148 | 149 | To fetch a user's notifications you can do this: 150 | 151 | ```ruby 152 | require 'magicbell' 153 | 154 | magicbell = MagicBell::Client.new 155 | 156 | user = magicbell.user_with_email('joe@example.com') 157 | user.notifications.each { |notification| puts notification.attribute('title') } 158 | ``` 159 | 160 | If you identify a user by an ID, you can use the 161 | `magicbell.user_with_external_id` method instead. 162 | 163 | Please note that the example above fetches the user's 15 most recent 164 | notifications (the default number per page). If you'd like to fetch subsequent 165 | pages, use the `each_page` method instead: 166 | 167 | ```ruby 168 | require 'magicbell' 169 | 170 | magicbell = MagicBell::Client.new 171 | 172 | user = magicbell.user_with_email('joe@example.com') 173 | user.notifications.each_page do |page| 174 | page.each do |notification| 175 | puts notification.attribute('title') 176 | end 177 | end 178 | ``` 179 | 180 | ### Mark a user's notification as read/unread 181 | 182 | ```ruby 183 | require 'magicbell' 184 | 185 | magicbell = MagicBell::Client.new 186 | 187 | user = magicbell.user_with_email('joe@example.com') 188 | 189 | notification = user.notifications.first 190 | notification.mark_as_read 191 | notification.mark_as_unread 192 | ``` 193 | 194 | ### Mark all notifications of a user as read 195 | 196 | ```ruby 197 | require 'magicbell' 198 | 199 | magicbell = MagicBell::Client.new 200 | 201 | user = magicbell.user_with_email('joe@example.com') 202 | user.mark_all_notifications_as_read 203 | ``` 204 | 205 | ### Mark all notifications of a user as seen 206 | 207 | ```ruby 208 | require 'magicbell' 209 | 210 | magicbell = MagicBell::Client.new 211 | 212 | user = magicbell.user_with_email('joe@example.com') 213 | user.mark_all_notifications_as_seen 214 | ``` 215 | 216 | ### Error handling 217 | 218 | This gem raises a `MagicBell::Client::HTTPError` if the MagicBell API returns a 219 | non-2xx response. 220 | 221 | ```ruby 222 | require 'magicbell' 223 | 224 | begin 225 | magicbell = MagicBell::Client.new 226 | magicbell.create_notification( 227 | title: 'Rob assigned to a task to you' 228 | ) 229 | rescue MagicBell::Client::HTTPError => e 230 | # Report the error to your error tracker, for example 231 | error_context = { 232 | response_status: e.response_status, 233 | response_headers: e.response_headers, 234 | response_body: e.response_body 235 | } 236 | 237 | ErrorReporter.report(e, context: error_context) 238 | end 239 | ``` 240 | 241 | ### Calculate the HMAC secret for a user 242 | 243 | You can use the `MagicBell.hmac` method. Note that in this case, the API secret, 244 | which is used to generate the HMAC, will be fetched from the global 245 | configuration. 246 | 247 | ```ruby 248 | require 'magicbell' 249 | 250 | hmac = MagicBell.hmac('joe@example.com') 251 | ``` 252 | 253 | You can also use the API secret of a specific client instance to calculate the 254 | HMAC: 255 | 256 | ```ruby 257 | require 'magicbell' 258 | 259 | magicbell = MagicBell::Client.new( 260 | api_key: 'MAGICBELL_API_KEY', 261 | api_secret: 'MAGICBELL_API_SECRET' 262 | ) 263 | 264 | hmac = magicbell.hmac('joe@example.com') 265 | ``` 266 | 267 | Please refer to our docs to know [how to turn on HMAC authentication](https://magicbell.com/docs/turn-on-hmac-authentication) 268 | for your MagicBell project. 269 | 270 | ## API docs 271 | 272 | Please visit [our website](https://magicbell.com) and 273 | [our docs](https://magicbell.com/docs) for more information. 274 | 275 | ## Contact Us 276 | 277 | Have a query or hit upon a problem? Feel free to contact us at 278 | [hello@magicbell.io](mailto:hello@magicbell.io). 279 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | require 'rdoc/task' 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = 'rdoc' 11 | rdoc.title = 'MagicbellRuby' 12 | rdoc.options << '--line-numbers' 13 | rdoc.rdoc_files.include('README.md') 14 | rdoc.rdoc_files.include('lib/**/*.rb') 15 | end 16 | 17 | require 'bundler/gem_tasks' 18 | 19 | require 'rake/testtask' 20 | 21 | Rake::TestTask.new(:test) do |t| 22 | t.libs << 'test' 23 | t.pattern = 'test/**/*_test.rb' 24 | t.verbose = false 25 | end 26 | 27 | task default: :test 28 | 29 | # @example 30 | # GEM_HOST_API_KEY="our_rubygems_api_key" bundle exec rake publish_to_rubygems 31 | task :publish_to_rubygems do 32 | `gem build magicbell.gemspec` 33 | require_relative "lib/magicbell" 34 | `gem push magicbell-#{MagicBell::VERSION}.gem` 35 | end 36 | 37 | task :console do 38 | require_relative "lib/magicbell" 39 | require "pry" 40 | binding.pry 41 | end 42 | -------------------------------------------------------------------------------- /bin/publish_to_rubygems.sh: -------------------------------------------------------------------------------- 1 | gem build magicbell.gemspec 2 | gem push magicbell-$(cat VERSION).gem 3 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-4.2.x: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gemspec path: ".." 5 | 6 | gem "rails", "~> 4.2.0" 7 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-5.0.x: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gemspec path: ".." 5 | 6 | gem "rails", "~> 5.0.0" 7 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-5.1.x: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gemspec path: ".." 5 | 6 | gem "rails", "~> 5.1.0" 7 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-5.2.x: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gemspec path: ".." 5 | 6 | gem "rails", "~> 5.2.0" 7 | -------------------------------------------------------------------------------- /lib/magicbell.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | require 'openssl' 4 | require 'base64' 5 | 6 | require 'magicbell/config' 7 | 8 | require 'httparty' 9 | require 'magicbell/api_operations' 10 | require 'magicbell/client' 11 | require 'magicbell/api_resource' 12 | require 'magicbell/singleton_api_resource' 13 | require 'magicbell/api_resource_collection' 14 | require 'magicbell/api_resources/notification' 15 | require 'magicbell/api_resources/user' 16 | require 'magicbell/api_resources/user_notification' 17 | require 'magicbell/api_resources/user_notifications' 18 | require 'magicbell/api_resources/user_notification_read' 19 | require 'magicbell/api_resources/user_notification_unread' 20 | require 'magicbell/api_resources/user_notifications_read' 21 | require 'magicbell/api_resources/user_notifications_seen' 22 | require 'magicbell/api_resources/user_notification_preferences' 23 | 24 | require 'magicbell/railtie' if defined?(Rails) 25 | 26 | module MagicBell 27 | class << self 28 | extend Forwardable 29 | 30 | def_delegators :config, :api_key, :api_secret, :api_host 31 | 32 | def configure 33 | yield(config) 34 | end 35 | 36 | def config 37 | @config ||= Config.new 38 | end 39 | 40 | def reset_config 41 | @config = Config.new 42 | end 43 | 44 | def authentication_headers(client_api_key: nil, client_api_secret: nil) 45 | { 46 | 'X-MAGICBELL-API-KEY' => client_api_key || api_key, 47 | 'X-MAGICBELL-API-SECRET' => client_api_secret || api_secret 48 | } 49 | end 50 | 51 | # Calculate HMAC for user's email 52 | def hmac(message) 53 | MagicBell::Client.new(api_key: api_key, api_secret: api_secret).hmac(message) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/magicbell/action_mailer_extension.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module MagicBell 4 | module ActionMailerExtension 5 | def magicbell_notification_action_url(action_url) 6 | headers['X-MagicBell-Notification-ActionUrl'] = action_url 7 | end 8 | 9 | def magicbell_notification_metadata(metadata) 10 | headers['X-MagicBell-Notification-Metadata'] = metadata.to_json 11 | end 12 | 13 | def magicbell_notification_title(title) 14 | headers['X-MagicBell-Notification-Title'] = title 15 | end 16 | 17 | def magicbell_notification_skip 18 | headers['X-MagicBell-Notification-Skip'] = true 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/magicbell/api_operations.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'colorize' 3 | module MagicBell 4 | module ApiOperations 5 | def get(url, options = {}) 6 | defaults = { headers: default_headers } 7 | response = HTTParty.get(url, options.merge(defaults)) 8 | raise_http_error_unless_2xx_response(response) 9 | 10 | response 11 | end 12 | 13 | def post(url, options = {}) 14 | defaults = { headers: default_headers } 15 | response = HTTParty.post(url, options.merge(defaults)) 16 | raise_http_error_unless_2xx_response(response) 17 | 18 | response 19 | end 20 | 21 | def put(url, options = {}) 22 | defaults = { headers: default_headers } 23 | response = HTTParty.put(url, options.merge(defaults)) 24 | raise_http_error_unless_2xx_response(response) 25 | 26 | response 27 | end 28 | 29 | protected 30 | 31 | def default_headers 32 | authentication_headers.merge({ 'Content-Type' => 'application/json', 'Accept' => 'application/json' }) 33 | end 34 | 35 | private 36 | 37 | def raise_http_error_unless_2xx_response(response) 38 | return if response.success? 39 | 40 | e = MagicBell::Client::HTTPError.new 41 | e.response_status = response.code 42 | e.response_headers = response.headers.to_h 43 | e.response_body = response.body 44 | e.errors = [] 45 | unless e.response_body.nil? || e.response_body.empty? 46 | body = JSON.parse(response.body) 47 | e.errors = body['errors'].is_a?(Array) ? body['errors'] : [] 48 | e.errors.each do |error, _index| 49 | suggestion = error['suggestion'] 50 | help_link = error['help_link'] 51 | 52 | puts suggestion.red.to_s if suggestion 53 | puts help_link.blue.on_white.to_s if help_link 54 | end 55 | end 56 | 57 | raise e 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/magicbell/api_resource.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/inflector' 2 | require 'active_support/core_ext/object/blank' 3 | require 'json' 4 | 5 | module MagicBell 6 | class ApiResource 7 | class << self 8 | def create(client, attributes = {}) 9 | new(client, attributes).create 10 | end 11 | 12 | def find(client, id) 13 | api_resource = new(client, 'id' => id) 14 | api_resource.retrieve 15 | api_resource 16 | end 17 | 18 | def name 19 | to_s.demodulize.underscore 20 | end 21 | 22 | def create_path 23 | "/#{name}s" 24 | end 25 | 26 | def create_url 27 | MagicBell.api_host + create_path 28 | end 29 | end 30 | 31 | attr_reader :id 32 | 33 | def initialize(client, attributes = {}) 34 | @client = client 35 | @attributes = attributes 36 | 37 | @id = @attributes['id'] 38 | @retrieved = true if @id 39 | end 40 | 41 | def attributes 42 | retrieve_unless_retrieved 43 | @attributes 44 | end 45 | 46 | alias_method :to_h, :attributes 47 | 48 | def attribute(attribute_name) 49 | attributes[attribute_name] 50 | end 51 | 52 | def retrieve 53 | response = @client.get(url) 54 | parse_response(response) 55 | 56 | self 57 | end 58 | 59 | def name 60 | self.class.name 61 | end 62 | 63 | def create_url 64 | MagicBell.api_host + create_path 65 | end 66 | 67 | def create_path 68 | "/#{name}s" 69 | end 70 | 71 | def url 72 | MagicBell.api_host + path 73 | end 74 | 75 | def path 76 | "/#{name}s/#{id}" 77 | end 78 | 79 | def create 80 | response = @client.post( 81 | create_url, 82 | body: { name => attributes }.to_json, 83 | headers: extra_headers 84 | ) 85 | parse_response(response) 86 | 87 | self 88 | end 89 | 90 | def update(new_attributes = {}) 91 | response = @client.put( 92 | url, 93 | body: new_attributes.to_json, 94 | headers: extra_headers 95 | ) 96 | parse_response(response) 97 | 98 | self 99 | end 100 | 101 | protected 102 | 103 | def extra_headers 104 | {} 105 | end 106 | 107 | private 108 | 109 | attr_reader :response, :response_hash 110 | 111 | def retrieve_unless_retrieved 112 | return unless id # Never retrieve a new unsaved resource 113 | return if @retrieved 114 | 115 | retrieve 116 | end 117 | 118 | def parse_response(response) 119 | @response = response 120 | unless response.body.blank? 121 | @response_hash = JSON.parse(@response.body) 122 | @attributes = @response_hash[name] 123 | end 124 | @retrieved = true 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/magicbell/api_resource_collection.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class ApiResourceCollection 3 | def initialize(client, query_params = {}) 4 | @client = client 5 | @query_params = query_params 6 | @retrieved = false 7 | end 8 | 9 | # @todo Add examples 10 | def retrieve 11 | @response = @client.get( 12 | url, 13 | query: @query_params 14 | ) 15 | @response_hash = JSON.parse(response.body) 16 | @resources = response_hash[name].map { |resource_attributes| resource_class.new(@client, resource_attributes) } 17 | @retrieved = true 18 | 19 | self 20 | end 21 | 22 | def to_a 23 | resources 24 | end 25 | 26 | def first 27 | resources.first 28 | end 29 | 30 | def url 31 | MagicBell.api_host + path 32 | end 33 | 34 | def authentication_headers 35 | MagicBell.authentication_headers 36 | end 37 | 38 | def each(&block) 39 | resources.each(&block) 40 | end 41 | 42 | def each_page 43 | current_page = self 44 | loop do 45 | yield(current_page) 46 | break if current_page.last_page? 47 | 48 | current_page = current_page.next_page 49 | end 50 | end 51 | 52 | def last_page? 53 | current_page == total_pages 54 | end 55 | 56 | def next_page 57 | self.class.new(@client, page: current_page + 1, per_page: per_page) 58 | end 59 | 60 | def current_page 61 | retrieve_unless_retrieved 62 | response_hash['current_page'] 63 | end 64 | 65 | def total_pages 66 | retrieve_unless_retrieved 67 | response_hash['total_pages'] 68 | end 69 | 70 | def per_page 71 | retrieve_unless_retrieved 72 | response_hash['per_page'] 73 | end 74 | 75 | private 76 | 77 | attr_reader :response, :response_hash 78 | 79 | def resources 80 | retrieve_unless_retrieved 81 | @resources 82 | end 83 | 84 | def retrieve_unless_retrieved 85 | return if @retrieved 86 | 87 | retrieve 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/notification.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class Notification < ApiResource 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class User < ApiResource 3 | include ApiOperations 4 | 5 | attr_reader :email, :external_id 6 | 7 | def initialize(client, attributes) 8 | @client = client 9 | @email = attributes['email'] 10 | @external_id = attributes['external_id'] 11 | 12 | super(client, attributes) 13 | end 14 | 15 | def notifications(query_params = {}) 16 | client = self 17 | MagicBell::UserNotifications.new(client, query_params) 18 | end 19 | 20 | def find_notification(notification_id) 21 | client = self 22 | MagicBell::UserNotification.find(client, notification_id) 23 | end 24 | 25 | def mark_all_notifications_as_read 26 | client = self 27 | MagicBell::UserNotificationsRead.new(client).create 28 | end 29 | 30 | def mark_all_notifications_as_seen 31 | client = self 32 | MagicBell::UserNotificationsSeen.new(client).create 33 | end 34 | 35 | def notification_preferences 36 | client = self 37 | MagicBell::UserNotificationPreferences.new(client) 38 | end 39 | 40 | def path 41 | if id 42 | self.class.path + "/#{id}" 43 | elsif external_id 44 | self.class.path + "/external_id:#{external_id}" 45 | elsif email 46 | self.class.path + "/email:#{email}" 47 | end 48 | end 49 | 50 | def authentication_headers 51 | if external_id 52 | MagicBell.authentication_headers.merge('X-MAGICBELL-USER-EXTERNAL-ID' => external_id) 53 | elsif email 54 | MagicBell.authentication_headers.merge('X-MAGICBELL-USER-EMAIL' => email) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user_notification.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class UserNotification < ApiResource 3 | class << self 4 | def path 5 | '/notifications' 6 | end 7 | end 8 | 9 | def path 10 | "/notifications/#{id}" 11 | end 12 | 13 | def mark_as_read 14 | UserNotificationRead.new(@client, 'user_notification' => self).create 15 | end 16 | 17 | def mark_as_unread 18 | UserNotificationUnread.new(@client, 'user_notification' => self).create 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user_notification_preferences.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class UserNotificationPreferences < ApiResource 3 | def name 4 | 'notification_preferences' 5 | end 6 | 7 | def path 8 | '/notification_preferences' 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user_notification_read.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class UserNotificationRead < SingletonApiResource 3 | attr_reader :user_notification 4 | 5 | def initialize(client, attributes) 6 | @user_notification = attributes.delete('user_notification') 7 | super(client, attributes) 8 | end 9 | 10 | def path 11 | user_notification.path + '/read' 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user_notification_unread.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class UserNotificationUnread < SingletonApiResource 3 | attr_reader :user_notification 4 | 5 | def initialize(client, attributes) 6 | @user_notification = attributes.delete('user_notification') 7 | super(client, attributes) 8 | end 9 | 10 | def path 11 | user_notification.path + '/unread' 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user_notifications.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class UserNotifications < ApiResourceCollection 3 | def name 4 | 'notifications' 5 | end 6 | 7 | def path 8 | '/notifications' 9 | end 10 | 11 | def resource_class 12 | MagicBell::UserNotification 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user_notifications_read.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class UserNotificationsRead < SingletonApiResource 3 | def path 4 | '/notifications/read' 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/magicbell/api_resources/user_notifications_seen.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class UserNotificationsSeen < SingletonApiResource 3 | def path 4 | '/notifications/seen' 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/magicbell/client.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class Client 3 | class HTTPError < StandardError 4 | attr_accessor :response_status, 5 | :response_headers, 6 | :response_body, 7 | :errors 8 | end 9 | 10 | include ApiOperations 11 | 12 | def initialize(api_key: nil, api_secret: nil) 13 | @api_key = api_key 14 | @api_secret = api_secret 15 | end 16 | 17 | def create_notification(notification_attributes) 18 | MagicBell::Notification.create(self, notification_attributes) 19 | end 20 | 21 | def user_with_email(email) 22 | client = self 23 | MagicBell::User.new(client, 'email' => email) 24 | end 25 | 26 | def user_with_external_id(external_id) 27 | client = self 28 | MagicBell::User.new(client, 'external_id' => external_id) 29 | end 30 | 31 | def authentication_headers 32 | MagicBell.authentication_headers(client_api_key: @api_key, client_api_secret: @api_secret) 33 | end 34 | 35 | def hmac(message) 36 | secret = @api_secret || MagicBell.api_secret 37 | digest = OpenSSL::HMAC.digest(sha256_digest, secret, message) 38 | 39 | Base64.encode64(digest).strip 40 | end 41 | 42 | private 43 | 44 | def sha256_digest 45 | OpenSSL::Digest.new('sha256') 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/magicbell/config.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class Config 3 | attr_writer :api_key, :api_secret, :api_host 4 | 5 | def initialize 6 | @api_host = 'https://api.magicbell.io' 7 | end 8 | 9 | def api_key 10 | @api_key || ENV['MAGICBELL_API_KEY'] 11 | end 12 | 13 | def api_secret 14 | @api_secret || ENV['MAGICBELL_API_SECRET'] 15 | end 16 | 17 | def api_host 18 | @api_host || ENV['MAGICBELL_API_HOST'] || 'https://api.magicbell.io' 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/magicbell/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | require 'magicbell/action_mailer_extension' 3 | 4 | module MagicBell 5 | class Railtie < Rails::Railtie 6 | initializer 'magicbell' do 7 | ActionMailer::Base.send :include, MagicBell::ActionMailerExtension 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/magicbell/singleton_api_resource.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | class SingletonApiResource < ApiResource 3 | def create_path 4 | path 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/magicbell/version.rb: -------------------------------------------------------------------------------- 1 | module MagicBell 2 | VERSION = '2.2.1' 3 | end 4 | -------------------------------------------------------------------------------- /magicbell.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("lib", __dir__) 2 | 3 | # Maintain your gem's version: 4 | require "magicbell/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "magicbell" 9 | s.version = MagicBell::VERSION 10 | s.authors = ["Hana Mohan", "Nisanth Chunduru", "Rahmane Ousmane", "Josue Montano"] 11 | s.email = ["hana@magicbell.io", "nisanth@supportbee.com", "rahmane@magicbell.io", "josue@magicbell.io"] 12 | s.homepage = "https://magicbell.com" 13 | s.summary = "Ruby Library for MagicBell" 14 | s.description = "The notification inbox for your product" 15 | s.license = "MIT" 16 | 17 | s.files = Dir["{lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 18 | 19 | s.add_dependency 'httparty' 20 | s.add_dependency 'activesupport' 21 | s.add_dependency 'colorize' 22 | 23 | s.add_development_dependency "actionmailer" 24 | s.add_development_dependency "rspec", '~> 3.9' 25 | s.add_development_dependency "webmock" 26 | s.add_development_dependency "pry" 27 | s.add_development_dependency "rake" 28 | 29 | s.post_install_message = %q{ 30 | *** Breaking Change:: The 2.0.0 release removes the BCC functionality. *** 31 | Please update your MagicBell integration to use the API for creating notifications or downgrade to 1.0.4 32 | } 33 | end 34 | -------------------------------------------------------------------------------- /spec/magicbell/action_mailer_extension_spec.rb: -------------------------------------------------------------------------------- 1 | require "action_mailer" 2 | require "magicbell/action_mailer_extension" 3 | 4 | describe MagicBell::ActionMailerExtension do 5 | describe "Helper methods" do 6 | let(:custom_action_url) { "https://myapp.com/comments/1" } 7 | let(:metadata) { { comment_id: 1 } } 8 | let(:custom_title) { "I'd like to have a title that is different from the email's subject" } 9 | let(:notification_mailer) { 10 | Class.new(ActionMailer::Base) do 11 | include MagicBell::ActionMailerExtension 12 | 13 | def new_comment 14 | mail( 15 | from: "notifications@mydummyapp.com", 16 | to: "dummycustomer@example.com", 17 | subject: "Just a dummy notification", 18 | body: "Just a dummy notification" 19 | ) 20 | custom_action_url = "https://myapp.com/comments/1" 21 | magicbell_notification_action_url(custom_action_url) 22 | magicbell_notification_metadata(comment_id: 1) 23 | custom_title = "I'd like to have a title that is different from the email's subject" 24 | magicbell_notification_title(custom_title) 25 | end 26 | 27 | def reaching_monthly_limit 28 | mail( 29 | from: "notifications@mydummyapp.com", 30 | to: "dummycustomer@example.com", 31 | subject: "Just a dummy notification", 32 | body: "Just a dummy notification" 33 | ) 34 | magicbell_notification_skip 35 | end 36 | end 37 | } 38 | 39 | describe "#magicbell_notification_action_url" do 40 | it "adds the 'X-MagicBell-Notification-ActionUrl' header" do 41 | mail = notification_mailer.new_comment 42 | expect(mail["X-MagicBell-Notification-ActionUrl"].decoded).to eq(custom_action_url) 43 | end 44 | end 45 | 46 | describe "#magicbell_notification_metadata" do 47 | it "adds the 'X-MagicBell-Notification-Metadata' header" do 48 | mail = notification_mailer.new_comment 49 | expect(mail["X-MagicBell-Notification-Metadata"].decoded).to eq(metadata.to_json) 50 | end 51 | end 52 | 53 | describe "#magicbell_notification_title" do 54 | it "adds the 'X-MagicBell-Notification-Title' header" do 55 | mail = notification_mailer.new_comment 56 | expect(mail["X-MagicBell-Notification-Title"].decoded).to eq(custom_title) 57 | end 58 | end 59 | 60 | describe "#magicbell_notification_skip" do 61 | it "adds the 'X-MagicBell-Notification-Skip' header" do 62 | mail = notification_mailer.reaching_monthly_limit 63 | expect(mail["X-MagicBell-Notification-Skip"].decoded).to eq("true") 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/magicbell/client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe MagicBell::Client do 4 | let(:api_key) { "dummy_api_key" } 5 | let(:api_secret) { "dummy_api_secret" } 6 | let(:api_host) { "https://api.magicbell.io" } 7 | let(:user_email) { "joe@example.com" } 8 | let(:user_external_id) { "2acb4ac3-8a32-408a-a057-194dfbe89126" } 9 | let(:headers) { 10 | { 11 | "X-MAGICBELL-API-KEY" => api_key, 12 | "X-MAGICBELL-API-SECRET" => api_secret, 13 | "Content-Type" => "application/json", 14 | "Accept" => "application/json", 15 | } 16 | } 17 | let(:user_authentication_headers) { headers.merge("X-MAGICBELL-USER-EMAIL" => user_email) } 18 | 19 | def base64_decode(base64_decoded_string) 20 | Base64.decode64(base64_decoded_string) 21 | end 22 | 23 | before(:each) do 24 | ENV["MAGICBELL_API_KEY"] = api_key 25 | ENV["MAGICBELL_API_SECRET"] = api_secret 26 | 27 | WebMock.enable! 28 | end 29 | 30 | describe "#create_notification" do 31 | let(:notifications_url) { api_host + "/notifications" } 32 | 33 | context "when recipient is identified by email" do 34 | it "creates a notification" do 35 | body = { 36 | "notification" => { 37 | "title" => "Welcome to Muziboo", 38 | "recipients" => [{ 39 | "email" => "john@example.com" 40 | }] 41 | } 42 | }.to_json 43 | stub_request(:post, notifications_url).with(headers: headers, body: body).and_return(status: 201, body: "{}") 44 | 45 | magicbell = MagicBell::Client.new 46 | magicbell.create_notification( 47 | title: "Welcome to Muziboo", 48 | recipients: [{ 49 | email: "john@example.com" 50 | }] 51 | ) 52 | end 53 | end 54 | 55 | context "when recipient is identified by external_id" do 56 | it "creates a notification" do 57 | body = { 58 | "notification" => { 59 | "title" => "Welcome to Muziboo", 60 | "recipients" => [{ 61 | "external_id" => "id_in_your_database" 62 | }] 63 | } 64 | }.to_json 65 | stub_request(:post, notifications_url).with(headers: headers, body: body).and_return(status: 201, body: "{}") 66 | 67 | magicbell = MagicBell::Client.new 68 | magicbell.create_notification( 69 | title: "Welcome to Muziboo", 70 | recipients: [{ 71 | external_id: "id_in_your_database" 72 | }] 73 | ) 74 | end 75 | end 76 | 77 | context "API response was not a 2xx response" do 78 | 79 | let(:title) { "Welcome to Muziboo" } 80 | let(:body) do 81 | { 82 | "notification" => { 83 | "title" => title 84 | } 85 | }.to_json 86 | end 87 | let(:magicbell) { MagicBell::Client.new } 88 | 89 | context("and there is no response body") do 90 | before do 91 | stub_request(:post, notifications_url).with(headers: headers, body: body).and_return(status: 422) 92 | end 93 | 94 | it "raises an error" do 95 | exception_thrown = nil 96 | begin 97 | magicbell.create_notification(title: title) 98 | rescue MagicBell::Client::HTTPError => e 99 | exception_thrown = e 100 | end 101 | 102 | expect(exception_thrown.response_status).to eq(422) 103 | expect(exception_thrown.response_headers).to eq({}) 104 | expect(exception_thrown.response_body).to eq("") 105 | end 106 | end 107 | 108 | context("and there is a response body with an errors block") do 109 | let(:response_body) do 110 | { 111 | "errors"=> 112 | [ 113 | { 114 | "code" => "api_secret_not_provided", 115 | "suggestion" => "Please provide the 'X-MAGICBELL-API-SECRET' header containing your MagicBell project's API secret. Alternatively, if you intend to use MagicBell's API in JavaScript in your web application's frontend, please provide the 'X-MAGICBELL-USER-EMAIL' header containing a user's email and the 'X-MAGICBELL-USER-HMAC' containing the user's HMAC.", 116 | "message" => "API Secret not provided", 117 | "help_link" => "https://developer.magicbell.io/reference#authentication" 118 | } 119 | ] 120 | }.to_json 121 | end 122 | 123 | before do 124 | stub_request(:post, notifications_url).with(headers: headers, body: body).and_return(status: 422, body: response_body) 125 | end 126 | 127 | it "raises and displays an error" do 128 | exception_thrown = nil 129 | begin 130 | magicbell.create_notification(title: title) 131 | rescue MagicBell::Client::HTTPError => e 132 | exception_thrown = e 133 | end 134 | 135 | expect(exception_thrown.response_status).to eq(422) 136 | expect(exception_thrown.response_headers).to eq({}) 137 | expect(exception_thrown.response_body).to eq(response_body) 138 | expect(exception_thrown.errors.length).to eq(1) 139 | end 140 | end 141 | 142 | context("and there is a response body with no errors block") do 143 | let(:response_body) { {}.to_json } 144 | 145 | before do 146 | stub_request(:post, notifications_url).with(headers: headers, body: body).and_return(status: 422, body: response_body) 147 | end 148 | 149 | it "raises and displays an error" do 150 | exception_thrown = nil 151 | begin 152 | magicbell.create_notification(title: title) 153 | rescue MagicBell::Client::HTTPError => e 154 | exception_thrown = e 155 | end 156 | 157 | expect(exception_thrown.response_status).to eq(422) 158 | expect(exception_thrown.response_headers).to eq({}) 159 | expect(exception_thrown.response_body).to eq(response_body) 160 | expect(exception_thrown.errors.length).to eq(0) 161 | end 162 | end 163 | 164 | context("and there is a response body with with a nonarray errors block") do 165 | let(:response_body) { { "errors" => "testing 1234" }.to_json } 166 | 167 | before do 168 | stub_request(:post, notifications_url).with(headers: headers, body: body).and_return(status: 422, body: response_body) 169 | end 170 | 171 | it "raises and displays an error" do 172 | exception_thrown = nil 173 | begin 174 | magicbell.create_notification(title: title) 175 | rescue MagicBell::Client::HTTPError => e 176 | exception_thrown = e 177 | end 178 | 179 | expect(exception_thrown.response_status).to eq(422) 180 | expect(exception_thrown.response_headers).to eq({}) 181 | expect(exception_thrown.response_body).to eq(response_body) 182 | expect(exception_thrown.errors.length).to eq(0) 183 | end 184 | end 185 | end 186 | end 187 | 188 | describe "Mark a user notification as read" do 189 | let(:notification_id) { "dummy_notification_id" } 190 | let(:notification_url) { "#{api_host}/notifications/#{notification_id}" } 191 | let(:notification_read_url) { "#{notification_url}/read" } 192 | 193 | it "can mark a user notification as read" do 194 | magicbell = MagicBell::Client.new 195 | user = magicbell.user_with_email(user_email) 196 | response_body = { 197 | "notification" => { 198 | "id" => "notification_id" 199 | } 200 | }.to_json 201 | 202 | stub_request(:get, notification_url).with(headers: user_authentication_headers).and_return(status: 200, body: response_body) 203 | user_notification = user.find_notification(notification_id) 204 | 205 | stub_request(:post, notification_read_url).with(headers: user_authentication_headers).and_return(status: 204) 206 | user_notification.mark_as_read 207 | end 208 | end 209 | 210 | describe "Fetching a user's notifications" do 211 | let("notifications_url") { "#{api_host}/notifications" } 212 | 213 | context "when the user is identified by external_id" do 214 | let(:user_authentication_headers) { headers.merge("X-MAGICBELL-USER-EXTERNAL-ID" => user_external_id) } 215 | 216 | it "can fetch a user's notifications" do 217 | magicbell = MagicBell::Client.new 218 | user = magicbell.user_with_external_id(user_external_id) 219 | 220 | response_body = { 221 | "notifications" => [ 222 | { 223 | "id" => "a", 224 | }, 225 | { 226 | "id" => "b" 227 | } 228 | ] 229 | }.to_json 230 | fetch_notifications_request = stub_request(:get, notifications_url).with(headers: user_authentication_headers).and_return(status: 200, body: response_body) 231 | user.notifications.each { |notification| notification.attribute("title") } 232 | 233 | assert_requested(fetch_notifications_request) 234 | end 235 | end 236 | 237 | context "when the user is identified by email" do 238 | it "can fetch a user's notifications" do 239 | magicbell = MagicBell::Client.new 240 | user = magicbell.user_with_email(user_email) 241 | 242 | response_body = { 243 | "notifications" => [ 244 | { 245 | "id" => "a", 246 | }, 247 | { 248 | "id" => "b" 249 | } 250 | ] 251 | }.to_json 252 | fetch_notifications_request = stub_request(:get, notifications_url).with(headers: user_authentication_headers).and_return(status: 200, body: response_body) 253 | user.notifications.each { |notification| notification.attribute("title") } 254 | 255 | assert_requested(fetch_notifications_request) 256 | end 257 | end 258 | end 259 | 260 | describe "Mark a user notification as unread" do 261 | let(:notification_id) { "dummy_notification_id" } 262 | let(:notification_url) { "#{api_host}/notifications/#{notification_id}" } 263 | let(:notification_read_url) { "#{notification_url}/unread" } 264 | 265 | it "can mark a user notification as unread" do 266 | magicbell = MagicBell::Client.new 267 | user = magicbell.user_with_email(user_email) 268 | response_body = { 269 | "notification" => { 270 | "id" => "notification_id" 271 | } 272 | }.to_json 273 | fetch_notification_request = stub_request(:get, notification_url).with(headers: user_authentication_headers).and_return(status: 200, body: response_body) 274 | user_notification = user.find_notification(notification_id) 275 | mark_notification_as_read_request = stub_request(:post, notification_read_url).with(headers: user_authentication_headers).and_return(status: 204) 276 | user_notification.mark_as_unread 277 | 278 | assert_requested(fetch_notification_request) 279 | assert_requested(mark_notification_as_read_request) 280 | end 281 | end 282 | 283 | describe "Mark all user notifications as read" do 284 | let(:notifications_read_url) { "#{api_host}/notifications/read" } 285 | 286 | it "can mark all of a user's notifications as read" do 287 | magicbell = MagicBell::Client.new 288 | user = magicbell.user_with_email(user_email) 289 | mark_all_notifications_as_read_request = stub_request(:post, notifications_read_url).with(headers: user_authentication_headers).and_return(status: 204) 290 | user.mark_all_notifications_as_read 291 | 292 | assert_requested(mark_all_notifications_as_read_request) 293 | end 294 | end 295 | 296 | describe "Mark all user notifications as seen" do 297 | let(:notifications_seen_url) { "#{api_host}/notifications/seen" } 298 | 299 | it "can mark all of a user's notifications as seen" do 300 | magicbell = MagicBell::Client.new 301 | user = magicbell.user_with_email(user_email) 302 | mark_all_notification_as_seen_request = stub_request(:post, notifications_seen_url).with(headers: user_authentication_headers).and_return(status: 204) 303 | user.mark_all_notifications_as_seen 304 | 305 | assert_requested(mark_all_notification_as_seen_request) 306 | end 307 | end 308 | 309 | describe "Configuration" do 310 | it "is also configurable in an initializer" do 311 | ENV.delete("MAGICBELL_API_KEY") 312 | ENV.delete("MAGICBELL_API_SECRET") 313 | WebMock.enable! 314 | 315 | MagicBell.configure do |config| 316 | config.api_key = api_key 317 | config.api_secret = api_secret 318 | end 319 | 320 | headers = { 321 | "X-MAGICBELL-API-KEY" => api_key, 322 | "X-MAGICBELL-API-SECRET" => api_secret, 323 | "Content-Type" => "application/json", 324 | "Accept" => "application/json", 325 | } 326 | body = { 327 | "notification" => { 328 | "title" => "Welcome to Muziboo", 329 | "recipients" => [{ 330 | "email" => "john@example.com" 331 | }] 332 | } 333 | }.to_json 334 | stub_request(:post, "#{api_host}/notifications").with(headers: headers, body: body).and_return(status: 201, body: "{}") 335 | 336 | magicbell = MagicBell::Client.new 337 | magicbell.create_notification( 338 | title: "Welcome to Muziboo", 339 | recipients: [{ 340 | email: "john@example.com" 341 | }] 342 | ) 343 | end 344 | end 345 | 346 | describe "API key and API secret configuration" do 347 | let(:client_api_key) { 'client_api_key' } 348 | let(:client_api_secret) { 'client_api_secret' } 349 | 350 | context "No API key and API secret provided" do 351 | it "keeps the global headers" do 352 | magicbell = MagicBell::Client.new 353 | 354 | expect(magicbell.authentication_headers).to eq("X-MAGICBELL-API-KEY" => api_key, "X-MAGICBELL-API-SECRET" => api_secret) 355 | end 356 | end 357 | 358 | context "API key and API key provided" do 359 | it "overrides the global headers with the project headers" do 360 | magicbell = MagicBell::Client.new(api_key: client_api_key, api_secret: client_api_secret) 361 | 362 | expect(magicbell.authentication_headers).to eq("X-MAGICBELL-API-KEY" => client_api_key, "X-MAGICBELL-API-SECRET" => client_api_secret) 363 | end 364 | end 365 | end 366 | 367 | describe "#hmac" do 368 | let(:client_api_key) { 'client_api_key' } 369 | let(:client_api_secret) { 'client_api_secret' } 370 | 371 | context "Using the global API secret" do 372 | it "calculates the hmac for the given string" do 373 | magicbell = MagicBell::Client.new 374 | hmac = magicbell.hmac(user_email) 375 | sha256_digest = OpenSSL::Digest.new('sha256') 376 | 377 | expect(base64_decode(hmac)).to eq(OpenSSL::HMAC.digest(sha256_digest, api_secret, user_email)) 378 | end 379 | end 380 | 381 | context "Using the client API secret" do 382 | it "calculates the hmac for the given string" do 383 | magicbell = MagicBell::Client.new(api_key: client_api_key, api_secret: client_api_secret) 384 | hmac = magicbell.hmac(user_email) 385 | sha256_digest = OpenSSL::Digest.new('sha256') 386 | 387 | expect(base64_decode(hmac)).to eq(OpenSSL::HMAC.digest(sha256_digest, client_api_secret, user_email)) 388 | end 389 | end 390 | end 391 | end 392 | -------------------------------------------------------------------------------- /spec/magicbell/config_spec.rb: -------------------------------------------------------------------------------- 1 | describe MagicBell::Config do 2 | describe "#api_host" do 3 | it "should default to api.magicbell.io" do 4 | expect(MagicBell::Config.new.api_host).to eq "https://api.magicbell.io" 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /spec/magicbell_spec.rb: -------------------------------------------------------------------------------- 1 | describe MagicBell do 2 | let(:api_key) { "dummy_api_key" } 3 | let(:api_secret) { "dummy_api_secret" } 4 | 5 | before do 6 | MagicBell.configure do |config| 7 | config.api_key = api_key 8 | config.api_secret = api_secret 9 | end 10 | end 11 | 12 | after do 13 | MagicBell.reset_config 14 | end 15 | 16 | describe ".configure" do 17 | it "configures the gem" do 18 | expect(MagicBell.api_key).to eq(api_key) 19 | expect(MagicBell.api_secret).to eq(api_secret) 20 | expect(MagicBell.api_secret).to eq(api_secret) 21 | end 22 | end 23 | 24 | def base64_decode(base64_decoded_string) 25 | Base64.decode64(base64_decoded_string) 26 | end 27 | 28 | describe "#hmac" do 29 | let(:user_email) { "john@example.com" } 30 | let(:magicbell) { MagicBell::Client.new } 31 | 32 | it "calls the hmac method on MagicBell::Client object" do 33 | expect(MagicBell::Client).to receive(:new).with(api_key: MagicBell.api_key, api_secret: MagicBell.api_secret).and_return(magicbell) 34 | expect(magicbell).to receive(:hmac).with(user_email) 35 | 36 | MagicBell.hmac(user_email) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it 14 | 15 | require "webmock/rspec" 16 | 17 | require "pry" 18 | 19 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 20 | RSpec.configure do |config| 21 | # rspec-expectations config goes here. You can use an alternate 22 | # assertion/expectation library such as wrong or the stdlib/minitest 23 | # assertions if you prefer. 24 | config.expect_with :rspec do |expectations| 25 | # This option will default to `true` in RSpec 4. It makes the `description` 26 | # and `failure_message` of custom matchers include text for helper methods 27 | # defined using `chain`, e.g.: 28 | # be_bigger_than(2).and_smaller_than(4).description 29 | # # => "be bigger than 2 and smaller than 4" 30 | # ...rather than: 31 | # # => "be bigger than 2" 32 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 33 | end 34 | 35 | # rspec-mocks config goes here. You can use an alternate test double 36 | # library (such as bogus or mocha) by changing the `mock_with` option here. 37 | config.mock_with :rspec do |mocks| 38 | # Prevents you from mocking or stubbing a method that does not exist on 39 | # a real object. This is generally recommended, and will default to 40 | # `true` in RSpec 4. 41 | mocks.verify_partial_doubles = true 42 | end 43 | 44 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 45 | # have no way to turn it off -- the option exists only for backwards 46 | # compatibility in RSpec 3). It causes shared context metadata to be 47 | # inherited by the metadata hash of host groups and examples, rather than 48 | # triggering implicit auto-inclusion in groups with matching metadata. 49 | config.shared_context_metadata_behavior = :apply_to_host_groups 50 | 51 | # The settings below are suggested to provide a good initial experience 52 | # with RSpec, but feel free to customize to your heart's content. 53 | =begin 54 | # This allows you to limit a spec run to individual examples or groups 55 | # you care about by tagging them with `:focus` metadata. When nothing 56 | # is tagged with `:focus`, all examples get run. RSpec also provides 57 | # aliases for `it`, `describe`, and `context` that include `:focus` 58 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 59 | config.filter_run_when_matching :focus 60 | 61 | # Allows RSpec to persist some state between runs in order to support 62 | # the `--only-failures` and `--next-failure` CLI options. We recommend 63 | # you configure your source control system to ignore this file. 64 | config.example_status_persistence_file_path = "spec/examples.txt" 65 | 66 | # Limits the available syntax to the non-monkey patched syntax that is 67 | # recommended. For more details, see: 68 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 69 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 70 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 71 | config.disable_monkey_patching! 72 | 73 | # This setting enables warnings. It's recommended, but in some cases may 74 | # be too noisy due to issues in dependencies. 75 | config.warnings = true 76 | 77 | # Many RSpec users commonly either run the entire suite or an individual 78 | # file, and it's useful to allow more verbose output when running an 79 | # individual spec file. 80 | if config.files_to_run.one? 81 | # Use the documentation formatter for detailed output, 82 | # unless a formatter has already been configured 83 | # (e.g. via a command-line flag). 84 | config.default_formatter = "doc" 85 | end 86 | 87 | # Print the 10 slowest examples and example groups at the 88 | # end of the spec run, to help surface which specs are running 89 | # particularly slow. 90 | config.profile_examples = 10 91 | 92 | # Run specs in random order to surface order dependencies. If you find an 93 | # order dependency and want to debug it, you can fix the order by providing 94 | # the seed, which is printed after each run. 95 | # --seed 1234 96 | config.order = :random 97 | 98 | # Seed global randomization in this process using the `--seed` CLI option. 99 | # Setting this allows you to use `--seed` to deterministically reproduce 100 | # test failures related to randomization by passing the same `--seed` value 101 | # as the one that triggered the failure. 102 | Kernel.srand config.seed 103 | =end 104 | 105 | config.before(:all) do 106 | # MagicBell.configure do |config| 107 | # config.magic_address = "dummy_magic_address@ring.magicbell.io" 108 | # config.api_key = "dummy_api_key" 109 | # config.api_secret = "dummy_api_secret" 110 | # config.project_id = 1 111 | # config.api_host = "https://api.example.com" 112 | # end 113 | 114 | WebMock.disable! # Disable webmock in case someone forgets to do it in a spec 115 | end 116 | 117 | config.after(:all) do 118 | MagicBell.reset_config 119 | ENV.delete("MAGICBELL_API_KEY") 120 | ENV.delete("MAGICBELL_API_SECRET") 121 | end 122 | end 123 | --------------------------------------------------------------------------------