jsonapi_compliable 0.6.4 → 0.6.5

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.
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