jsonapi_compliable 0.6.4 → 0.6.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -1
  3. data/.travis.yml +11 -3
  4. data/.yardopts +1 -0
  5. data/README.md +10 -1
  6. data/Rakefile +1 -0
  7. data/docs/JsonapiCompliable.html +202 -0
  8. data/docs/JsonapiCompliable/Adapters.html +119 -0
  9. data/docs/JsonapiCompliable/Adapters/Abstract.html +2285 -0
  10. data/docs/JsonapiCompliable/Adapters/ActiveRecord.html +2151 -0
  11. data/docs/JsonapiCompliable/Adapters/ActiveRecordSideloading.html +582 -0
  12. data/docs/JsonapiCompliable/Adapters/Null.html +1682 -0
  13. data/docs/JsonapiCompliable/Base.html +1395 -0
  14. data/docs/JsonapiCompliable/Deserializer.html +835 -0
  15. data/docs/JsonapiCompliable/Errors.html +115 -0
  16. data/docs/JsonapiCompliable/Errors/BadFilter.html +124 -0
  17. data/docs/JsonapiCompliable/Errors/StatNotFound.html +266 -0
  18. data/docs/JsonapiCompliable/Errors/UnsupportedPageSize.html +264 -0
  19. data/docs/JsonapiCompliable/Errors/ValidationError.html +124 -0
  20. data/docs/JsonapiCompliable/Extensions.html +117 -0
  21. data/docs/JsonapiCompliable/Extensions/BooleanAttribute.html +212 -0
  22. data/docs/JsonapiCompliable/Extensions/BooleanAttribute/ClassMethods.html +229 -0
  23. data/docs/JsonapiCompliable/Extensions/ExtraAttribute.html +242 -0
  24. data/docs/JsonapiCompliable/Extensions/ExtraAttribute/ClassMethods.html +237 -0
  25. data/docs/JsonapiCompliable/Query.html +1099 -0
  26. data/docs/JsonapiCompliable/Rails.html +211 -0
  27. data/docs/JsonapiCompliable/Resource.html +5241 -0
  28. data/docs/JsonapiCompliable/Scope.html +703 -0
  29. data/docs/JsonapiCompliable/Scoping.html +117 -0
  30. data/docs/JsonapiCompliable/Scoping/Base.html +843 -0
  31. data/docs/JsonapiCompliable/Scoping/DefaultFilter.html +318 -0
  32. data/docs/JsonapiCompliable/Scoping/ExtraFields.html +301 -0
  33. data/docs/JsonapiCompliable/Scoping/Filter.html +313 -0
  34. data/docs/JsonapiCompliable/Scoping/Filterable.html +364 -0
  35. data/docs/JsonapiCompliable/Scoping/Paginate.html +613 -0
  36. data/docs/JsonapiCompliable/Scoping/Sort.html +454 -0
  37. data/docs/JsonapiCompliable/SerializableTempId.html +216 -0
  38. data/docs/JsonapiCompliable/Sideload.html +2484 -0
  39. data/docs/JsonapiCompliable/Stats.html +117 -0
  40. data/docs/JsonapiCompliable/Stats/DSL.html +999 -0
  41. data/docs/JsonapiCompliable/Stats/Payload.html +391 -0
  42. data/docs/JsonapiCompliable/Util.html +117 -0
  43. data/docs/JsonapiCompliable/Util/FieldParams.html +228 -0
  44. data/docs/JsonapiCompliable/Util/Hash.html +471 -0
  45. data/docs/JsonapiCompliable/Util/IncludeParams.html +299 -0
  46. data/docs/JsonapiCompliable/Util/Persistence.html +435 -0
  47. data/docs/JsonapiCompliable/Util/RelationshipPayload.html +563 -0
  48. data/docs/JsonapiCompliable/Util/RenderOptions.html +250 -0
  49. data/docs/JsonapiCompliable/Util/ValidationResponse.html +532 -0
  50. data/docs/_index.html +527 -0
  51. data/docs/class_list.html +51 -0
  52. data/docs/css/common.css +1 -0
  53. data/docs/css/full_list.css +58 -0
  54. data/docs/css/style.css +492 -0
  55. data/docs/file.README.html +99 -0
  56. data/docs/file_list.html +56 -0
  57. data/docs/frames.html +17 -0
  58. data/docs/index.html +99 -0
  59. data/docs/js/app.js +248 -0
  60. data/docs/js/full_list.js +216 -0
  61. data/docs/js/jquery.js +4 -0
  62. data/docs/method_list.html +1731 -0
  63. data/docs/top-level-namespace.html +110 -0
  64. data/gemfiles/rails_5.gemfile +1 -1
  65. data/lib/jsonapi_compliable/adapters/abstract.rb +267 -4
  66. data/lib/jsonapi_compliable/adapters/active_record.rb +23 -1
  67. data/lib/jsonapi_compliable/adapters/null.rb +43 -3
  68. data/lib/jsonapi_compliable/base.rb +182 -33
  69. data/lib/jsonapi_compliable/deserializer.rb +90 -21
  70. data/lib/jsonapi_compliable/extensions/boolean_attribute.rb +12 -0
  71. data/lib/jsonapi_compliable/extensions/extra_attribute.rb +32 -0
  72. data/lib/jsonapi_compliable/extensions/temp_id.rb +11 -0
  73. data/lib/jsonapi_compliable/query.rb +94 -2
  74. data/lib/jsonapi_compliable/rails.rb +8 -0
  75. data/lib/jsonapi_compliable/resource.rb +548 -11
  76. data/lib/jsonapi_compliable/scope.rb +43 -1
  77. data/lib/jsonapi_compliable/scoping/base.rb +59 -8
  78. data/lib/jsonapi_compliable/scoping/default_filter.rb +33 -0
  79. data/lib/jsonapi_compliable/scoping/extra_fields.rb +33 -0
  80. data/lib/jsonapi_compliable/scoping/filter.rb +29 -2
  81. data/lib/jsonapi_compliable/scoping/filterable.rb +4 -0
  82. data/lib/jsonapi_compliable/scoping/paginate.rb +33 -0
  83. data/lib/jsonapi_compliable/scoping/sort.rb +18 -0
  84. data/lib/jsonapi_compliable/sideload.rb +229 -1
  85. data/lib/jsonapi_compliable/stats/dsl.rb +44 -0
  86. data/lib/jsonapi_compliable/stats/payload.rb +20 -0
  87. data/lib/jsonapi_compliable/util/field_params.rb +1 -0
  88. data/lib/jsonapi_compliable/util/hash.rb +18 -0
  89. data/lib/jsonapi_compliable/util/include_params.rb +22 -0
  90. data/lib/jsonapi_compliable/util/persistence.rb +13 -3
  91. data/lib/jsonapi_compliable/util/relationship_payload.rb +2 -0
  92. data/lib/jsonapi_compliable/util/render_options.rb +2 -0
  93. data/lib/jsonapi_compliable/util/validation_response.rb +16 -0
  94. data/lib/jsonapi_compliable/version.rb +1 -1
  95. metadata +60 -5
  96. data/gemfiles/rails_4.gemfile.lock +0 -208
  97. data/gemfiles/rails_5.gemfile.lock +0 -212
  98. data/lib/jsonapi_compliable/write.rb +0 -93
@@ -1,11 +1,23 @@
1
1
  module JsonapiCompliable
2
2
  module Extensions
3
+ # Turns ruby ? methods into is_ attributes
4
+ #
5
+ # @example Basic Usage
6
+ # boolean_attribute :active?
7
+ #
8
+ # # equivalent do
9
+ # def is_active
10
+ # @object.active?
11
+ # end
3
12
  module BooleanAttribute
4
13
  def self.included(klass)
5
14
  klass.extend ClassMethods
6
15
  end
7
16
 
8
17
  module ClassMethods
18
+ # Register a boolean attribute
19
+ # @param name the corresponding ? method
20
+ # @param [Hash] options Normal .attribute options
9
21
  def boolean_attribute(name, options = {}, &blk)
10
22
  blk ||= proc { @object.public_send(name) }
11
23
  field_name = :"is_#{name.to_s.gsub('?', '')}"
@@ -2,12 +2,44 @@ require 'jsonapi/serializable/resource/conditional_fields'
2
2
 
3
3
  module JsonapiCompliable
4
4
  module Extensions
5
+ # Only render a given attribute when the user specifically requests it.
6
+ # Useful for computationally-expensive attributes that are not required
7
+ # on every request.
8
+ #
9
+ # This class handles the serialization, but you may also want to run
10
+ # code during scoping (for instance, to eager load associations referenced
11
+ # by this extra attribute. See (Resource.extra_field).
12
+ #
13
+ # @example Basic Usage
14
+ # # Will only be rendered on user request, ie
15
+ # # /people?extra_fields[people]=net_worth
16
+ # extra_attribute :net_worth
17
+ #
18
+ # @example Eager Loading
19
+ # class PersonResource < ApplicationResource
20
+ # # If the user requests the 'net_worth' attribute, make sure
21
+ # # 'assets' are eager loaded
22
+ # extra_field :net_worth do |scope|
23
+ # scope.includes(:assets)
24
+ # end
25
+ # end
26
+ #
27
+ # class SerializablePerson < JSONAPI::Serializable::Resource
28
+ # # ... code ...
29
+ # extra_attribute :net_worth do
30
+ # @object.assets.sum(&:value)
31
+ # end
32
+ # end
33
+ #
34
+ # @see Resource.extra_field
5
35
  module ExtraAttribute
6
36
  def self.included(klass)
7
37
  klass.extend ClassMethods
8
38
  end
9
39
 
10
40
  module ClassMethods
41
+ # @param [Symbol] name the name of the attribute
42
+ # @param [Hash] options the options passed on to vanilla to .attribute
11
43
  def extra_attribute(name, options = {}, &blk)
12
44
  allow_field = proc {
13
45
  if options[:if]
@@ -1,5 +1,16 @@
1
1
  module JsonapiCompliable
2
+ # If the object we are serializing has the instance variable
3
+ # +@_jsonapi_temp_id+, render +temp-id+ in the {http://jsonapi.org/format/#document-resource-identifier-objects resource identifier}
4
+ #
5
+ # Why? Well, when the request is a nested POST, creating the main entity as
6
+ # well as relationships, we need some way of telling the client, "hey, the
7
+ # object you have in memory, that you just sent to the server, has been
8
+ # persisted and now has id X".
9
+ #
10
+ # +@_jsonapi_temp_id+ is set within this library. You should never have to
11
+ # reference it directly.
2
12
  module SerializableTempId
13
+ # Common interface for jsonapi-rb extensions
3
14
  def as_jsonapi(*)
4
15
  super.tap do |hash|
5
16
  if temp_id = @object.instance_variable_get(:'@_jsonapi_temp_id')
@@ -1,9 +1,14 @@
1
- # TODO: refactor - code could be better but it's a one-time thing.
2
-
3
1
  module JsonapiCompliable
2
+ # @attr_reader [Hash] params hash of query parameters with symbolized keys
3
+ # @attr_reader [Resource] resource the corresponding Resource object
4
4
  class Query
5
+ # TODO: This class could use some refactoring love!
5
6
  attr_reader :params, :resource
6
7
 
8
+ # This is the structure of +Query#to_hash+ used elsewhere in the library
9
+ # @see #to_hash
10
+ # @api private
11
+ # @return [Hash] the default hash
7
12
  def self.default_hash
8
13
  {
9
14
  filter: {},
@@ -21,10 +26,26 @@ module JsonapiCompliable
21
26
  @params = params
22
27
  end
23
28
 
29
+ # The relevant include directive
30
+ # @see http://jsonapi-rb.org
31
+ # @return [JSONAPI::IncludeDirective]
24
32
  def include_directive
25
33
  @include_directive ||= JSONAPI::IncludeDirective.new(params[:include])
26
34
  end
27
35
 
36
+ # The include, directive, as a hash. For instance
37
+ #
38
+ # { posts: { comments: {} } }
39
+ #
40
+ # This will only include relationships that are
41
+ #
42
+ # * Available on the Resource
43
+ # * Whitelisted (when specified)
44
+ #
45
+ # So that users can't simply request your entire object graph.
46
+ #
47
+ # @see Util::IncludeParams
48
+ # @return [Hash] the scrubbed include directive as a hash
28
49
  def include_hash
29
50
  @include_hash ||= begin
30
51
  requested = include_directive.to_hash
@@ -36,10 +57,66 @@ module JsonapiCompliable
36
57
  end
37
58
  end
38
59
 
60
+ # All the keys of the #include_hash
61
+ #
62
+ # For example, let's say we had
63
+ #
64
+ # { posts: { comments: {} }
65
+ #
66
+ # +#association_names+ would return
67
+ #
68
+ # [:posts, :comments]
69
+ #
70
+ # @return [Array<Symbol>] all association names, recursive
39
71
  def association_names
40
72
  @association_names ||= Util::Hash.keys(include_hash)
41
73
  end
42
74
 
75
+ # A flat hash of sanitized query parameters.
76
+ # All relationship names are top-level:
77
+ #
78
+ # {
79
+ # posts: { filter, sort, ... }
80
+ # comments: { filter, sort, ... }
81
+ # }
82
+ #
83
+ # @example sorting
84
+ # # GET /posts?sort=-title
85
+ # { posts: { sort: { title: :desc } } }
86
+ #
87
+ # @example pagination
88
+ # # GET /posts?page[number]=2&page[size]=10
89
+ # { posts: { page: { number: 2, size: 10 } }
90
+ #
91
+ # @example filtering
92
+ # # GET /posts?filter[title]=Foo
93
+ # { posts: { filter: { title: 'Foo' } }
94
+ #
95
+ # @example include
96
+ # # GET /posts?include=comments.author
97
+ # { posts: { include: { comments: { author: {} } } } }
98
+ #
99
+ # @example stats
100
+ # # GET /posts?stats[likes]=count,average
101
+ # { posts: { stats: [:count, :average] } }
102
+ #
103
+ # @example fields
104
+ # # GET /posts?fields=foo,bar
105
+ # { posts: { fields: [:foo, :bar] } }
106
+ #
107
+ # @example extra fields
108
+ # # GET /posts?fields=foo,bar
109
+ # { posts: { extra_fields: [:foo, :bar] } }
110
+ #
111
+ # @example nested parameters
112
+ # # GET /posts?include=comments&sort=comments.created_at&page[comments][size]=3
113
+ # {
114
+ # posts: { ... },
115
+ # comments: { page: { size: 3 }, sort: { created_at: :asc } }
116
+ #
117
+ # @see #default_hash
118
+ # @see Base#query_hash
119
+ # @return [Hash] the normalized query hash
43
120
  def to_hash
44
121
  hash = { resource.type => self.class.default_hash }
45
122
 
@@ -64,6 +141,21 @@ module JsonapiCompliable
64
141
  hash
65
142
  end
66
143
 
144
+ # Check if the user has requested 0 actual results
145
+ # They may have done this to get, say, the total count
146
+ # without the overhead of fetching actual records.
147
+ #
148
+ # @example Total Count, 0 Results
149
+ # # GET /posts?page[size]=0&stats[total]=count
150
+ # # Response:
151
+ # {
152
+ # data: [],
153
+ # meta: {
154
+ # stats: { total: { count: 100 } }
155
+ # }
156
+ # }
157
+ #
158
+ # @return [Boolean] were 0 results requested?
67
159
  def zero_results?
68
160
  !@params[:page].nil? &&
69
161
  !@params[:page][:size].nil? &&
@@ -1,6 +1,14 @@
1
1
  require 'jsonapi/rails'
2
2
 
3
3
  module JsonapiCompliable
4
+ # Rails Integration. Mix this in to ApplicationController.
5
+ #
6
+ # * Mixes in Base
7
+ # * Adds a global around_action (see Base#wrap_context)
8
+ # * Uses Rails' +render+ for rendering
9
+ #
10
+ # @see Base#render_jsonapi
11
+ # @see Base#wrap_context
4
12
  module Rails
5
13
  def self.included(klass)
6
14
  klass.send(:include, Base)
@@ -1,33 +1,203 @@
1
1
  module JsonapiCompliable
2
+ # Resources hold configuration: How do you want to process incoming JSONAPI
3
+ # requests?
4
+ #
5
+ # Let's say we start with an empty hash as our scope object:
6
+ #
7
+ # render_jsonapi({})
8
+ #
9
+ # Let's define the behavior of various parameters. Here we'll merge
10
+ # options into our hash when the user filters, sorts, and paginates.
11
+ # Then, we'll pass that hash off to an HTTP Client:
12
+ #
13
+ # class PostResource < ApplicationResource
14
+ # type :posts
15
+ # use_adapter JsonapiCompliable::Adapters::Null
16
+ #
17
+ # # What do do when filter[active] parameter comes in
18
+ # allow_filter :active do |scope, value|
19
+ # scope.merge(active: value)
20
+ # end
21
+ #
22
+ # # What do do when sorting parameters come in
23
+ # sort do |scope, attribute, direction|
24
+ # scope.merge(order: { attribute => direction })
25
+ # end
26
+ #
27
+ # # What do do when pagination parameters come in
28
+ # page do |scope, current_page, per_page|
29
+ # scope.merge(page: current_page, per_page: per_page)
30
+ # end
31
+ #
32
+ # # Resolve the scope by passing the hash to an HTTP Client
33
+ # def resolve(scope)
34
+ # MyHttpClient.get(scope)
35
+ # end
36
+ # end
37
+ #
38
+ # This code can quickly become duplicative - we probably want to reuse
39
+ # this logic for other objects that use the same HTTP client.
40
+ #
41
+ # That's why we also have *Adapters*. Adapters encapsulate common, reusable
42
+ # resource configuration. That's why we don't need to specify the above code
43
+ # when using +ActiveRecord+ - the default logic is already in the adapter.
44
+ #
45
+ # class PostResource < ApplicationResource
46
+ # type :posts
47
+ # use_adapter JsonapiCompliable::Adapters::ActiveRecord
48
+ #
49
+ # allow_filter :title
50
+ # end
51
+ #
52
+ # Of course, we can always override the Resource directly for one-off
53
+ # customizations:
54
+ #
55
+ # class PostResource < ApplicationResource
56
+ # type :posts
57
+ # use_adapter JsonapiCompliable::Adapters::ActiveRecord
58
+ #
59
+ # allow_filter :title_prefix do |scope, value|
60
+ # scope.where(["title LIKE ?", "#{value}%"])
61
+ # end
62
+ # end
63
+ #
64
+ # Resources can also define *Sideloads*. Sideloads define the relationships between resources:
65
+ #
66
+ # allow_sideload :comments, resource: CommentResource do
67
+ # # How to fetch the associated objects
68
+ # # This will be further chained down the line
69
+ # scope do |posts|
70
+ # Comment.where(post_id: posts.map(&:id))
71
+ # end
72
+ #
73
+ # # Now that we've resolved everything, how to assign the objects
74
+ # assign do |posts, comments|
75
+ # posts.each do |post|
76
+ # relevant_comments = comments.select { |c| c.post_id === post.id }
77
+ # post.comments = relevant_comments
78
+ # end
79
+ # end
80
+ # end
81
+ #
82
+ # Once again, we can DRY this up using an Adapter:
83
+ #
84
+ # use_adapter JsonapiCompliable::Adapters::ActiveRecord
85
+ #
86
+ # has_many :comments,
87
+ # scope: -> { Comment.all },
88
+ # resource: CommentResource,
89
+ # foreign_key: :post_id
90
+ #
91
+ # @attr_reader [Hash] context A hash of +object+ and +namespace+ - Example object is a Rails controller, example namespace would be +:index+ or +:show+
2
92
  class Resource
93
+ extend Forwardable
3
94
  attr_reader :context
4
95
 
5
96
  class << self
97
+ extend Forwardable
6
98
  attr_accessor :config
7
99
 
8
- delegate :allow_sideload, to: :sideloading
9
- delegate :has_many, to: :sideloading
10
- delegate :has_one, to: :sideloading
11
- delegate :belongs_to, to: :sideloading
12
- delegate :has_and_belongs_to_many, to: :sideloading
13
- delegate :polymorphic_belongs_to, to: :sideloading
14
- delegate :polymorphic_has_many, to: :sideloading
15
- end
16
-
17
- delegate :sideload, to: :sideloading
18
-
100
+ # @!method allow_sideload
101
+ # @see Sideload#allow_sideload
102
+ def_delegator :sideloading, :allow_sideload
103
+ # @!method has_many
104
+ # @see Adapters::ActiveRecordSideloading#has_many
105
+ def_delegator :sideloading, :has_many
106
+ # @!method has_one
107
+ # @see Adapters::ActiveRecordSideloading#has_one
108
+ def_delegator :sideloading, :has_one
109
+ # @!method belongs_to
110
+ # @see Adapters::ActiveRecordSideloading#belongs_to
111
+ def_delegator :sideloading, :belongs_to
112
+ # @!method has_and_belongs_to_many
113
+ # @see Adapters::ActiveRecordSideloading#has_and_belongs_to_many
114
+ def_delegator :sideloading, :has_and_belongs_to_many
115
+ # @!method polymorphic_belongs_to
116
+ # @see Adapters::ActiveRecordSideloading#polymorphic_belongs_to
117
+ def_delegator :sideloading, :polymorphic_belongs_to
118
+ # @!method polymorphic_has_many
119
+ # @see Adapters::ActiveRecordSideloading#polymorphic_has_many
120
+ def_delegator :sideloading, :polymorphic_has_many
121
+ end
122
+
123
+ # @!method sideload
124
+ # @see Sideload#sideload
125
+ def_delegator :sideloading, :sideload
126
+
127
+ # @private
19
128
  def self.inherited(klass)
20
129
  klass.config = Util::Hash.deep_dup(self.config)
21
130
  end
22
131
 
132
+ # @api private
23
133
  def self.sideloading
24
134
  @sideloading ||= Sideload.new(:base, resource: self)
25
135
  end
26
136
 
137
+ # Set the sideload whitelist. You may want to omit sideloads for
138
+ # security or performance reasons.
139
+ #
140
+ # Uses JSONAPI::IncludeDirective from {{http://jsonapi-rb.org jsonapi-rb}}
141
+ #
142
+ # @example Whitelisting Relationships
143
+ # # Given the following whitelist
144
+ # class PostResource < ApplicationResource
145
+ # # ... code ...
146
+ # sideload_whitelist([:blog, { comments: :author }])
147
+ # end
148
+ #
149
+ # # A request to sideload 'tags'
150
+ # #
151
+ # # GET /posts?include=tags
152
+ # #
153
+ # # ...will silently fail.
154
+ # #
155
+ # # A request for comments and tags:
156
+ # #
157
+ # # GET /posts?include=tags,comments
158
+ # #
159
+ # # ...will only sideload comments
160
+ #
161
+ # @param [Hash, Array, Symbol] whitelist
162
+ # @see Query#include_hash
27
163
  def self.sideload_whitelist(whitelist)
28
164
  config[:sideload_whitelist] = JSONAPI::IncludeDirective.new(whitelist).to_hash
29
165
  end
30
166
 
167
+ # Whitelist a filter
168
+ #
169
+ # @example Basic Filtering
170
+ # allow_filter :title
171
+ #
172
+ # # When using ActiveRecord, this code is equivalent
173
+ # allow_filter :title do |scope, value|
174
+ # scope.where(title: value)
175
+ # end
176
+ #
177
+ # @example Custom Filtering
178
+ # # All filters can be customized with a block
179
+ # allow_filter :title_prefix do |scope, value|
180
+ # scope.where('title LIKE ?', "#{value}%")
181
+ # end
182
+ #
183
+ # @example Guarding Filters
184
+ # # Only allow the current user to filter on a property
185
+ # allow_filter :title, if: :admin?
186
+ #
187
+ # def admin?
188
+ # current_user.role == 'admin'
189
+ # end
190
+ #
191
+ # If a filter is not allowed, a +Jsonapi::Errors::BadFilter+ error will be raised.
192
+ #
193
+ # @overload allow_filter(name, options = {})
194
+ # @param [Symbol] name The name of the filter
195
+ # @param [Hash] options
196
+ # @option options [Symbol] :if A method name on the current context - If the method returns false, +BadFilter+ will be raised.
197
+ # @option options [Array<Symbol>] :aliases Allow the user to specify these aliases in the URL, then match to this filter. Mainly used for backwards-compatibility.
198
+ #
199
+ # @yieldparam scope The object being scoped
200
+ # @yieldparam value The sanitized value from the URL
31
201
  def self.allow_filter(name, *args, &blk)
32
202
  opts = args.extract_options!
33
203
  aliases = [name, opts[:aliases]].flatten.compact
@@ -38,54 +208,210 @@ module JsonapiCompliable
38
208
  }
39
209
  end
40
210
 
211
+ # Whitelist a statistic.
212
+ #
213
+ # Statistics are requested like
214
+ #
215
+ # GET /posts?stats[total]=count
216
+ #
217
+ # And returned in +meta+:
218
+ #
219
+ # {
220
+ # data: [...],
221
+ # meta: { stats: { total: { count: 100 } } }
222
+ # }
223
+ #
224
+ # Statistics take into account the current scope, *without pagination*.
225
+ #
226
+ # @example Total Count
227
+ # allow_stat total: [:count]
228
+ #
229
+ # @example Average Rating
230
+ # allow_stat rating: [:average]
231
+ #
232
+ # @example Custom Stat
233
+ # allow_stat rating: [:average] do
234
+ # standard_deviation { |scope, attr| ... }
235
+ # end
236
+ #
237
+ # @param [Symbol, Hash] symbol_or_hash The attribute and metric
238
+ # @yieldparam scope The object being scoped
239
+ # @yieldparam [Symbol] attr The name of the metric
41
240
  def self.allow_stat(symbol_or_hash, &blk)
42
241
  dsl = Stats::DSL.new(config[:adapter], symbol_or_hash)
43
242
  dsl.instance_eval(&blk) if blk
44
243
  config[:stats][dsl.name] = dsl
45
244
  end
46
245
 
246
+ # When you want a filter to always apply, on every request.
247
+ #
248
+ # @example Only Active Posts
249
+ # default_filter :active do |scope|
250
+ # scope.where(active: true)
251
+ # end
252
+ #
253
+ # Default filters can be overridden *if* there is a corresponding +allow_filter+:
254
+ #
255
+ # @example Overriding Default Filters
256
+ # allow_filter :active
257
+ #
258
+ # default_filter :active do |scope|
259
+ # scope.where(active: true)
260
+ # end
261
+ #
262
+ # # GET /posts?filter[active]=false
263
+ # # Returns only active posts
264
+ #
265
+ # @see .allow_filter
266
+ # @param [Symbol] name The default filter name
267
+ # @yieldparam scope The object being scoped
47
268
  def self.default_filter(name, &blk)
48
269
  config[:default_filters][name.to_sym] = {
49
270
  filter: blk
50
271
  }
51
272
  end
52
273
 
274
+ # The Model object associated with this class.
275
+ #
276
+ # This model will be utilized on write requests.
277
+ #
278
+ # Models need not be ActiveRecord ;)
279
+ #
280
+ # @example
281
+ # class PostResource < ApplicationResource
282
+ # # ... code ...
283
+ # model Post
284
+ # end
285
+ #
286
+ # @param [Class] klass The associated Model class
53
287
  def self.model(klass)
54
288
  config[:model] = klass
55
289
  end
56
290
 
291
+ # Define custom sorting logic
292
+ #
293
+ # @example Sort on alternate table
294
+ # # GET /employees?sort=title
295
+ # sort do |scope, att, dir|
296
+ # if att == :title
297
+ # scope.joins(:current_position).order("title #{dir}")
298
+ # else
299
+ # scope.order(att => dir)
300
+ # end
301
+ # end
302
+ #
303
+ # @yieldparam scope The current object being scoped
304
+ # @yieldparam [Symbol] att The requested sort attribute
305
+ # @yieldparam [Symbol] dir The requested sort direction (:asc/:desc)
57
306
  def self.sort(&blk)
58
307
  config[:sorting] = blk
59
308
  end
60
309
 
310
+ # Define custom pagination logic
311
+ #
312
+ # @example Use will_paginate instead of Kaminari
313
+ # # GET /employees?page[size]=10&page[number]=2
314
+ # paginate do |scope, current_page, per_page|
315
+ # scope.paginate(page: current_page, per_page: per_page)
316
+ # end
317
+ #
318
+ # @yieldparam scope The current object being scoped
319
+ # @yieldparam [Integer] current_page The page[number] parameter value
320
+ # @yieldparam [Integer] per_page The page[size] parameter value
61
321
  def self.paginate(&blk)
62
322
  config[:pagination] = blk
63
323
  end
64
324
 
325
+ # Perform special logic when an extra field is requested.
326
+ # Often used to eager load data that will be used to compute the
327
+ # extra field.
328
+ #
329
+ # This is *not* required if you have no custom logic.
330
+ #
331
+ # @example Eager load if extra field is required
332
+ # # GET /employees?extra_fields[employees]=net_worth
333
+ # extra_field(employees: [:net_worth]) do |scope|
334
+ # scope.includes(:assets)
335
+ # end
336
+ #
337
+ # @see Scoping::ExtraFields
338
+ #
339
+ # @param [Symbol] name Name of the extra field
340
+ # @yieldparam scope The current object being scoped
341
+ # @yieldparam [Integer] current_page The page[number] parameter value
342
+ # @yieldparam [Integer] per_page The page[size] parameter value
65
343
  def self.extra_field(name, &blk)
66
344
  config[:extra_fields][name] = blk
67
345
  end
68
346
 
347
+ # Configure the adapter you want to use.
348
+ #
349
+ # @example ActiveRecord Adapter
350
+ # require 'jsonapi_compliable/adapters/active_record'
351
+ # use_adapter JsonapiCompliable::Adapters::ActiveRecord
352
+ #
353
+ # @param [Class] klass The adapter class
69
354
  def self.use_adapter(klass)
70
355
  config[:adapter] = klass.new
71
356
  end
72
357
 
358
+ # Override default sort applied when not present in the query parameters.
359
+ #
360
+ # Default: [{ id: :asc }]
361
+ #
362
+ # @example Order by created_at descending by default
363
+ # # GET /employees will order by created_at descending
364
+ # default_sort([{ created_at: :desc }])
365
+ #
366
+ # @param [Array<Hash>] val Array of sorting criteria
73
367
  def self.default_sort(val)
74
368
  config[:default_sort] = val
75
369
  end
76
370
 
371
+ # The JSONAPI Type. For instance if you queried:
372
+ #
373
+ # GET /employees?fields[positions]=title
374
+ #
375
+ # And/Or got back in the response
376
+ #
377
+ # { id: '1', type: 'positions' }
378
+ #
379
+ # The type would be :positions
380
+ #
381
+ # This should match the +type+ set in your serializer.
382
+ #
383
+ # @example
384
+ # class PostResource < ApplicationResource
385
+ # type :posts
386
+ # end
387
+ #
388
+ # @param [Array<Hash>] value Array of sorting criteria
77
389
  def self.type(value = nil)
78
390
  config[:type] = value
79
391
  end
80
392
 
393
+ # Set an alternative default page number. Defaults to 1.
394
+ # @param [Integer] val The new default
81
395
  def self.default_page_number(val)
82
396
  config[:default_page_number] = val
83
397
  end
84
398
 
399
+ # Set an alternate default page size, when not specified in query parameters.
400
+ #
401
+ # @example
402
+ # # GET /employees will only render 10 employees
403
+ # default_page_size 10
404
+ #
405
+ # @param [Integer] val The new default page size.
85
406
  def self.default_page_size(val)
86
407
  config[:default_page_size] = val
87
408
  end
88
409
 
410
+ # This is where we store all information set via DSL.
411
+ # Useful for introspection.
412
+ # Gets dup'd when inherited.
413
+ #
414
+ # @return [Hash] the current configuration
89
415
  def self.config
90
416
  @config ||= begin
91
417
  {
@@ -102,6 +428,23 @@ module JsonapiCompliable
102
428
  end
103
429
  end
104
430
 
431
+ # Run code within a given context.
432
+ # Useful for running code within, say, a Rails controller context
433
+ #
434
+ # When using Rails, controller actions are wrapped this way.
435
+ #
436
+ # @example Sinatra
437
+ # get '/api/posts' do
438
+ # resource.with_context self, :index do
439
+ # scope = jsonapi_scope(Tweet.all)
440
+ # render_jsonapi(scope.resolve, scope: false)
441
+ # end
442
+ # end
443
+ #
444
+ # @see Rails
445
+ # @see Base#wrap_context
446
+ # @param object The context (Rails controller or equivalent)
447
+ # @param namespace One of index/show/etc
105
448
  def with_context(object, namespace = nil)
106
449
  begin
107
450
  prior = context
@@ -112,32 +455,105 @@ module JsonapiCompliable
112
455
  end
113
456
  end
114
457
 
458
+ # The current context set by +#with_context+ in the form of
459
+ #
460
+ # { object: context_obj, namespace: :index }
461
+ #
462
+ # @see #with_context
463
+ # @return [Hash] the context hash
115
464
  def context
116
465
  @context || {}
117
466
  end
118
467
 
468
+ # Build a scope using this Resource configuration
469
+ #
470
+ # Essentially "api private", but can be useful for testing.
471
+ #
472
+ # @see Scope
473
+ # @see Query
474
+ # @param base The base scope we are going to chain
475
+ # @param query The relevant Query object
476
+ # @param opts Opts passed to +Scope.new+
477
+ # @return [Scope] a configured Scope instance
119
478
  def build_scope(base, query, opts = {})
120
479
  Scope.new(base, self, query, opts)
121
480
  end
122
481
 
482
+ # Create the relevant model.
483
+ # You must configure a model (see .model) to create.
484
+ # If you override, you *must* return the created instance.
485
+ #
486
+ # @example Send e-mail on creation
487
+ # def create(attributes)
488
+ # instance = model.create(attributes)
489
+ # UserMailer.welcome_email(instance).deliver_later
490
+ # instance
491
+ # end
492
+ #
493
+ # @see .model
494
+ # @see Adapters::ActiveRecord#create
495
+ # @param [Hash] create_params The relevant attributes, including id and foreign keys
496
+ # @return [Object] an instance of the just-created model
123
497
  def create(create_params)
124
498
  adapter.create(model, create_params)
125
499
  end
126
500
 
501
+ # Update the relevant model.
502
+ # You must configure a model (see .model) to update.
503
+ # If you override, you *must* return the updated instance.
504
+ #
505
+ # @example Send e-mail on update
506
+ # def update(attributes)
507
+ # instance = model.update_attributes(attributes)
508
+ # UserMailer.profile_updated_email(instance).deliver_later
509
+ # instance
510
+ # end
511
+ #
512
+ # @see .model
513
+ # @see Adapters::ActiveRecord#update
514
+ # @param [Hash] update_params The relevant attributes, including id and foreign keys
515
+ # @return [Object] an instance of the just-created model
127
516
  def update(update_params)
128
517
  adapter.update(model, update_params)
129
518
  end
130
519
 
520
+ # Destroy the relevant model.
521
+ # You must configure a model (see .model) to destroy.
522
+ # If you override, you *must* return the destroyed instance.
523
+ #
524
+ # @example Send e-mail on destroy
525
+ # def destroy(attributes)
526
+ # instance = model_class.find(id)
527
+ # instance.destroy
528
+ # UserMailer.goodbye_email(instance).deliver_later
529
+ # instance
530
+ # end
531
+ #
532
+ # @see .model
533
+ # @see Adapters::ActiveRecord#destroy
534
+ # @param [String] id The +id+ of the relevant Model
535
+ # @return [Object] an instance of the just-created model
131
536
  def destroy(id)
132
537
  adapter.destroy(model, id)
133
538
  end
134
539
 
540
+ # @api private
135
541
  def persist_with_relationships(meta, attributes, relationships)
136
542
  persistence = JsonapiCompliable::Util::Persistence \
137
543
  .new(self, meta, attributes, relationships)
138
544
  persistence.run
139
545
  end
140
546
 
547
+ # All possible sideload names, including nested names
548
+ #
549
+ # { comments: { author: {} } }
550
+ #
551
+ # Becomes
552
+ #
553
+ # [:comments, :author]
554
+ #
555
+ # @see Sideload#to_hash
556
+ # @return [Array<Symbol>] the list of association names
141
557
  def association_names
142
558
  @association_names ||= begin
143
559
  if sideloading
@@ -148,6 +564,31 @@ module JsonapiCompliable
148
564
  end
149
565
  end
150
566
 
567
+ # An Include Directive Hash of all possible sideloads for the current
568
+ # context namespace, taking into account the sideload whitelist.
569
+ #
570
+ # In other words, say we have this resource:
571
+ #
572
+ # class PostResource < ApplicationResource
573
+ # sideload_whitelist({
574
+ # index: :comments,
575
+ # show: { comments: :author }
576
+ # })
577
+ # end
578
+ #
579
+ # Expected behavior:
580
+ #
581
+ # allowed_sideloads(:index) # => { comments: {} }
582
+ # allowed_sideloads(:show) # => { comments: { author: {} }
583
+ #
584
+ # instance.with_context({}, :index) do
585
+ # instance.allowed_sideloads # => { comments: {} }
586
+ # end
587
+ #
588
+ # @see Util::IncludeParams.scrub
589
+ # @see #with_context
590
+ # @param [Symbol] namespace Can be :index/:show/etc - The current context namespace will be used by default.
591
+ # @return [Hash] the scrubbed include directive
151
592
  def allowed_sideloads(namespace = nil)
152
593
  return {} unless sideloading
153
594
 
@@ -159,72 +600,168 @@ module JsonapiCompliable
159
600
  sideloads
160
601
  end
161
602
 
603
+ # The relevant proc for the given attribute and calculation.
604
+ #
605
+ # @example Custom Stats
606
+ # # Given this configuration
607
+ # allow_stat :rating do
608
+ # average { |scope, attr| ... }
609
+ # end
610
+ #
611
+ # # We'd call the method like
612
+ # resource.stat(:rating, :average)
613
+ # # Which would return the custom proc
614
+ #
615
+ # Raises +JsonapiCompliable::Errors::StatNotFound+ if not corresponding
616
+ # stat has been configured.
617
+ #
618
+ # @see Errors::StatNotFound
619
+ # @param [String, Symbol] attribute The attribute we're calculating.
620
+ # @param [String, Symbol] calculation The calculation to run
621
+ # @return [Proc] the corresponding callable
162
622
  def stat(attribute, calculation)
163
623
  stats_dsl = stats[attribute] || stats[attribute.to_sym]
164
624
  raise Errors::StatNotFound.new(attribute, calculation) unless stats_dsl
165
625
  stats_dsl.calculation(calculation)
166
626
  end
167
627
 
628
+ # Interface to the sideloads for this Resource
629
+ # @api private
168
630
  def sideloading
169
631
  self.class.sideloading
170
632
  end
171
633
 
634
+ # @see .default_sort
635
+ # @api private
172
636
  def default_sort
173
637
  self.class.config[:default_sort] || [{ id: :asc }]
174
638
  end
175
639
 
640
+ # @see .default_page_number
641
+ # @api private
176
642
  def default_page_number
177
643
  self.class.config[:default_page_number] || 1
178
644
  end
179
645
 
646
+ # @see .default_page_size
647
+ # @api private
180
648
  def default_page_size
181
649
  self.class.config[:default_page_size] || 20
182
650
  end
183
651
 
652
+ # Returns :undefined_jsonapi_type when not configured.
653
+ # @see .type
654
+ # @api private
184
655
  def type
185
656
  self.class.config[:type] || :undefined_jsonapi_type
186
657
  end
187
658
 
659
+ # @see .allow_filter
660
+ # @api private
188
661
  def filters
189
662
  self.class.config[:filters]
190
663
  end
191
664
 
665
+ # @see .sort
666
+ # @api private
192
667
  def sorting
193
668
  self.class.config[:sorting]
194
669
  end
195
670
 
671
+ # @see .allow_stat
672
+ # @api private
196
673
  def stats
197
674
  self.class.config[:stats]
198
675
  end
199
676
 
677
+ # @see .paginate
678
+ # @api private
200
679
  def pagination
201
680
  self.class.config[:pagination]
202
681
  end
203
682
 
683
+ # @see .extra_field
684
+ # @api private
204
685
  def extra_fields
205
686
  self.class.config[:extra_fields]
206
687
  end
207
688
 
689
+ # @see .sideload_whitelist
690
+ # @api private
208
691
  def sideload_whitelist
209
692
  self.class.config[:sideload_whitelist]
210
693
  end
211
694
 
695
+ # @see .default_filter
696
+ # @api private
212
697
  def default_filters
213
698
  self.class.config[:default_filters]
214
699
  end
215
700
 
701
+ # @see .model
702
+ # @api private
216
703
  def model
217
704
  self.class.config[:model]
218
705
  end
219
706
 
707
+ # @see .use_adapter
708
+ # @api private
220
709
  def adapter
221
710
  self.class.config[:adapter]
222
711
  end
223
712
 
713
+ # How do you want to resolve the scope?
714
+ #
715
+ # For ActiveRecord, when we want to actually fire SQL, it's
716
+ # +#to_a+.
717
+ #
718
+ # @example Custom API Call
719
+ # # Let's build a hash and pass it off to an HTTP client
720
+ # class PostResource < ApplicationResource
721
+ # type :posts
722
+ # use_adapter JsonapiCompliable::Adapters::Null
723
+ #
724
+ # sort do |scope, attribute, direction|
725
+ # scope.merge!(order: { attribute => direction }
726
+ # end
727
+ #
728
+ # page do |scope, current_page, per_page|
729
+ # scope.merge!(page: current_page, per_page: per_page)
730
+ # end
731
+ #
732
+ # def resolve(scope)
733
+ # MyHttpClient.get(scope)
734
+ # end
735
+ # end
736
+ #
737
+ # This method *must* return an array of resolved model objects.
738
+ #
739
+ # By default, delegates to the adapter. You likely want to alter your
740
+ # adapter rather than override this directly.
741
+ #
742
+ # @see Adapters::ActiveRecord#resolve
743
+ # @param scope The scope object we've built up
744
+ # @return [Array] array of resolved model objects
224
745
  def resolve(scope)
225
746
  adapter.resolve(scope)
226
747
  end
227
748
 
749
+ # How to run write requests within a transaction.
750
+ #
751
+ # @example
752
+ # resource.transaction do
753
+ # # ... save calls ...
754
+ # end
755
+ #
756
+ # Should roll back the transaction, but avoid bubbling up the error,
757
+ # if +JsonapiCompliable::Errors::ValidationError+ is raised within
758
+ # the block.
759
+ #
760
+ # By default, delegates to the adapter. You likely want to alter your
761
+ # adapter rather than override this directly.
762
+ #
763
+ # @see Adapters::ActiveRecord#transaction
764
+ # @return the result of +yield+
228
765
  def transaction
229
766
  response = nil
230
767
  begin