bootic_client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 4311719bc6b53b09e5026c67183f7313c491f48f
4
+ data.tar.gz: 7f85b7946a404ae8b32019cb05df811ce6a60709
5
+ SHA512:
6
+ metadata.gz: 125c0102f1890b005ef8e4ff9ca74f4f597d16c8c542c097edb1e5541ee17c05edc3cd64b0200e7dc61282bd1d5b72dcf8a32e8f3b5f1ce2e0f11ee4907e86f9
7
+ data.tar.gz: f9b42eeb52e5e031b35d3b2d3c25a20408e0753348c60e358b3fd8fa8f198a21918c8958377a809484f65304ff2b60a1209f1081eb8cc36c75da6ab34e45d187
data/.gitignore ADDED
@@ -0,0 +1,17 @@
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
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.0.0
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in bootic_client.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'byebug'
8
+ gem 'vcr', '~> 2.4'
9
+ gem 'webmock', '>= 1.9'
10
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Ismael Celis
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ [![Build Status](https://travis-ci.org/bootic/bootic_client.rb.svg?branch=master)](https://travis-ci.org/bootic/bootic_client.rb)
2
+
3
+ ## WORK IN PROGRESS
4
+
5
+ # BooticClient
6
+
7
+ Official Ruby client for the [Bootic API](https://developers.bootic.net)
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ gem 'bootic_client'
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install bootic_client
22
+
23
+ ## Usage
24
+
25
+ ### Configure with you app's credentials
26
+
27
+ ```ruby
28
+ BooticClient.configure do |c|
29
+ c.client_id = ENV['BOOTIC_CLIENT_ID']
30
+ c.client_secret = ENV['BOOTIC_CLIENT_SECRET']
31
+ c.logger = Logger.new(STDOUT)
32
+ c.logging = true
33
+ c.cache_store = Rails.cache
34
+ end
35
+ ```
36
+
37
+ ### Using with an existing access token
38
+
39
+ ```ruby
40
+ bootic = BooticClient.client(:authorized, access_token: 'beidjbewjdiedue...', logging: true)
41
+
42
+ root = bootic.root
43
+
44
+ if root.has?(:products)
45
+ # All products
46
+ all_products = root.products(q: 'xmas presents')
47
+ all_products.total_items # => 23443
48
+ all_products.each do |product|
49
+ puts product.title
50
+ puts product.price
51
+ end
52
+
53
+ if all_product.has?(:next)
54
+ next_page = all_products.next
55
+ next_page.each{...}
56
+ end
57
+ end
58
+ ```
59
+
60
+ ## 1. Refresh token flow (web apps)
61
+
62
+ In this flow you first get a token by authorizing an app. ie. using [omniauth-bootic](https://github.com/bootic/omniauth-bootic)
63
+
64
+ ```ruby
65
+ def client
66
+ @client ||= BooticClient.client(:authorized, access_token: session[:access_token]) do |new_token|
67
+ session[:access_token] = new_token
68
+ end
69
+ end
70
+ ```
71
+
72
+
73
+ ## 2. User-less flow (client credentials - automated scripts)
74
+
75
+ ```ruby
76
+ client = BooticClient.client(:client_credentials, scope: 'admin', access_token: some_store[:access_token]) do |new_token|
77
+ some_store[:access_token] = new_token
78
+ end
79
+ ```
80
+
81
+
82
+ ## Cache storage
83
+
84
+ `BooticClient` honours HTTP caching headers included in API responses (such as `ETag` and `Last-Modified`).
85
+
86
+ By default a simple memory store is used. It is recommended that you use a distributed store in production, such as Memcache. In Rails applications you can use the `Rails.cache` interface.
87
+
88
+ ```ruby
89
+ BooticClient.configure do |c|
90
+ ...
91
+ c.cache_store = Rails.cache
92
+ end
93
+ ```
94
+
95
+ Outside of Rails, BooticClient ships with a wrapper around the [Dalli](https://github.com/mperham/dalli) memcache client.
96
+ You must include Dalli in your Gemfile and require the wrapper explicitely.
97
+
98
+ ```ruby
99
+ require 'bootic_client/stores/memcache'
100
+ CACHE_STORE = BooticClient::Stores::Memcache.new(ENV['MEMCACHE_SERVER'])
101
+
102
+ BooticClient.configure do |c|
103
+ ...
104
+ c.cache_store = CACHE_STORE
105
+ end
106
+ ```
107
+
108
+ ## Contributing
109
+
110
+ 1. Fork it
111
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
112
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
113
+ 4. Push to the branch (`git push origin my-new-feature`)
114
+ 5. Create new Pull Request
115
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'bootic_client/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "bootic_client"
8
+ spec.version = BooticClient::VERSION
9
+ spec.authors = ["Ismael Celis"]
10
+ spec.email = ["ismaelct@gmail.com"]
11
+ spec.description = %q{Official Ruby client for the Bootic API}
12
+ spec.summary = %q{Official Ruby client for the Bootic API}
13
+ spec.homepage = "https://developers.bootic.net"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "faraday", '~> 0.9'
22
+ spec.add_dependency "uri_template", '~> 0.7'
23
+ spec.add_dependency "faraday_middleware", '~> 0.9'
24
+ spec.add_dependency "faraday-http-cache", '~> 0.4'
25
+ spec.add_dependency "net-http-persistent", '~> 2.9'
26
+ spec.add_dependency "oauth2"
27
+
28
+ spec.add_development_dependency "bundler", "~> 1.3"
29
+ spec.add_development_dependency "rake"
30
+ spec.add_development_dependency "rspec"
31
+ spec.add_development_dependency "jwt"
32
+ spec.add_development_dependency "dalli"
33
+ end
@@ -0,0 +1,73 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+ require 'faraday-http-cache'
4
+ require "bootic_client/errors"
5
+ require 'faraday/adapter/net_http_persistent'
6
+
7
+ module BooticClient
8
+
9
+ class Client
10
+
11
+ USER_AGENT = "[BooticClient v#{VERSION}] Ruby-#{RUBY_VERSION} - #{RUBY_PLATFORM}".freeze
12
+
13
+ attr_reader :options, :api_root
14
+
15
+ def initialize(api_root, options = {}, &block)
16
+ @api_root = api_root
17
+ @options = {
18
+ access_token: nil,
19
+ logging: false
20
+ }.merge(options.dup)
21
+
22
+ @options[:cache_store] = @options[:cache_store] || Faraday::HttpCache::MemoryStore.new
23
+
24
+ conn &block if block_given?
25
+ end
26
+
27
+ def get_and_wrap(href, wrapper_class, query = {})
28
+ wrapper_class.new get(href, query).body, self
29
+ end
30
+
31
+ def get(href, query = {})
32
+ validate_request!
33
+
34
+ resp = conn.get do |req|
35
+ req.url href
36
+ req.params.update(query)
37
+ req.headers['Authorization'] = "Bearer #{options[:access_token]}"
38
+ req.headers['User-Agent'] = USER_AGENT
39
+ end
40
+
41
+ raise_if_invalid! resp
42
+
43
+ resp
44
+ end
45
+
46
+ protected
47
+
48
+ def conn(&block)
49
+ @conn ||= Faraday.new(url: api_root) do |f|
50
+ cache_options = {shared_cache: false, store: options[:cache_store]}
51
+ cache_options[:logger] = options[:logger] if options[:logging]
52
+
53
+ f.use :http_cache, cache_options
54
+ f.response :logger, options[:logger] if options[:logging]
55
+ f.response :json
56
+ yield f if block_given?
57
+ f.adapter :net_http_persistent
58
+ end
59
+ end
60
+
61
+ def validate_request!
62
+ raise NoAccessTokenError, "Missing access token" unless options[:access_token]
63
+ end
64
+
65
+ def raise_if_invalid!(resp)
66
+ raise ServerError, "Server Error" if resp.status > 499
67
+ raise NotFoundError, "Not Found" if resp.status == 404
68
+ raise UnauthorizedError, "Unauthorized request" if resp.status == 401
69
+ raise AccessForbiddenError, "Access Forbidden" if resp.status == 403
70
+ end
71
+ end
72
+
73
+ end
@@ -0,0 +1,125 @@
1
+ require "bootic_client/relation"
2
+ require 'ostruct'
3
+
4
+ module BooticClient
5
+ class Entity
6
+
7
+ CURIE_EXP = /(.+):(.+)/.freeze
8
+ CURIES_REL = 'curies'.freeze
9
+ SPECIAL_PROP_EXP = /^_.+/.freeze
10
+
11
+ attr_reader :curies, :entities
12
+
13
+ def initialize(attrs, client, top = self)
14
+ @attrs, @client, @top = attrs, client, top
15
+ build!
16
+ end
17
+
18
+ def [](key)
19
+ properties[key.to_sym]
20
+ end
21
+
22
+ def has?(prop_name)
23
+ has_property?(prop_name) || has_entity?(prop_name) || has_rel?(prop_name)
24
+ end
25
+
26
+ def inspect
27
+ %(#<#{self.class.name} props: [#{properties.keys.join(', ')}] rels: [#{rels.keys.join(', ')}] entities: [#{entities.keys.join(', ')}]>)
28
+ end
29
+
30
+ def properties
31
+ @properties ||= attrs.select{|k,v| !(k =~ SPECIAL_PROP_EXP)}.each_with_object({}) do |(k,v),memo|
32
+ memo[k.to_sym] = Entity.wrap(v)
33
+ end
34
+ end
35
+
36
+ def links
37
+ @links ||= attrs.fetch('_links', {})
38
+ end
39
+
40
+ def self.wrap(obj)
41
+ case obj
42
+ when Hash
43
+ OpenStruct.new(obj)
44
+ when Array
45
+ obj.map{|e| wrap(e)}
46
+ else
47
+ obj
48
+ end
49
+ end
50
+
51
+ def method_missing(name, *args, &block)
52
+ if !block_given?
53
+ if has_property?(name)
54
+ self[name]
55
+ elsif has_entity?(name)
56
+ entities[name]
57
+ elsif has_rel?(name)
58
+ rels[name].get(*args)
59
+ else
60
+ super
61
+ end
62
+ else
63
+ super
64
+ end
65
+ end
66
+
67
+ def respond_to_missing?(method_name, include_private = false)
68
+ has?(method_name)
69
+ end
70
+
71
+ def has_property?(prop_name)
72
+ properties.has_key? prop_name.to_sym
73
+ end
74
+
75
+ def has_entity?(prop_name)
76
+ entities.has_key? prop_name.to_sym
77
+ end
78
+
79
+ def has_rel?(prop_name)
80
+ rels.has_key? prop_name.to_sym
81
+ end
82
+
83
+ def each(&block)
84
+ iterable? ? entities[:items].each(&block) : [self].each(&block)
85
+ end
86
+
87
+ def rels
88
+ @rels ||= (
89
+ links = attrs.fetch('_links', {})
90
+ links.each_with_object({}) do |(rel,rel_attrs),memo|
91
+ if rel =~ CURIE_EXP
92
+ _, curie_namespace, rel = rel.split(CURIE_EXP)
93
+ if curie = curies.find{|c| c['name'] == curie_namespace}
94
+ rel_attrs['docs'] = Relation.expand(curie['href'], rel: rel)
95
+ end
96
+ end
97
+ if rel != CURIES_REL
98
+ rel_attrs['name'] = rel
99
+ memo[rel.to_sym] = Relation.new(rel_attrs, client, Entity)
100
+ end
101
+ end
102
+ )
103
+ end
104
+
105
+ protected
106
+
107
+ attr_reader :client, :top, :attrs
108
+
109
+ def iterable?
110
+ has_entity?(:items) && entities[:items].respond_to?(:each)
111
+ end
112
+
113
+ def build!
114
+ @curies = top.links.fetch('curies', [])
115
+
116
+ @entities = attrs.fetch('_embedded', {}).each_with_object({}) do |(k,v),memo|
117
+ memo[k.to_sym] = if v.kind_of?(Array)
118
+ v.map{|ent_attrs| Entity.new(ent_attrs, client, top)}
119
+ else
120
+ Entity.new(v, client, top)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,9 @@
1
+ module BooticClient
2
+ class TransportError < StandardError; end
3
+ class ServerError < TransportError; end
4
+ class NotFoundError < ServerError; end
5
+ class TokenError < ServerError; end
6
+ class UnauthorizedError < TokenError; end
7
+ class AccessForbiddenError < TokenError; end
8
+ class NoAccessTokenError < TokenError; end
9
+ end
@@ -0,0 +1,60 @@
1
+ require 'uri_template'
2
+ require "bootic_client/entity"
3
+
4
+ module BooticClient
5
+
6
+ class Relation
7
+
8
+ def initialize(attrs, client, wrapper_class = Entity)
9
+ @attrs, @client, @wrapper_class = attrs, client, wrapper_class
10
+ end
11
+
12
+ def inspect
13
+ %(#<#{self.class.name} #{attrs.inspect}>)
14
+ end
15
+
16
+ def href
17
+ attrs['href']
18
+ end
19
+
20
+ def templated?
21
+ !!attrs['templated']
22
+ end
23
+
24
+ def name
25
+ attrs['name']
26
+ end
27
+
28
+ def title
29
+ attrs['title']
30
+ end
31
+
32
+ def type
33
+ attrs['type']
34
+ end
35
+
36
+ def docs
37
+ attrs['docs']
38
+ end
39
+
40
+ def get(opts = {})
41
+ if templated?
42
+ client.get_and_wrap uri.expand(opts), wrapper_class
43
+ else
44
+ client.get_and_wrap href, wrapper_class, opts
45
+ end
46
+ end
47
+
48
+ def self.expand(href, opts = {})
49
+ URITemplate.new(href).expand(opts)
50
+ end
51
+
52
+ protected
53
+ attr_reader :wrapper_class, :client, :attrs
54
+
55
+ def uri
56
+ @uri ||= URITemplate.new(href)
57
+ end
58
+ end
59
+
60
+ end
@@ -0,0 +1,33 @@
1
+ require 'dalli'
2
+
3
+ module BooticClient
4
+ module Stores
5
+ class Memcache
6
+ attr_reader :client
7
+
8
+ def initialize(server_hosts, dalli_options = {})
9
+ @client = Dalli::Client.new(Array(server_hosts), dalli_options)
10
+ end
11
+
12
+ def read(key)
13
+ @client.get key.to_s
14
+ end
15
+
16
+ def write(key, data, ttl = nil)
17
+ @client.set key.to_s, data, ttl
18
+ end
19
+
20
+ def get(key)
21
+ @client.get key
22
+ end
23
+
24
+ def set(key, data, ttl = nil)
25
+ @client.set key, data, ttl
26
+ end
27
+
28
+ def stats
29
+ @client.stats
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ require 'bootic_client/strategies/strategy'
2
+
3
+ module BooticClient
4
+ module Strategies
5
+
6
+ class Authorized < Strategy
7
+ protected
8
+
9
+ def validate!(options)
10
+ raise "options MUST include access_token" unless options[:access_token]
11
+ end
12
+
13
+ def get_token
14
+ # The JWT grant must have an expiration date, in seconds since the epoch.
15
+ # For most cases a few seconds should be enough.
16
+ exp = Time.now.utc.to_i + 5
17
+
18
+ # Use the "assertion" flow to exchange the JWT grant for an access token
19
+ access_token = auth.assertion.get_token(
20
+ hmac_secret: config.client_secret,
21
+ iss: config.client_id,
22
+ prn: client.options[:access_token],
23
+ aud: 'api',
24
+ exp: exp
25
+ )
26
+
27
+ access_token.token
28
+ end
29
+
30
+ def auth
31
+ @auth ||= OAuth2::Client.new(nil, nil, site: config.auth_host)
32
+ end
33
+ end
34
+
35
+ end
36
+
37
+ strategies[:authorized] = Strategies::Authorized
38
+ end
@@ -0,0 +1,19 @@
1
+ require 'bootic_client/strategies/strategy'
2
+
3
+ module BooticClient
4
+ module Strategies
5
+
6
+ class ClientCredentials < Strategy
7
+ protected
8
+ def get_token
9
+ opts = {}
10
+ opts['scope'] = options.delete(:scope) if options[:scope]
11
+ token = auth.client_credentials.get_token(opts, 'auth_scheme' => 'basic')
12
+ token.token
13
+ end
14
+ end
15
+
16
+ end
17
+
18
+ strategies[:client_credentials] = Strategies::ClientCredentials
19
+ end
@@ -0,0 +1,59 @@
1
+ require 'oauth2'
2
+
3
+ module BooticClient
4
+ module Strategies
5
+ class Strategy
6
+
7
+ def initialize(config, client_opts = {}, &on_new_token)
8
+ @config, @options, @on_new_token = config, client_opts, (on_new_token || Proc.new)
9
+ raise "MUST include client_id" unless config.client_id
10
+ raise "MUST include client_secret" unless config.client_secret
11
+ raise "MUST include api_root" unless config.api_root
12
+ validate! @options
13
+ end
14
+
15
+ def root
16
+ get config.api_root
17
+ end
18
+
19
+ def get(href, query = {})
20
+ begin
21
+ client.get_and_wrap(href, Entity, query)
22
+ rescue TokenError => e
23
+ new_token = get_token
24
+ client.options[:access_token] = new_token
25
+ on_new_token.call new_token
26
+ client.get_and_wrap(href, Entity, query)
27
+ end
28
+ end
29
+
30
+ def inspect
31
+ %(#<#{self.class.name} cid: #{config.client_id} root: #{config.api_root} auth: #{config.auth_host}>)
32
+ end
33
+
34
+ protected
35
+
36
+ attr_reader :config, :options, :on_new_token
37
+
38
+ def validate!(options)
39
+
40
+ end
41
+
42
+ def get_token
43
+ raise "Implement this in subclasses"
44
+ end
45
+
46
+ def auth
47
+ @auth ||= OAuth2::Client.new(
48
+ config.client_id,
49
+ config.client_secret,
50
+ site: config.auth_host
51
+ )
52
+ end
53
+
54
+ def client
55
+ @client ||= Client.new(config.api_root, options)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,3 @@
1
+ module BooticClient
2
+ VERSION = "0.0.1"
3
+ end