├── example ├── Procfile ├── Gemfile ├── README.md └── config.ru ├── Gemfile ├── lib └── rack │ ├── passbook │ ├── version.rb │ ├── migrations │ │ ├── 002_add_full_text_search.rb │ │ └── 001_base_schema.rb │ └── models │ │ ├── pass.rb │ │ └── registration.rb │ └── passbook.rb ├── Rakefile ├── Gemfile.lock ├── rack-passbook.gemspec ├── LICENSE.md └── README.md /example/Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec thin start -p $PORT 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /lib/rack/passbook/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class Passbook 5 | VERSION = '0.3.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'rack-passbook', :require => 'rack/passbook', :path => File.join(__FILE__, "../..") 4 | 5 | gem 'thin' 6 | gem 'pg' 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | Bundler.setup 5 | 6 | gemspec = eval(File.read('rack-passbook.gemspec')) 7 | 8 | task build: "#{gemspec.full_name}.gem" 9 | 10 | file "#{gemspec.full_name}.gem" => gemspec.files + ['rack-passbook.gemspec'] do 11 | system 'gem build rack-passbook.gemspec' 12 | end 13 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Rack::Passbook Example 2 | 3 | ## Instructions 4 | 5 | To run the example application, ensure that you have Postgres running locally (see [Postgres.app](http://postgresapp.com) for an easy way to get set up on a Mac), and run the following commands: 6 | 7 | ```sh 8 | $ cd example 9 | $ psql -c "CREATE DATABASE passbook_example;" 10 | $ echo "DATABASE_URL=postgres://localhost:5432/passbook_example" > .env 11 | $ bundle 12 | $ foreman start 13 | ``` 14 | -------------------------------------------------------------------------------- /example/config.ru: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler.require 3 | 4 | run Rack::Passbook 5 | 6 | # Seed data if no records currently exist 7 | if Rack::Passbook::Pass.count == 0 8 | pass = Rack::Passbook::Pass.create(pass_type_identifier: "com.company.pass.example", serial_number: "ABC123", authentication_token: "XYZ456") 9 | pass.data = { 10 | foo: 57, 11 | bar: Time.now, 12 | baz: "Lorem ipsum dolar sit amet" 13 | }.hstore 14 | 15 | pass.save 16 | 17 | pass.registrations << Rack::Passbook::Registration.create(pass_id: pass.id, device_library_identifier: "123456789", push_token: "0" * 40) 18 | pass.save 19 | end 20 | -------------------------------------------------------------------------------- /lib/rack/passbook/migrations/002_add_full_text_search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Sequel.migration do 4 | up do 5 | add_column :passbook_passes, :tsv, 'TSVector' 6 | add_index :passbook_passes, :tsv, type: 'GIN' 7 | create_trigger :passbook_passes, :tsv, :tsvector_update_trigger, 8 | args: %i[tsv pg_catalog.english pass_type_identifier serial_number], 9 | events: %i[insert update], 10 | each_row: true 11 | end 12 | 13 | down do 14 | drop_column :passbook_passes, :tsv 15 | drop_index :passbook_passes, :tsv 16 | drop_trigger :passbook_passes, :tsv 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rack/passbook/models/pass.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class Passbook 5 | class Pass < Sequel::Model 6 | plugin :json_serializer, naked: true, except: :id 7 | plugin :validation_helpers 8 | plugin :timestamps, force: true, update_on_create: true 9 | plugin :schema 10 | plugin :typecast_on_load 11 | 12 | self.dataset = :passbook_passes 13 | self.strict_param_setting = false 14 | self.raise_on_save_failure = false 15 | 16 | one_to_many :registrations, class_name: 'Rack::Passbook::Registration' 17 | 18 | def validate 19 | super 20 | 21 | validates_presence %i[pass_type_identifier serial_number] 22 | validates_unique :pass_type_identifier 23 | validates_unique %i[serial_number pass_type_identifier] 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rack-passbook (0.3.0) 5 | rack (~> 1.4) 6 | sequel (~> 3.37) 7 | sinatra (~> 1.3) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | diff-lcs (1.3) 13 | rack (1.6.11) 14 | rack-protection (1.5.5) 15 | rack 16 | rake (12.3.2) 17 | rspec (3.8.0) 18 | rspec-core (~> 3.8.0) 19 | rspec-expectations (~> 3.8.0) 20 | rspec-mocks (~> 3.8.0) 21 | rspec-core (3.8.2) 22 | rspec-support (~> 3.8.0) 23 | rspec-expectations (3.8.4) 24 | diff-lcs (>= 1.2.0, < 2.0) 25 | rspec-support (~> 3.8.0) 26 | rspec-mocks (3.8.1) 27 | diff-lcs (>= 1.2.0, < 2.0) 28 | rspec-support (~> 3.8.0) 29 | rspec-support (3.8.2) 30 | sequel (3.48.0) 31 | sinatra (1.4.8) 32 | rack (~> 1.5) 33 | rack-protection (~> 1.4) 34 | tilt (>= 1.3, < 3) 35 | tilt (2.0.9) 36 | 37 | PLATFORMS 38 | ruby 39 | 40 | DEPENDENCIES 41 | rack-passbook! 42 | rake 43 | rspec 44 | 45 | BUNDLED WITH 46 | 2.0.1 47 | -------------------------------------------------------------------------------- /lib/rack/passbook/models/registration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class Passbook 5 | class Registration < Sequel::Model 6 | plugin :json_serializer, naked: true, except: :id 7 | plugin :validation_helpers 8 | plugin :timestamps, force: true, update_on_create: true 9 | plugin :schema 10 | 11 | self.dataset = :passbook_registrations 12 | self.strict_param_setting = false 13 | self.raise_on_save_failure = false 14 | 15 | def before_validation 16 | normalize_push_token! if push_token 17 | end 18 | 19 | def validate 20 | super 21 | 22 | validates_presence :device_library_identifier 23 | validates_unique %i[device_library_identifier pass_id] 24 | validates_format /[[:xdigit:]]+/, :push_token 25 | validates_exact_length 64, :push_token 26 | end 27 | 28 | private 29 | 30 | def normalize_push_token! 31 | self.push_token = push_token.strip.gsub(/[<\s>]/, '') 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /rack-passbook.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path('lib', __dir__) 4 | require 'rack/passbook/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'rack-passbook' 8 | s.authors = ['Mattt'] 9 | s.email = 'mattt@me.com' 10 | s.license = 'MIT' 11 | s.homepage = 'https://mat.tt' 12 | s.version = Rack::Passbook::VERSION 13 | s.platform = Gem::Platform::RUBY 14 | s.summary = 'Rack::Passbook' 15 | s.description = 'Automatically generate REST APIs for Passbook registration.' 16 | 17 | s.add_development_dependency 'rake' 18 | s.add_development_dependency 'rspec' 19 | 20 | s.add_dependency 'rack', '~> 1.4' 21 | s.add_dependency 'sequel', '~> 3.37' 22 | s.add_dependency 'sinatra', '~> 1.3' 23 | 24 | s.files = Dir['./**/*'].reject { |file| file =~ %r{\./(bin|example|log|pkg|script|spec|test|vendor)} } 25 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 26 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 27 | s.require_paths = ['lib'] 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 – 2019 Mattt (https://mat.tt/) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/rack/passbook/migrations/001_base_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Sequel.migration do 4 | up do 5 | run %(CREATE EXTENSION IF NOT EXISTS hstore;) 6 | 7 | create_table :passbook_passes do 8 | primary_key :id 9 | 10 | column :pass_type_identifier, :varchar, unique: true, empty: false 11 | column :serial_number, :varchar, empty: false 12 | column :authentication_token, :varchar 13 | column :data, :hstore 14 | column :created_at, :timestamp 15 | column :updated_at, :timestamp 16 | 17 | index :pass_type_identifier 18 | index :serial_number 19 | end 20 | 21 | create_table :passbook_registrations do 22 | primary_key :id 23 | 24 | column :pass_id, :int8, null: false 25 | column :device_library_identifier, :varchar, empty: false 26 | column :push_token, :varchar 27 | column :created_at, :timestamp 28 | column :updated_at, :timestamp 29 | 30 | index :device_library_identifier 31 | end 32 | end 33 | 34 | down do 35 | drop_table :passbook_passes 36 | drop_table :passbook_registrations 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rack/passbook.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'rack/contrib' 3 | 4 | require 'sinatra/base' 5 | require 'sinatra/param' 6 | require 'rack' 7 | 8 | require 'sequel' 9 | 10 | module Rack 11 | class Passbook < Sinatra::Base 12 | 13 | use Rack::PostBodyContentTypeParser 14 | helpers Sinatra::Param 15 | 16 | autoload :Pass, 'rack/passbook/models/pass' 17 | autoload :Registration, 'rack/passbook/models/registration' 18 | 19 | disable :raise_errors, :show_exceptions 20 | 21 | configure do 22 | Sequel.extension :core_extensions, :migration, :pg_hstore, :pg_hstore_ops 23 | 24 | if ENV['DATABASE_URL'] 25 | DB = Sequel.connect(ENV['DATABASE_URL']) 26 | Sequel::Migrator.run(DB, ::File.join(::File.dirname(__FILE__), "passbook/migrations"), table: 'passbook_schema_info') 27 | end 28 | end 29 | 30 | before do 31 | content_type :json 32 | end 33 | 34 | # Get the latest version of a pass. 35 | get '/passes/:pass_type_identifier/:serial_number/?' do 36 | @pass = Pass.filter(pass_type_identifier: params[:pass_type_identifier], serial_number: params[:serial_number]).first 37 | halt 404 if @pass.nil? 38 | filter_authorization_for_pass!(@pass) 39 | 40 | last_modified @pass.updated_at.utc 41 | 42 | @pass.to_json 43 | end 44 | 45 | 46 | # Get the serial numbers for passes associated with a device. 47 | # This happens the first time a device communicates with our web service. 48 | # Additionally, when a device gets a push notification, it asks our 49 | # web service for the serial numbers of passes that have changed since 50 | # a given update tag (timestamp). 51 | get '/devices/:device_library_identifier/registrations/:pass_type_identifier/?' do 52 | @passes = Pass.filter(pass_type_identifier: params[:pass_type_identifier]).join(Registration.dataset, device_library_identifier: params[:device_library_identifier]) 53 | halt 404 if @passes.empty? 54 | 55 | @passes = @passes.filter("#{Pass.table_name}.updated_at > ?", Time.parse(params[:passesUpdatedSince])) if params[:passesUpdatedSince] 56 | 57 | if @passes.any? 58 | { 59 | lastUpdated: @passes.collect(&:updated_at).max, 60 | serialNumbers: @passes.collect(&:serial_number).collect(&:to_s) 61 | }.to_json 62 | else 63 | halt 204 64 | end 65 | end 66 | 67 | 68 | # Register a device to receive push notifications for a pass. 69 | post '/devices/:device_library_identifier/registrations/:pass_type_identifier/:serial_number/?' do 70 | @pass = Pass.where(pass_type_identifier: params[:pass_type_identifier], serial_number: params[:serial_number]).first 71 | halt 404 if @pass.nil? 72 | filter_authorization_for_pass!(@pass) 73 | 74 | param :pushToken, String, required: true 75 | 76 | @registration = @pass.registrations.detect{|registration| registration.device_library_identifier == params[:device_library_identifier]} 77 | @registration ||= Registration.new(pass_id: @pass.id, device_library_identifier: params[:device_library_identifier]) 78 | @registration.push_token = params[:pushToken] 79 | 80 | status = @registration.new? ? 201 : 200 81 | 82 | @registration.save 83 | halt 406 unless @registration.valid? 84 | 85 | halt status 86 | end 87 | 88 | # Unregister a device so it no longer receives push notifications for a pass. 89 | delete '/devices/:device_library_identifier/registrations/:pass_type_identifier/:serial_number/?' do 90 | @pass = Pass.filter(pass_type_identifier: params[:pass_type_identifier], serial_number: params[:serial_number]).first 91 | halt 404 if @pass.nil? 92 | filter_authorization_for_pass!(@pass) 93 | 94 | @registration = @pass.registrations.detect{|registration| registration.device_library_identifier == params[:device_library_identifier]} 95 | halt 404 if @registration.nil? 96 | 97 | @registration.destroy 98 | 99 | halt 200 100 | end 101 | 102 | private 103 | 104 | def filter_authorization_for_pass!(pass) 105 | halt 401 if request.env['HTTP_AUTHORIZATION'] != "ApplePass #{pass.authentication_token}" 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rack::Passbook 2 | 3 | > This project is no longer maintained. 4 | 5 | [Passbook](https://www.apple.com/ios/whats-new/#passbook) 6 | manages boarding passes, movie tickets, retail coupons, & loyalty cards. Using the [PassKit API](https://developer.apple.com/documentation/passkit), 7 | developers can register web services to automatically update content on the pass, 8 | such as gate changes on a boarding pass 9 | or adding credit to a loyalty card. 10 | 11 | Apple [provides a specification](https://developer.apple.com/library/prerelease/ios/#documentation/PassKit/Reference/PassKit_WebService/WebService.html) 12 | for a REST-style web service protocol to communicate with Passbook, 13 | with endpoints to get the latest version of a pass, 14 | register / unregister devices to receive push notifications for a pass, 15 | and query for passes registered for a device. 16 | 17 | Rack::Passbook provides those specified endpoints. 18 | 19 | ## Requirements 20 | 21 | - Ruby 1.9 or higher 22 | - PostgreSQL 9.1 or higher 23 | 24 | ## Installation 25 | 26 | ### Gemfile 27 | 28 | ```ruby 29 | gem 'rack-passbook', require: 'rack/passbook' 30 | ``` 31 | 32 | ## Example Usage 33 | 34 | Rack::Passbook can be run as Rack middleware or as a single web application. 35 | All that's required is a connection to a Postgres database. 36 | 37 | An example application can be found in the `/example` directory of this repository. 38 | 39 | ### config.ru 40 | 41 | ```ruby 42 | require 'bundler' 43 | Bundler.require 44 | 45 | run Rack::Passbook 46 | ``` 47 | 48 | --- 49 | 50 | ## Specification 51 | 52 | What follows is a summary of the specification. 53 | The complete specification can be found in the 54 | [Passbook Web Service Reference](https://developer.apple.com/library/prerelease/ios/#documentation/PassKit/Reference/PassKit_WebService/WebService.html). 55 | 56 | ### Getting the Latest Version of a Pass 57 | 58 | ``` 59 | GET https://example.com/v1/passes/:passTypeIdentifier/:serialNumber 60 | ``` 61 | 62 | - **passTypeIdentifier** The pass’s type, as specified in the pass. 63 | - **serialNumber** The unique pass identifier, as specified in the pass. 64 | 65 | **Response** 66 | 67 | - If request is authorized, return HTTP status 200 with a payload of the pass data. 68 | - If the request is not authorized, return HTTP status 401. 69 | - Otherwise, return the appropriate standard HTTP status. 70 | 71 | ### Getting the Serial Numbers for Passes Associated with a Device 72 | 73 | ``` 74 | GET https://example.com/v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier[?passesUpdatedSince=tag] 75 | ``` 76 | 77 | - **deviceLibraryIdentifier** A unique identifier that is used to identify and authenticate the device. 78 | - **passTypeIdentifier** The pass’s type, as specified in the pass. 79 | - **serialNumber** The unique pass identifier, as specified in the pass. 80 | - **passesUpdatedSince** _Optional_ A tag from a previous request. 81 | 82 | **Response** 83 | 84 | If the `passesUpdatedSince` parameter is present, return only the passes that have been updated since the time indicated by tag. Otherwise, return all passes. 85 | 86 | - If there are matching passes, return HTTP status 200 with a JSON dictionary with the following keys and values: 87 | - **lastUpdated** _(string)_ The current modification tag. 88 | - **serialNumbers** _(array of strings)_ The serial numbers of the matching passes. 89 | - If there are no matching passes, return HTTP status 204. 90 | - Otherwise, return the appropriate standard HTTP status. 91 | 92 | ### Registering a Device to Receive Push Notifications for a Pass 93 | 94 | ``` 95 | POST https://example.com/v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier/:serialNumber 96 | ``` 97 | 98 | - **deviceLibraryIdentifier** A unique identifier that is used to identify and authenticate the device. 99 | - **passTypeIdentifier** The pass’s type, as specified in the pass. 100 | - **serialNumber** The unique pass identifier, as specified in the pass. 101 | 102 | The POST payload is a JSON dictionary, containing a single key and value: 103 | 104 | - **pushToken** The push token that the server can use to send push notifications to this device. 105 | 106 | **Response** 107 | 108 | - If the serial number is already registered for this device, return HTTP status 200. 109 | - If registration succeeds, return HTTP status 201. 110 | - If the request is not authorized, return HTTP status 401. 111 | - Otherwise, return the appropriate standard HTTP status. 112 | 113 | ### Unregistering a Device 114 | 115 | ``` 116 | DELETE https://example.com/v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier/:serialNumber 117 | ``` 118 | 119 | - **deviceLibraryIdentifier** A unique identifier that is used to identify and authenticate the device. 120 | - **passTypeIdentifier** The pass’s type, as specified in the pass. 121 | - **serialNumber** The unique pass identifier, as specified in the pass. 122 | 123 | **Response** 124 | 125 | - If disassociation succeeds, return HTTP status 200. 126 | - If the request is not authorized, return HTTP status 401. 127 | - Otherwise, return the appropriate standard HTTP status. 128 | 129 | --- 130 | 131 | ## Contact 132 | 133 | [Mattt](https://twitter.com/mattt) 134 | 135 | ## License 136 | 137 | Rack::Passbook is available under the MIT license. 138 | See the LICENSE file for more info. 139 | --------------------------------------------------------------------------------