munson 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,7 +4,12 @@ module Munson
4
4
  CONTENT_TYPE = 'Content-Type'.freeze
5
5
  ACCEPT = 'Accept'.freeze
6
6
  MIME_TYPE = 'application/vnd.api+json'.freeze
7
- USER_AGENT = 'User-Agent'
7
+ USER_AGENT = 'User-Agent'.freeze
8
+
9
+ def initialize(app, key_formatter = nil)
10
+ super(app)
11
+ @key_formatter = key_formatter
12
+ end
8
13
 
9
14
  def call(env)
10
15
  env[:request_headers][USER_AGENT] = "Munson v#{Munson::VERSION}"
@@ -16,7 +21,8 @@ module Munson
16
21
  end
17
22
 
18
23
  def encode(data)
19
- ::JSON.dump data
24
+ json = @key_formatter ? @key_formatter.externalize(data) : data
25
+ ::JSON.dump(json)
20
26
  end
21
27
 
22
28
  def match_content_type(env)
@@ -43,3 +49,5 @@ module Munson
43
49
  end
44
50
  end
45
51
  end
52
+
53
+ Faraday::Request.register_middleware :"Munson::Middleware::EncodeJsonApi" => Munson::Middleware::EncodeJsonApi
@@ -1,10 +1,14 @@
1
1
  module Munson
2
2
  module Middleware
3
- class JsonParser < Faraday::Middleware
3
+ class JsonParser < Faraday::Response::Middleware
4
+ def initialize(app, key_formatter = nil)
5
+ super(app)
6
+ @key_formatter = key_formatter
7
+ end
8
+
4
9
  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])
10
+ @app.call(request_env).on_complete do |request_env|
11
+ request_env[:body] = parse(request_env[:body])
8
12
  end
9
13
  end
10
14
 
@@ -12,7 +16,8 @@ module Munson
12
16
 
13
17
  def parse(body)
14
18
  unless body.strip.empty?
15
- ::JSON.parse(body, symbolize_names: true)
19
+ json = ::JSON.parse(body, symbolize_names: true)
20
+ @key_formatter ? @key_formatter.internalize(json) : json
16
21
  else
17
22
  {}
18
23
  end
@@ -20,3 +25,4 @@ module Munson
20
25
  end
21
26
  end
22
27
  end
28
+ Faraday::Response.register_middleware :"Munson::Middleware::JsonParser" => Munson::Middleware::JsonParser
@@ -0,0 +1,218 @@
1
+ module Munson
2
+ class Query
3
+ attr_reader :values
4
+
5
+ # Description of method
6
+ #
7
+ # @param [Munson::Client] client
8
+ def initialize(client = nil)
9
+ @client = client
10
+ @headers = {}
11
+ @values = {
12
+ include: [],
13
+ fields: [],
14
+ filter: [],
15
+ sort: [],
16
+ page: {}
17
+ }
18
+ end
19
+
20
+ def fetch
21
+ if @client
22
+ response = @client.agent.get(params: to_params, headers: @headers)
23
+ ResponseMapper.new(response.body).collection
24
+ else
25
+ raise Munson::ClientNotSet, "Client was not set. Query#new(client)"
26
+ end
27
+ end
28
+
29
+ def find(id)
30
+ if @client
31
+ response = @client.agent.get(id: id, params: to_params, headers: @headers)
32
+ ResponseMapper.new(response.body).resource
33
+ else
34
+ raise Munson::ClientNotSet, "Client was not set. Query#new(client)"
35
+ end
36
+ end
37
+
38
+ # @return [String] query as a query string
39
+ def to_query_string
40
+ Faraday::Utils.build_nested_query(to_params)
41
+ end
42
+
43
+ def to_s
44
+ to_query_string
45
+ end
46
+
47
+ def to_params
48
+ str = {}
49
+ str[:filter] = filter_to_query_value unless @values[:filter].empty?
50
+ str[:fields] = fields_to_query_value unless @values[:fields].empty?
51
+ str[:include] = include_to_query_value unless @values[:include].empty?
52
+ str[:sort] = sort_to_query_value unless @values[:sort].empty?
53
+ str[:page] = @values[:page] unless @values[:page].empty?
54
+ str
55
+ end
56
+
57
+ # Chainably set page options
58
+ #
59
+ # @example set a limit and offset
60
+ # Munson::Query.new.page(limit: 10, offset: 5)
61
+ #
62
+ # @example set a size and number
63
+ # Munson::Query.new.page(size: 10, number: 5)
64
+ #
65
+ # @return [Munson::Query] self for chaining queries
66
+ def page(opts={})
67
+ @values[:page].merge!(opts)
68
+ self
69
+ end
70
+
71
+ # Chainably set headers
72
+ #
73
+ # @example set a header
74
+ # Munson::Query.new.headers("X-API-TOKEN" => "banana")
75
+ #
76
+ # @example set headers
77
+ # Munson::Query.new.headers("X-API-TOKEN" => "banana", "X-API-VERSION" => "1.3")
78
+ #
79
+ # @return [Munson::Query] self for chaining queries
80
+ def headers(opts={})
81
+ @headers.merge!(opts)
82
+ self
83
+ end
84
+
85
+ # Chainably include related resources.
86
+ #
87
+ # @example including a resource
88
+ # Munson::Query.new.include(:user)
89
+ #
90
+ # @example including a related resource
91
+ # Munson::Query.new.include("user.addresses")
92
+ #
93
+ # @example including multiple resources
94
+ # Munson::Query.new.include("user.addresses", "user.images")
95
+ #
96
+ # @param [Array<String,Symbol>] *args relationships to include
97
+ # @return [Munson::Query] self for chaining queries
98
+ #
99
+ # @see http://jsonapi.org/format/#fetching-includes JSON API Including Relationships
100
+ def include(*args)
101
+ @values[:include] += args
102
+ self
103
+ end
104
+
105
+ # Chainably sort results
106
+ # @note Default order is ascending
107
+ #
108
+ # @example sorting by a single field
109
+ # Munsun::Query.new.sort(:created_at)
110
+ #
111
+ # @example sorting by a multiple fields
112
+ # Munsun::Query.new.sort(:created_at, :age)
113
+ #
114
+ # @example specifying sort direction
115
+ # Munsun::Query.new.sort(:created_at, age: :desc)
116
+ #
117
+ # @example specifying sort direction
118
+ # Munsun::Query.new.sort(score: :desc, :created_at)
119
+ #
120
+ # @param [Hash<Symbol,Symbol>, Symbol] *args fields to sort by
121
+ # @return [Munson::Query] self for chaining queries
122
+ #
123
+ # @see http://jsonapi.org/format/#fetching-sorting JSON API Sorting Spec
124
+ def sort(*args)
125
+ validate_sort_args(args.select{|arg| arg.is_a?(Hash)})
126
+ @values[:sort] += args
127
+ self
128
+ end
129
+
130
+ # Hash resouce_name: [array of attribs]
131
+ def fields(*args)
132
+ @values[:fields] += args
133
+ self
134
+ end
135
+
136
+ def filter(*args)
137
+ @values[:filter] += args
138
+ self
139
+ end
140
+
141
+ protected
142
+
143
+ def sort_to_query_value
144
+ @values[:sort].map{|item|
145
+ if item.is_a?(Hash)
146
+ item.to_a.map{|name,dir|
147
+ dir.to_sym == :desc ? "-#{name}" : name.to_s
148
+ }
149
+ else
150
+ item.to_s
151
+ end
152
+ }.join(',')
153
+ end
154
+
155
+ def fields_to_query_value
156
+ @values[:fields].inject({}) do |acc, hash_arg|
157
+ hash_arg.each do |k,v|
158
+ acc[k] ||= []
159
+ v.is_a?(Array) ?
160
+ acc[k] += v :
161
+ acc[k] << v
162
+
163
+ acc[k].map(&:to_s).uniq!
164
+ end
165
+
166
+ acc
167
+ end.map { |k, v| [k, v.join(',')] }.to_h
168
+ end
169
+
170
+ def include_to_query_value
171
+ @values[:include].map(&:to_s).sort.join(',')
172
+ end
173
+
174
+ # Since the filter param's format isn't specified in the [spec](http://jsonapi.org/format/#fetching-filtering)
175
+ # this implemenation uses (JSONAPI::Resource's implementation](https://github.com/cerebris/jsonapi-resources#filters)
176
+ #
177
+ # To override, implement your own CustomQuery inheriting from {Munson::Query}
178
+ # {Munson::Client} takes a Query class to use. This method could be overriden in your custom class
179
+ #
180
+ # @example Custom Query Builder
181
+ # class MyBuilder < Munson::Query
182
+ # def filter_to_query_value
183
+ # # ... your fancier logic
184
+ # end
185
+ # end
186
+ #
187
+ # class Article
188
+ # def self.munson
189
+ # return @munson if @munson
190
+ # @munson = Munson::Client.new(
191
+ # query_builder: MyQuery,
192
+ # path: 'products'
193
+ # )
194
+ # end
195
+ # end
196
+ #
197
+ def filter_to_query_value
198
+ @values[:filter].reduce({}) do |acc, hash_arg|
199
+ hash_arg.each do |k,v|
200
+ acc[k] ||= []
201
+ v.is_a?(Array) ? acc[k] += v : acc[k] << v
202
+ acc[k].uniq!
203
+ end
204
+ acc
205
+ end.map { |k, v| [k, v.join(',')] }.to_h
206
+ end
207
+
208
+ def validate_sort_args(hashes)
209
+ hashes.each do |hash|
210
+ hash.each do |k,v|
211
+ if !%i(desc asc).include?(v.to_sym)
212
+ raise Munson::UnsupportedSortDirectionError, "Unknown direction '#{v}'. Use :asc or :desc"
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -1,28 +1,182 @@
1
- module Munson
2
- module Resource
3
- def self.included(base)
4
- base.extend ClassMethods
1
+ class Munson::Resource
2
+ extend Forwardable
3
+ attr_reader :document
4
+ attr_reader :attributes
5
+
6
+ # @example Given a Munson::Document
7
+ # document = Munson::Document.new(jsonapi_hash)
8
+ # Person.new(document)
9
+ #
10
+ # @example Given an attributes hash
11
+ # Person.new(first_name: "Chauncy", last_name: "Vünderboot")
12
+ #
13
+ # @param [Hash,Munson::Document] attrs
14
+ def initialize(attrs = {})
15
+ if attrs.is_a?(Munson::Document)
16
+ @document = attrs
17
+ else
18
+ @document = Munson::Document.new(
19
+ data: {
20
+ type: self.class.type,
21
+ id: attrs.delete(:id),
22
+ attributes: attrs
23
+ }
24
+ )
5
25
  end
6
26
 
7
- module ClassMethods
8
- def munson
9
- return @munson if @munson
10
- @munson = Munson::Agent.new
11
- @munson
12
- end
13
-
14
- def register_munson_type(name)
15
- Munson.register_type(name, self)
16
- self.munson.type = name
27
+ initialize_attrs
28
+ end
29
+
30
+ def id
31
+ return nil if document.id.nil?
32
+ @id ||= self.class.format_id(document.id)
33
+ end
34
+
35
+ def initialize_attrs
36
+ @attributes = @document.attributes.clone
37
+ self.class.schema.each do |name, attribute|
38
+ casted_value = attribute.process(@attributes[name])
39
+ @attributes[name] = casted_value
40
+ end
41
+ end
42
+
43
+ def persisted?
44
+ !id.nil?
45
+ end
46
+
47
+ def save
48
+ @document = document.save(agent)
49
+ !errors?
50
+ end
51
+
52
+ # @return [Array<Hash>] array of JSON API errors
53
+ def errors
54
+ document.errors
55
+ end
56
+
57
+ def errors?
58
+ document.errors.any?
59
+ end
60
+
61
+ # @return [Munson::Agent] a new {Munson::Agent} instance
62
+ def agent
63
+ self.class.munson.agent
64
+ end
65
+
66
+ def serialized_attributes
67
+ serialized_attrs = {}
68
+ self.class.schema.each do |name, attribute|
69
+ serialized_value = attribute.serialize(@attributes[name])
70
+ serialized_attrs[name] = serialized_value
71
+ end
72
+ serialized_attrs
73
+ end
74
+
75
+ def ==(other)
76
+ self.class.type == other.class.type && self.id == other.id
77
+ end
78
+
79
+ class << self
80
+ def inherited(subclass)
81
+ if subclass.to_s.respond_to?(:tableize)
82
+ subclass.type = subclass.to_s.tableize.to_sym
17
83
  end
84
+ end
18
85
 
19
- [:includes, :sort, :filter, :fields, :fetch, :find, :page].each do |method|
20
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
21
- def #{method}(*args)
22
- munson.#{method}(*args)
23
- end
24
- RUBY
86
+ def key_type(type)
87
+ @key_type = type
88
+ end
89
+
90
+ def format_id(id)
91
+ case @key_type
92
+ when :integer, nil
93
+ id.to_i
94
+ when :string
95
+ id.to_s
96
+ when Proc
97
+ @key_type.call(id)
25
98
  end
26
99
  end
100
+
101
+ def schema
102
+ @schema ||= {}
103
+ end
104
+
105
+ def attribute(attribute_name, cast_type, **options)
106
+ schema[attribute_name] = Munson::Attribute.new(attribute_name, cast_type, options)
107
+
108
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
109
+ def #{attribute_name}
110
+ @attributes[:#{attribute_name}]
111
+ end
112
+ RUBY
113
+
114
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
115
+ def #{attribute_name}=(val)
116
+ document.attributes[:#{attribute_name}] = self.class.schema[:#{attribute_name}].serialize(val)
117
+ @attributes[:#{attribute_name}] = val
118
+ end
119
+ RUBY
120
+ end
121
+
122
+ def has_one(relation_name)
123
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
124
+ def #{relation_name}
125
+ return @_#{relation_name}_relationship if @_#{relation_name}_relationship
126
+ related_document = document.relationship(:#{relation_name})
127
+ @_#{relation_name}_relationship = Munson.factory(related_document)
128
+ end
129
+ RUBY
130
+ end
131
+
132
+ def has_many(relation_name)
133
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
134
+ def #{relation_name}
135
+ return @_#{relation_name}_relationship if @_#{relation_name}_relationship
136
+ documents = document.relationship(:#{relation_name})
137
+ collection = Munson::Collection.new(documents.map{ |doc| Munson.factory(doc) })
138
+ @_#{relation_name}_relationship = collection
139
+ end
140
+ RUBY
141
+ end
142
+
143
+ def munson_initializer(document)
144
+ new(document)
145
+ end
146
+
147
+ def munson
148
+ return @munson if @munson
149
+ @munson = Munson::Client.new
150
+ @munson
151
+ end
152
+
153
+ # Set the JSONAPI type
154
+ def type=(type)
155
+ Munson.register_type(type, self)
156
+ munson.type = type
157
+ end
158
+
159
+ # Get the JSONAPI type
160
+ def type
161
+ munson.type
162
+ end
163
+
164
+ # Overwrite Connection#fields delegator to allow for passing an array of fields
165
+ # @example
166
+ # Cat.fields(:name, :favorite_toy) #=> Query(fields[cats]=name,favorite_toy)
167
+ # Cat.fields(name, owner: [:name]) #=> Query(fields[cats]=name&fields[people]=name)
168
+ def fields(*args)
169
+ hash_fields = args.last.is_a?(Hash) ? args.pop : {}
170
+ hash_fields[type] = args if args.any?
171
+ munson.fields(hash_fields)
172
+ end
173
+
174
+ [:include, :sort, :filter, :fetch, :find, :page].each do |method|
175
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
176
+ def #{method}(*args)
177
+ munson.#{method}(*args)
178
+ end
179
+ RUBY
180
+ end
27
181
  end
28
182
  end
@@ -1,54 +1,114 @@
1
1
  module Munson
2
+ # Maps JSONAPI Responses to ruby objects.
3
+ #
4
+ # @note
5
+ # When a JSONAPI collection (data: <Array>) is received it maps the response
6
+ # into multiple JSONAPI resource objects (data: <Hash>) and passes each to the #initialize_resource method
7
+ # so that each resource can act independently of the collection. JSONAPI collection are wrapped in a Munson::Collection
8
+ # which will also contain metadata from the request
9
+ #
10
+ # @example Mapping an unregistered JSONAPI collection response
11
+ # json = {
12
+ # data: [
13
+ # {id: 1, type: :cats, attributes: {name: 'Gorbypuff'}},
14
+ # {id: 1, type: :cats, attributes: {name: 'Grumpy Cat'}}
15
+ # ]
16
+ # }
17
+ #
18
+ # mapper = ResponseMapper.new(json)
19
+ # mapper.collection #=>
20
+ # Munson::Collection([
21
+ # {data: {id: 1, type: :cats, attributes: {name: 'Gorbypuff'}}},
22
+ # {data: {id: 1, type: :cats, attributes: {name: 'Grumpy Cat'}}
23
+ # ])
24
+ #
25
+ # @example Mapping a registered JSONAPI collection response
26
+ # json = {
27
+ # data: [
28
+ # {id: 1, type: :cats, attributes: {name: 'Gorbypuff'}},
29
+ # {id: 1, type: :cats, attributes: {name: 'Grumpy Cat'}}
30
+ # ]
31
+ # }
32
+ # class Cat
33
+ # #... munson config
34
+ # def self.munson_initializer(resource)
35
+ # Cat.new(resource)
36
+ # end
37
+ #
38
+ # def new(attribs)
39
+ # #do what you want
40
+ # end
41
+ # end
42
+ # Munson.register_type(:cats, Cat)
43
+ #
44
+ # mapper.collection #=> Munson::Collection([cat1, cat2])
45
+ #
46
+ # ResourceMapper maps responses in 3 ways:
47
+ # @example Mapping a Munson::Resource
48
+ #
49
+ # @example Mapping a registered type
50
+ #
51
+ # @example Mapping an unregistered type
52
+ #
2
53
  class ResponseMapper
3
- class UnsupportedDatatype < StandardError;end;
4
-
5
- def initialize(response)
6
- @data = response.body[:data]
7
- @includes = response.body[:include]
54
+ # @param [Hash] response_body jsonapi formatted hash
55
+ def initialize(response_body)
56
+ @body = response_body
8
57
  end
9
58
 
10
- def resources
11
- if data_is_collection?
12
- map_data(@data)
59
+ # Moved top level keys to the collection
60
+ # * errors: an array of error objects
61
+ # * meta: a meta object that contains non-standard meta-information.
62
+ # * jsonapi: an object describing the server’s implementation
63
+ # * links: a links object related to the primary data.
64
+ def collection
65
+ if errors?
66
+ raise Exception, "IMPLEMENT ERRORS JERK"
67
+ elsif collection?
68
+ # Make each item in :data its own document, stick included into that document
69
+ records = @body[:data].reduce([]) do |agg, resource|
70
+ json = { data: resource }
71
+ json[:included] = @body[:included] if @body[:included]
72
+ agg << json
73
+ agg
74
+ end
75
+
76
+ Collection.new(records.map{ |datum| Munson.factory(datum) },
77
+ meta: @body[:meta],
78
+ jsonapi: @body[:jsonapi],
79
+ links: @body[:links]
80
+ )
13
81
  else
14
- raise StandardError, "Called #resources, but response was a single resource. Use ResponseMapper#resource"
82
+ raise Munson::Error, "Called #collection, but response was a single resource. Use ResponseMapper#resource"
15
83
  end
16
84
  end
17
85
 
18
86
  def resource
19
- if data_is_resource?
20
- map_data(@data)
87
+ if errors?
88
+ raise Exception, "IMPLEMENT ERRORS JERK"
89
+ elsif resource?
90
+ Munson.factory(@body)
21
91
  else
22
- raise StandardError, "Called #resource, but response was a collection of resources. Use ResponseMapper#resources"
92
+ raise Munson::Error, "Called #resource, but response was a collection of resources. Use ResponseMapper#collection"
23
93
  end
24
94
  end
25
95
 
26
- private
27
-
28
- def data_is_resource?
29
- @data.is_a?(Hash)
96
+ def jsonapi_resources
97
+ data = collection? ? @body[:data] : [@body[:data]]
98
+ included = @body[:included] || []
99
+ (data + included)
30
100
  end
31
101
 
32
- def data_is_collection?
33
- @data.is_a?(Array)
102
+ private def errors?
103
+ @body[:errors].is_a?(Array)
34
104
  end
35
105
 
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
106
+ private def resource?
107
+ @body[:data].is_a?(Hash)
44
108
  end
45
109
 
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
110
+ private def collection?
111
+ @body[:data].is_a?(Array)
52
112
  end
53
113
  end
54
114
  end
@@ -1,3 +1,3 @@
1
1
  module Munson
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end