munson 0.1.0

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.
@@ -0,0 +1,99 @@
1
+ module Munson
2
+ # Faraday::Connection wrapper for making JSON API Requests
3
+ #
4
+ # @attr_reader [Faraday::Connection] faraday connection object
5
+ # @attr_reader [Hash] options
6
+ class Connection
7
+ # @private
8
+ attr_reader :faraday, :options
9
+
10
+ FARADAY_OPTIONS = [:request, :proxy, :ssl, :builder, :url,
11
+ :parallel_manager, :params, :headers, :builder_class].freeze
12
+
13
+ # Create a new connection. A connection serves as a thin wrapper around a
14
+ # a faraday connection that includes two pieces of middleware for handling
15
+ # JSON API Spec
16
+ #
17
+ # @param [Hash] opts {Munson::Connection} configuration options
18
+ # @param [Proc] block to yield to Faraday::Connection
19
+ # @see https://github.com/lostisland/faraday/blob/master/lib/faraday/connection.rb Faraday::Connection
20
+ #
21
+ # @example Creating a new connection
22
+ # my_connection = Munson::Connection.new url: "http://api.example.com" do |c|
23
+ # c.use Your::Custom::Middleware
24
+ # end
25
+ #
26
+ # class User
27
+ # include Munson::Resource
28
+ # munson.connection = my_connection
29
+ # end
30
+ def initialize(opts, &block)
31
+ configure(opts, &block)
32
+ end
33
+
34
+ # JSON API Spec GET request
35
+ #
36
+ # @example making a GET request
37
+ # @connection.get(path: 'addresses', params: {include: 'user'}, headers: {'X-API-Token' => '2kewl'})
38
+ #
39
+ # @option [Hash,nil] params: nil query params
40
+ # @option [String] path: nil to GET
41
+ # @option [Hash] headers: nil HTTP Headers
42
+ # @return [Faraday::Response]
43
+ def get(path: nil, params: nil, headers: nil)
44
+ faraday.get do |request|
45
+ request.headers.merge!(headers) if headers
46
+ request.url path.to_s, params
47
+ end
48
+ end
49
+
50
+ # JSON API Spec POST request
51
+ #
52
+ # @option [Hash,nil] body: {} query params
53
+ # @option [String] path: nil path to GET, defaults to Faraday::Connection url + Agent#type
54
+ # @option [Hash] headers: nil HTTP Headers
55
+ # @option [Type] http_method: :post describe http_method: :post
56
+ # @return [Faraday::Response]
57
+ def post(body: {}, path: nil, headers: nil, http_method: :post)
58
+ connection.faraday.send(http_method) do |request|
59
+ request.headers.merge!(headers) if headers
60
+ request.url path.to_s
61
+ request.body = body
62
+ end
63
+ end
64
+
65
+
66
+ # Configure the connection
67
+ #
68
+ # @param [Hash] opts {Munson::Connection} configuration options
69
+ # @return Faraday::Connection
70
+ #
71
+ # @example Setting up the default API connection
72
+ # Munson::Connection.new url: "http://api.example.com"
73
+ #
74
+ # @example A custom middleware added to the default list
75
+ # class MyTokenAuth < Faraday::Middleware
76
+ # def call(env)
77
+ # env[:request_headers]["X-API-Token"] = "SECURE_TOKEN"
78
+ # @app.call(env)
79
+ # end
80
+ # end
81
+ #
82
+ # Munson::Connection.new url: "http://api.example.com" do |c|
83
+ # c.use MyTokenAuth
84
+ # end
85
+ def configure(opts={}, &block)
86
+ @options = opts
87
+
88
+ faraday_options = @options.reject { |key, value| !FARADAY_OPTIONS.include?(key.to_sym) }
89
+ @faraday = Faraday.new(faraday_options) do |conn|
90
+ yield conn if block_given?
91
+
92
+ conn.use Munson::Middleware::EncodeJsonApi
93
+ conn.use Munson::Middleware::JsonParser
94
+
95
+ conn.adapter Faraday.default_adapter
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,43 @@
1
+ module Munson
2
+ module Middleware
3
+ class EncodeJsonApi < Faraday::Middleware
4
+ CONTENT_TYPE = 'Content-Type'.freeze
5
+ ACCEPT = 'Accept'.freeze
6
+ MIME_TYPE = 'application/vnd.api+json'.freeze
7
+
8
+ def call(env)
9
+ env[:request_headers][ACCEPT] ||= MIME_TYPE
10
+ match_content_type(env) do |data|
11
+ env[:body] = encode data
12
+ end
13
+ @app.call env
14
+ end
15
+
16
+ def encode(data)
17
+ ::JSON.dump data
18
+ end
19
+
20
+ def match_content_type(env)
21
+ if process_request?(env)
22
+ env[:request_headers][CONTENT_TYPE] ||= MIME_TYPE
23
+ yield env[:body] unless env[:body].respond_to?(:to_str)
24
+ end
25
+ end
26
+
27
+ def process_request?(env)
28
+ type = request_type(env)
29
+ has_body?(env) and (type.empty? or type == MIME_TYPE)
30
+ end
31
+
32
+ def has_body?(env)
33
+ body = env[:body] and !(body.respond_to?(:to_str) and body.empty?)
34
+ end
35
+
36
+ def request_type(env)
37
+ type = env[:request_headers][CONTENT_TYPE].to_s
38
+ type = type.split(';', 2).first if type.index(';')
39
+ type
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ module Munson
2
+ module Middleware
3
+ class JsonParser < Faraday::Middleware
4
+ def call(request_env)
5
+ @app.call(request_env).on_complete do |response_env|
6
+ response_env[:raw_body] = response_env[:body]
7
+ response_env[:body] = parse(response_env[:body])
8
+ end
9
+ end
10
+
11
+ private
12
+
13
+ def parse(body)
14
+ unless body.strip.empty?
15
+ ::JSON.parse(body, symbolize_names: true)
16
+ else
17
+ {}
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # module Munson
2
+ # module Model
3
+ # extend ActiveSupport::Concern
4
+ #
5
+ # included do
6
+ # self.include Munson::Resource
7
+ # end
8
+ #
9
+ # class_methods do
10
+ # def has_many(*);end;
11
+ # def has_one(*);end;
12
+ # def belongs_to(*);end;
13
+ # end
14
+ # end
15
+ # end
@@ -0,0 +1,44 @@
1
+ module Munson
2
+ module Paginator
3
+ class OffsetPaginator
4
+ def initialize(options={})
5
+ @max_limit = options[:max]
6
+ @default_limit = options[:default]
7
+ end
8
+
9
+ def set(opts={})
10
+ limit(opts[:limit]) if opts[:limit]
11
+ offset(opts[:offset]) if opts[:offset]
12
+ end
13
+
14
+ def to_params
15
+ {
16
+ page: {
17
+ limit: @limit || @default_limit || 10,
18
+ offset: @offset
19
+ }.compact
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ # Set limit of resources per page
26
+ #
27
+ # @param [Fixnum] num number of resources per page
28
+ def limit(num)
29
+ if @max_limit && num > @max_limit
30
+ @limit = @max_limit
31
+ else
32
+ @limit = num
33
+ end
34
+ end
35
+
36
+ # Set offset
37
+ #
38
+ # @param [Fixnum] num pages to offset
39
+ def offset(num)
40
+ @offset = num
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ module Munson
2
+ module Paginator
3
+ class PagedPaginator
4
+ def initialize(options={})
5
+ @max_size = options[:max]
6
+ @default_size = options[:default]
7
+ end
8
+
9
+ def set(opts={})
10
+ number(opts[:number]) if opts[:number]
11
+ size(opts[:size]) if opts[:size]
12
+ end
13
+
14
+ def to_params
15
+ {
16
+ page: {
17
+ size: @size || @default_size || 10,
18
+ number: @number
19
+ }.compact
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ # Set number of resources per page
26
+ #
27
+ # @param [Fixnum] num number of resources per page
28
+ def size(num)
29
+ if @max_size && num > @max_size
30
+ @size = @max_size
31
+ else
32
+ @size = num
33
+ end
34
+ end
35
+
36
+ # Set page number
37
+ #
38
+ # @param [Fixnum] num page number
39
+ def number(num)
40
+ @number = num
41
+ end
42
+
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,6 @@
1
+ module Munson
2
+ module Paginator
3
+ autoload :OffsetPaginator, "munson/paginator/offset_paginator"
4
+ autoload :PagedPaginator, "munson/paginator/paged_paginator"
5
+ end
6
+ end
@@ -0,0 +1,226 @@
1
+ module Munson
2
+ class QueryBuilder
3
+ attr_reader :query
4
+ attr_reader :paginator
5
+ attr_reader :agent
6
+ class UnsupportedSortDirectionError < StandardError; end;
7
+ class PaginatorNotSet < StandardError; end;
8
+ class AgentNotSet < StandardError; end;
9
+
10
+ # Description of method
11
+ #
12
+ # @param [Class] paginator: nil instantiated paginator
13
+ # @param [Class] agent: nil instantiated agent to use for fetching results
14
+ # @return [Type] description of returned object
15
+ def initialize(paginator: nil, agent: nil)
16
+ @paginator = paginator
17
+ @agent = agent
18
+
19
+ @query = {
20
+ include: [],
21
+ fields: [],
22
+ filter: [],
23
+ sort: []
24
+ }
25
+ end
26
+
27
+ # @return [String] query as a query string
28
+ def to_query_string
29
+ to_params.to_query
30
+ end
31
+
32
+ def to_s
33
+ to_query
34
+ end
35
+
36
+ def to_params
37
+ str = {}
38
+ str[:filter] = filter_to_query_value unless @query[:filter].empty?
39
+ str[:fields] = fields_to_query_value unless @query[:fields].empty?
40
+ str[:include] = includes_to_query_value unless @query[:include].empty?
41
+ str[:sort] = sort_to_query_value unless @query[:sort].empty?
42
+
43
+ str.merge!(paginator.to_params) if paginator
44
+
45
+ str
46
+ end
47
+
48
+ # Fetches resources using {Munson::Agent}
49
+ #
50
+ # @return [Array] Array of resources
51
+ def fetch
52
+ if @agent
53
+ response = @agent.get(params: to_params)
54
+ resources = ResponseMapper.new(response).resources
55
+ Collection.new(resources)
56
+ else
57
+ raise AgentNotSet, "Agent was not set. QueryBuilder#new(agent:)"
58
+ end
59
+ end
60
+
61
+ def paging?
62
+ !!paginator
63
+ end
64
+
65
+ # Paginator proxy
66
+ #
67
+ # @return [Class,nil] paginator if set
68
+ def page(opts={})
69
+ if paging?
70
+ paginator.set(opts)
71
+ self
72
+ else
73
+ raise PaginatorNotSet, "Paginator was not set. QueryBuilder#new(paginator:)"
74
+ end
75
+ end
76
+
77
+ # Chainably include related resources.
78
+ #
79
+ # @example including a resource
80
+ # Munson::QueryBuilder.new.includes(:user)
81
+ #
82
+ # @example including a related resource
83
+ # Munson::QueryBuilder.new.includes("user.addresses")
84
+ #
85
+ # @example including multiple resources
86
+ # Munson::QueryBuilder.new.includes("user.addresses", "user.images")
87
+ #
88
+ # @param [Array<String,Symbol>] *args relationships to include
89
+ # @return [Munson::QueryBuilder] self for chaining queries
90
+ #
91
+ # @see http://jsonapi.org/format/#fetching-includes JSON API Including Relationships
92
+ def includes(*args)
93
+ @query[:include] += args
94
+ self
95
+ end
96
+
97
+ # Chainably sort results
98
+ # @note Default order is ascending
99
+ #
100
+ # @example sorting by a single field
101
+ # Munsun::QueryBuilder.new.sort(:created_at)
102
+ #
103
+ # @example sorting by a multiple fields
104
+ # Munsun::QueryBuilder.new.sort(:created_at, :age)
105
+ #
106
+ # @example specifying sort direction
107
+ # Munsun::QueryBuilder.new.sort(:created_at, age: :desc)
108
+ #
109
+ # @example specifying sort direction
110
+ # Munsun::QueryBuilder.new.sort(score: :desc, :created_at)
111
+ #
112
+ # @param [Hash<Symbol,Symbol>, Symbol] *args fields to sort by
113
+ # @return [Munson::QueryBuilder] self for chaining queries
114
+ #
115
+ # @see http://jsonapi.org/format/#fetching-sorting JSON API Sorting Spec
116
+ def sort(*args)
117
+ validate_sort_args(args.select{|arg| arg.is_a?(Hash)})
118
+ @query[:sort] += args
119
+ self
120
+ end
121
+
122
+ # Hash resouce_name: [array of attribs]
123
+ def fields(*args)
124
+ @query[:fields] += args
125
+ self
126
+ end
127
+
128
+ def filter(*args)
129
+ @query[:filter] += args
130
+ self
131
+ end
132
+
133
+ def self.includes(*args)
134
+ new.includes(*args)
135
+ end
136
+
137
+ def self.sort(*args)
138
+ new.sort(*args)
139
+ end
140
+
141
+ def self.fields(*args)
142
+ new.fields(*args)
143
+ end
144
+
145
+ def self.filter(*args)
146
+ new.filter(*args)
147
+ end
148
+
149
+ protected
150
+
151
+ def sort_to_query_value
152
+ @query[:sort].map{|item|
153
+ if item.is_a?(Hash)
154
+ item.to_a.map{|name,dir|
155
+ dir.to_sym == :desc ? "-#{name}" : name.to_s
156
+ }
157
+ else
158
+ item.to_s
159
+ end
160
+ }.join(',')
161
+ end
162
+
163
+ def fields_to_query_value
164
+ @query[:fields].inject({}) do |acc, hash_arg|
165
+ hash_arg.each do |k,v|
166
+ acc[k] ||= []
167
+ v.is_a?(Array) ?
168
+ acc[k] += v :
169
+ acc[k] << v
170
+
171
+ acc[k].map(&:to_s).uniq!
172
+ end
173
+
174
+ acc
175
+ end.map { |k, v| [k, v.join(',')] }.to_h
176
+ end
177
+
178
+ def includes_to_query_value
179
+ @query[:include].map(&:to_s).sort.join(',')
180
+ end
181
+
182
+ # Since the filter query param's format isn't specified in the [spec](http://jsonapi.org/format/#fetching-filtering)
183
+ # this implemenation uses (JSONAPI::Resource's implementation](https://github.com/cerebris/jsonapi-resources#filters)
184
+ #
185
+ # To override, implement your own CustomQueryBuilder inheriting from {Munson::QueryBuilder}
186
+ # {Munson::Agent} takes a QueryBuilder class to use. This method could be overriden in your custom class
187
+ #
188
+ # @example Custom Query Builder
189
+ # class MyBuilder < Munson::QueryBuilder
190
+ # def filter_to_query_value
191
+ # # ... your fancier logic
192
+ # end
193
+ # end
194
+ #
195
+ # class Article
196
+ # def self.munson
197
+ # return @munson if @munson
198
+ # @munson = Munson::Agent.new(
199
+ # query_builder: MyBuilder
200
+ # path: 'products'
201
+ # )
202
+ # end
203
+ # end
204
+ #
205
+ def filter_to_query_value
206
+ @query[:filter].reduce({}) do |acc, hash_arg|
207
+ hash_arg.each do |k,v|
208
+ acc[k] ||= []
209
+ v.is_a?(Array) ? acc[k] += v : acc[k] << v
210
+ acc[k].uniq!
211
+ end
212
+ acc
213
+ end.map { |k, v| [k, v.join(',')] }.to_h
214
+ end
215
+
216
+ def validate_sort_args(hashes)
217
+ hashes.each do |hash|
218
+ hash.each do |k,v|
219
+ if !%i(desc asc).include?(v.to_sym)
220
+ raise UnsupportedSortDirectionError, "Unknown direction '#{v}'. Use :asc or :desc"
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,26 @@
1
+ module Munson
2
+ module Resource
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ def self.munson
7
+ return @munson if @munson
8
+ @munson = Munson::Agent.new
9
+ @munson
10
+ end
11
+
12
+ self.munson.type = name.demodulize.tableize
13
+ Munson.register_type(self.munson.type, self)
14
+ end
15
+
16
+ class_methods do
17
+ [:includes, :sort, :filter, :fields, :fetch, :find, :page].each do |method|
18
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
19
+ def #{method}(*args)
20
+ munson.#{method}(*args)
21
+ end
22
+ RUBY
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ module Munson
2
+ class ResponseMapper
3
+ class UnsupportedDatatype < StandardError;end;
4
+
5
+ def initialize(response)
6
+ @data = response.body[:data]
7
+ @includes = response.body[:include]
8
+ end
9
+
10
+ def resources
11
+ if data_is_collection?
12
+ map_data(@data)
13
+ else
14
+ raise StandardError, "Called #resources, but response was a single resource. Use ResponseMapper#resource"
15
+ end
16
+ end
17
+
18
+ def resource
19
+ if data_is_resource?
20
+ map_data(@data)
21
+ else
22
+ raise StandardError, "Called #resource, but response was a collection of resources. Use ResponseMapper#resources"
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def data_is_resource?
29
+ @data.is_a?(Hash)
30
+ end
31
+
32
+ def data_is_collection?
33
+ @data.is_a?(Array)
34
+ end
35
+
36
+ def map_data(data)
37
+ if data_is_collection?
38
+ @data.map{ |datum| map_resource(datum) }
39
+ elsif data_is_resource?
40
+ map_resource(@data)
41
+ else
42
+ raise UnsupportedDatatype, "No mapping rule for #{data.class}"
43
+ end
44
+ end
45
+
46
+ def map_resource(resource)
47
+ if klass = Munson.lookup_type(resource[:type])
48
+ klass.new(resource[:attributes].merge(id: resource[:id]))
49
+ else
50
+ resource
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,3 @@
1
+ module Munson
2
+ VERSION = "0.1.0"
3
+ end
data/lib/munson.rb ADDED
@@ -0,0 +1,70 @@
1
+ require 'json'
2
+
3
+ require 'active_support/concern'
4
+ require "active_support/inflector"
5
+ require "active_support/core_ext/hash"
6
+
7
+ require 'faraday'
8
+ require 'faraday_middleware'
9
+
10
+ require "munson/version"
11
+
12
+ require "munson/middleware/encode_json_api"
13
+ require "munson/middleware/json_parser"
14
+
15
+ require 'munson/collection'
16
+ require 'munson/paginator'
17
+ require 'munson/response_mapper'
18
+ require 'munson/query_builder'
19
+ require 'munson/connection'
20
+ require 'munson/agent'
21
+ require 'munson/resource'
22
+
23
+ module Munson
24
+ @registered_types = {}
25
+ class << self
26
+ # Configure the default connection.
27
+ #
28
+ # @param [Hash] opts {Munson::Connection} configuration options
29
+ # @param [Proc] block to yield to Faraday::Connection
30
+ # @return [Munson::Connection] the default connection
31
+
32
+ # @see https://github.com/lostisland/faraday/blob/master/lib/faraday/connection.rb Faraday::Connection
33
+ # @see Munson::Connection
34
+ def configure(opts={}, &block)
35
+ @default_connection = Munson::Connection.new(opts, &block)
36
+ end
37
+
38
+ # The default connection
39
+ #
40
+ # @return [Munson::Connection, nil] the default connection if configured
41
+ def default_connection
42
+ defined?(@default_connection) ? @default_connection : nil
43
+ end
44
+
45
+ # Register a JSON Spec resource type to a class
46
+ # This is used in Faraday response middleware to package the JSON into a domain model
47
+ #
48
+ # @example Mapping a type
49
+ # Munson.register_type("addresses", Address)
50
+ #
51
+ # @param [#to_s] type JSON Spec type
52
+ # @param [Class] klass to map to
53
+ def register_type(type, klass)
54
+ @registered_types[type] = klass
55
+ end
56
+
57
+ # Lookup a class by JSON Spec type name
58
+ #
59
+ # @param [#to_s] type JSON Spec type
60
+ # @return [Class] domain model
61
+ def lookup_type(type)
62
+ @registered_types[type]
63
+ end
64
+
65
+ # @private
66
+ def flush_types!
67
+ @registered_types = {}
68
+ end
69
+ end
70
+ end
data/munson.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'munson/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "munson"
8
+ spec.version = Munson::VERSION
9
+ spec.authors = ["Cory O'Daniel"]
10
+ spec.email = ["cory@coryodaniel.com"]
11
+
12
+ spec.summary = %q{A JSON API Spec client for Ruby}
13
+ spec.homepage = "http://github.com/coryodaniel/munson"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.bindir = "exe"
17
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency "faraday"
21
+ spec.add_dependency "faraday_middleware"
22
+ spec.add_dependency "activesupport"
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.10"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec"
27
+ spec.add_development_dependency 'rspec-mocks'
28
+ spec.add_development_dependency "guard-rspec"
29
+ spec.add_development_dependency "pry"
30
+ spec.add_development_dependency "pry-byebug"
31
+ spec.add_development_dependency 'yard'
32
+ spec.add_development_dependency 'yard-activesupport-concern'
33
+ spec.add_development_dependency 'webmock'
34
+ end