munson 0.2.0 → 0.3.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.
@@ -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