munson 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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