json_api_server 0.0.1

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +7 -0
  3. data/.gitignore +14 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +35 -0
  6. data/.travis.yml +5 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Dockerfile +9 -0
  9. data/Gemfile +10 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +432 -0
  12. data/Rakefile +6 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +8 -0
  15. data/config/locales/en.yml +32 -0
  16. data/docker-compose.yml +10 -0
  17. data/json_api_server.gemspec +50 -0
  18. data/lib/json_api_server.rb +76 -0
  19. data/lib/json_api_server/api_version.rb +8 -0
  20. data/lib/json_api_server/attributes_builder.rb +135 -0
  21. data/lib/json_api_server/base_serializer.rb +169 -0
  22. data/lib/json_api_server/builder.rb +201 -0
  23. data/lib/json_api_server/cast.rb +89 -0
  24. data/lib/json_api_server/configuration.rb +99 -0
  25. data/lib/json_api_server/controller/error_handling.rb +164 -0
  26. data/lib/json_api_server/engine.rb +5 -0
  27. data/lib/json_api_server/error.rb +64 -0
  28. data/lib/json_api_server/errors.rb +50 -0
  29. data/lib/json_api_server/exceptions.rb +6 -0
  30. data/lib/json_api_server/fields.rb +76 -0
  31. data/lib/json_api_server/filter.rb +255 -0
  32. data/lib/json_api_server/filter_builders.rb +135 -0
  33. data/lib/json_api_server/filter_config.rb +71 -0
  34. data/lib/json_api_server/filter_parser.rb +88 -0
  35. data/lib/json_api_server/include.rb +158 -0
  36. data/lib/json_api_server/meta_builder.rb +39 -0
  37. data/lib/json_api_server/mime_types.rb +21 -0
  38. data/lib/json_api_server/pagination.rb +189 -0
  39. data/lib/json_api_server/paginator.rb +134 -0
  40. data/lib/json_api_server/relationships_builder.rb +215 -0
  41. data/lib/json_api_server/resource_serializer.rb +245 -0
  42. data/lib/json_api_server/resources_serializer.rb +131 -0
  43. data/lib/json_api_server/serializer.rb +34 -0
  44. data/lib/json_api_server/sort.rb +156 -0
  45. data/lib/json_api_server/sort_configs.rb +63 -0
  46. data/lib/json_api_server/validation_errors.rb +51 -0
  47. data/lib/json_api_server/version.rb +3 -0
  48. metadata +259 -0
@@ -0,0 +1,201 @@
1
+ module JsonApiServer # :nodoc:
2
+ # This class integrates JSON API features -- pagination, sorting, filters, inclusion of
3
+ # related resources, and sparse fieldsets -- in one place. It collects data to be used
4
+ # by serializers.
5
+ #
6
+ # - It merges JSON API sub-queries (i.e, filters, pagination, sorting, etc.) into a query.
7
+ # - It provides convenience methods to requested sparse fields, includes and pagination.
8
+ #
9
+ # === Usage:
10
+ #
11
+ # The Builder class takes two arguments, (1) the controller request and (2) an
12
+ # initial query (i.e., MyModel.all, MyModel.authorized_to(current_user), etc.).
13
+ #
14
+ # Add JSON API features appropriate for the request. These methods are chainable.
15
+ # Except for sparse fields, JSON API features are configured; filter, include
16
+ # and sort configurations whitelist attributes/behavior while pagination sets
17
+ # defaults and limits on number of records to return.
18
+ #
19
+ # - <tt>add_pagination(pagination_options)</tt> - for collections
20
+ # - <tt>add_filter(filter_options)</tt> - for collections
21
+ # - <tt>add_include(include_options)</tt>
22
+ # - <tt>add_sort(sort_options)</tt> - for collections
23
+ # - <tt>add_fields</tt>
24
+ #
25
+ # Once features are added, their corresponding values can be accessed:
26
+ #
27
+ # - <tt>query</tt> - memoized merged query (merges initial request with sort, pagination, filters, includes sub-queries if any)
28
+ # - <tt>paginator</tt> - Paginator class for pagination links.
29
+ # - <tt>includes</tt> - Array of requested (whitelisted) includes (i.e, <tt>['comments', 'comment.author']</tt>)
30
+ # - <tt>sparse_fields</tt> - Hash of type/fields (i.e., {'articles => ['title', 'body', 'author'], 'people' => ['name']})
31
+ #
32
+ # ===== Example
33
+ # attr_accessor :pagination_options, :sort_options, :filter_options, :include_options
34
+ #
35
+ # before_action do |c|
36
+ # c.pagination_options = { default_per_page: 10, max_per_page: 60 }
37
+ # c.sort_options = {
38
+ # permitted: [:character, :location, :published],
39
+ # default: { id: :desc }
40
+ # }
41
+ # c.filter_options = [
42
+ # { id: { type: 'Integer' } },
43
+ # { published: { type: 'Date' } },
44
+ # :location,
45
+ # { book: { wildcard: :both } }
46
+ # ]
47
+ # c.include_options = [
48
+ # {'publisher': -> { includes(:publisher) }},
49
+ # {'comments': -> { includes(:comments) }},
50
+ # 'comment.author'
51
+ # ]
52
+ # end
53
+ #
54
+ # # A collection.
55
+ # def index
56
+ # builder = JsonApiServer::Builder.new(request, Topic.current)
57
+ # .add_pagination(pagination_options)
58
+ # .add_filter(filter_options)
59
+ # .add_include(include_options)
60
+ # .add_sort(sort_options)
61
+ # .add_fields
62
+ #
63
+ # serializer = TopicsSerializer.from_builder(builder)
64
+ # render json: serializer.to_json, status: :ok
65
+ # end
66
+ #
67
+ # # A resource.
68
+ # def show
69
+ # builder = JsonApiServer::Builder.new(request, Topic.find(params[:id]))
70
+ # .add_include(['publisher', 'comments', 'comments.includes'])
71
+ # .add_fields
72
+ #
73
+ # serializer = TopicSerializer.from_builder(builder)
74
+ # render json: serializer.to_json, status: :ok
75
+ # end
76
+ #
77
+ class Builder
78
+ # ActionDispatch::Request passed in constructor.
79
+ attr_reader :request
80
+
81
+ # ActiveRecord::Base model extracted from initial query passed in constructor.
82
+ attr_reader :model
83
+
84
+ # JsonApiServer::Fields instance if #add_fields was called. nil otherwise.
85
+ attr_reader :fields
86
+
87
+ # JsonApiServer::Pagination instance if #add_pagination was called. nil otherwise.
88
+ attr_reader :pagination
89
+
90
+ # JsonApiServer::Filter instance if #add_filter was called. nil otherwise.
91
+ attr_reader :filter
92
+
93
+ # JsonApiServer::Include instance if #add_include was called. nil otherwise.
94
+ attr_reader :include
95
+
96
+ # JsonApiServer::Sort instance if #add_sort was called. nil otherwise.
97
+ attr_reader :sort
98
+
99
+ # Arguments:
100
+ # - request - an ActionDispatch::Request
101
+ # - query (ActiveRecord::Relation) - Initial query.
102
+ def initialize(request, query)
103
+ @request = request
104
+ @initial_query = query
105
+ @model = model_from_query(@initial_query)
106
+ end
107
+
108
+ # Merges pagination, filter, sort, and include sub-queries (if defined)
109
+ # into the initial query. Returns an ActiveRecord::Relation object.
110
+ def relation
111
+ @relation ||= begin
112
+ return @initial_query unless @initial_query.respond_to?(:where)
113
+
114
+ %i[pagination filter include sort].each_with_object(@initial_query) do |method, query|
115
+ frag = send(method).try(:relation)
116
+ query.merge!(frag) if frag
117
+ end
118
+ end
119
+ end
120
+
121
+ alias query relation
122
+
123
+ # Creates JsonApiServer::Pagination instance based on request, initial query
124
+ # model and pagination options. Instance is available through
125
+ # the #pagination attribute. For collections only.
126
+ #
127
+ # - <tt>options</tt> - JsonApiServer::Pagination options.
128
+ def add_pagination(**options)
129
+ @pagination = JsonApiServer::Pagination.new(request, model, options)
130
+ self
131
+ end
132
+
133
+ # Creates JsonApiServer::Filter instance based on request, initial query
134
+ # model and filter configs. Instance is available through
135
+ # the #filter attribute.
136
+ #
137
+ # - <tt>permitted</tt> - JsonApiServer::Filter configs.
138
+ def add_filter(permitted = [])
139
+ @filter = JsonApiServer::Filter.new(request, model, permitted)
140
+ self
141
+ end
142
+
143
+ # Creates JsonApiServer::Include instance based on request, initial query
144
+ # model and include configs. Instance is available through
145
+ # the #include attribute.
146
+ #
147
+ # - <tt>permitted</tt> - JsonApiServer::Include configs.
148
+ def add_include(permitted = [])
149
+ @include = JsonApiServer::Include.new(request, model, permitted)
150
+ self
151
+ end
152
+
153
+ # Creates JsonApiServer::Sort instance based on request, initial query
154
+ # model and sort options. Instance is available through
155
+ # the #sort attribute.
156
+ #
157
+ # - <tt>options</tt> - JsonApiServer::Sort options.
158
+ def add_sort(**options)
159
+ @sort = JsonApiServer::Sort.new(request, model, options)
160
+ self
161
+ end
162
+
163
+ # Creates JsonApiServer::Fields instance based on request. Instance is
164
+ # available through the #fields attribute.
165
+ def add_fields
166
+ @fields = JsonApiServer::Fields.new(request)
167
+ self
168
+ end
169
+
170
+ # JsonApiServer::Paginator instance for collection if #add_pagination
171
+ # was called previously, nil otherwise.
172
+ def paginator
173
+ @pagination.try(:paginator_for, relation)
174
+ end
175
+
176
+ # (Array or nil) Whitelisted includes. An array of relationships (strings)
177
+ # if #add_include was called previously, nil otherwise.
178
+ # i.e,
179
+ # ['comments', 'comments.author']
180
+ def includes
181
+ @include.try(:includes)
182
+ end
183
+
184
+ # (Hash or nil) Sparse fields. Available if #add_fields was previously called.
185
+ # i.e.,
186
+ # {'articles => ['title', 'body', 'author'], 'people' => ['name']}
187
+ def sparse_fields
188
+ @fields.try(:sparse_fields)
189
+ end
190
+
191
+ protected
192
+
193
+ def model_from_query(query)
194
+ if query.respond_to?(:klass)
195
+ query.klass
196
+ elsif query.respond_to?(:class)
197
+ query.class
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,89 @@
1
+ require 'bigdecimal'
2
+ require 'bigdecimal/util'
3
+ require 'date_core' # DateTime
4
+
5
+ #--
6
+ # TODO: verify it works in Rails, esp in_time_zone.
7
+ #++
8
+ module JsonApiServer # :nodoc:
9
+ # Converts string params to data types.
10
+ class Cast
11
+ class << self
12
+ def to(value, type = 'String')
13
+ case type.to_s
14
+ when 'String'
15
+ apply_cast(value, :to_string)
16
+ when 'Integer'
17
+ apply_cast(value, :to_integer)
18
+ when 'Date'
19
+ apply_cast(value, :to_date)
20
+ when 'DateTime'
21
+ apply_cast(value, :to_datetime)
22
+ when 'Float'
23
+ apply_cast(value, :to_float)
24
+ when 'BigDecimal'
25
+ apply_cast(value, :to_decimal)
26
+ else
27
+ apply_cast(value, :to_string)
28
+ end
29
+ end
30
+
31
+ # Calls to_s on object.
32
+ def to_string(string)
33
+ string.to_s
34
+ end
35
+
36
+ # Calls to_i on object. Returns zero if it can't be converted.
37
+ def to_integer(string)
38
+ string.to_i
39
+ rescue
40
+ 0
41
+ end
42
+
43
+ # Calls to_f on object. Returns zero if it can't be converted.
44
+ def to_float(string)
45
+ string.to_f
46
+ rescue
47
+ 0.0
48
+ end
49
+
50
+ # Converts to BigDecimal and calls to_f on it. Returns
51
+ # zero if it can't be converted.
52
+ def to_decimal(string)
53
+ d = BigDecimal.new(string)
54
+ d.to_f
55
+ rescue
56
+ 0.0
57
+ end
58
+
59
+ # Calls Date.parse on string.
60
+ # https://ruby-doc.org/stdlib-2.4.0/libdoc/date/rdoc/Date.html#method-c-parse
61
+ def to_date(string)
62
+ Date.parse(string)
63
+ rescue
64
+ nil
65
+ end
66
+
67
+ # Calls DateTime.parse on string. If datetime responds to
68
+ # :in_time_zone[http://apidock.com/rails/v4.2.1/ActiveSupport/TimeWithZone/in_time_zone)],
69
+ # it calls it.
70
+ def to_datetime(string)
71
+ d = DateTime.parse(string)
72
+ d.respond_to?(:in_time_zone) ? d.in_time_zone : d
73
+ rescue
74
+ nil
75
+ end
76
+
77
+ protected
78
+
79
+ # If val is an array, it casts each value. Otherwise, it casts the value.
80
+ def apply_cast(val, cast_method)
81
+ if val.respond_to?(:map)
82
+ val.map { |v| send(cast_method, v) }
83
+ else
84
+ send(cast_method, val)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,99 @@
1
+ module JsonApiServer # :nodoc:
2
+ # ==== Description
3
+ #
4
+ # Configurations for the gem.
5
+ #
6
+ # - Be sure to configure :base_url which defaults to nil.
7
+ # - Logger defaults to Logger.new(STDOUT). If using Rails, configure to Rails.logger.
8
+ # - Custom builders can be added to :filter_builders.
9
+ # - Default builders can be substituted.
10
+ #
11
+ # ===== Example
12
+ #
13
+ # # config/initializers/json_api_server.rb
14
+ #
15
+ # # example of custom filter builder
16
+ # module JsonApiServer
17
+ # class MyCustomFilter < FilterBuilder
18
+ # def to_query(model)
19
+ # model.where("#{column_name} LIKE :val", val: "%#{value}%")
20
+ # end
21
+ # end
22
+ # end
23
+ #
24
+ # JsonApiServer.configure do |c|
25
+ # c.base_url = 'http://localhost:3001' # or ENV['HOSTNAME']
26
+ # c.filter_builders = c.filter_builders
27
+ # .merge({my_custom_builder: JsonApiServer::MyCustomFilter})
28
+ # c.logger = Rails.logger
29
+ # end
30
+ #
31
+ class Configuration
32
+ # Root url i.e., http://www.example.com. Used in pagination links.
33
+ attr_accessor :base_url
34
+
35
+ # Pagination option. Default maximum number of records to show per page.
36
+ # Defaults to 100.
37
+ attr_accessor :default_max_per_page
38
+
39
+ # Pagination option. Default number of records to show per page.
40
+ # Defaults to 20.
41
+ attr_accessor :default_per_page
42
+
43
+ # JSON is serialized with OJ gem. Options are defined in DEFAULT_SERIALIZER_OPTIONS.
44
+ attr_accessor :serializer_options
45
+
46
+ # Defaults to sql_like: JsonApiServer::SqlLike. If using Postgres,
47
+ # it can be replaced with pg_ilike: JsonApiServer::PgIlike.
48
+ attr_accessor :default_like_builder
49
+
50
+ # Defaults to sql_in: JsonApiServer::SqlIn. For IN (x,y,z) queries.
51
+ attr_accessor :default_in_builder
52
+
53
+ # Defaults to sql_comparison: JsonApiServer::SqlComp.
54
+ # For <,>, <=, >=, etc. queries.
55
+ attr_accessor :default_comparison_builder
56
+
57
+ # Defaults to sql_eql: JsonApiServer::SqlEql.
58
+ attr_accessor :default_builder
59
+
60
+ # Defaults to DEFAULT_FILTER_BUILDERS.
61
+ attr_accessor :filter_builders
62
+
63
+ # Defaults to Logger.new(STDOUT)
64
+ attr_accessor :logger
65
+
66
+ # Serializer options for the OJ gem.
67
+ DEFAULT_SERIALIZER_OPTIONS = {
68
+ escape_mode: :xss_safe,
69
+ time: :xmlschema,
70
+ mode: :compat
71
+ }.freeze
72
+
73
+ # Default filter builders. For generating queries based on
74
+ # on requested filters.
75
+ DEFAULT_FILTER_BUILDERS = {
76
+ sql_eql: JsonApiServer::SqlEql,
77
+ sql_comparison: JsonApiServer::SqlComp,
78
+ sql_in: JsonApiServer::SqlIn,
79
+ sql_like: JsonApiServer::SqlLike,
80
+ pg_ilike: JsonApiServer::PgIlike,
81
+ pg_jsonb_array: JsonApiServer::PgJsonbArray,
82
+ pg_jsonb_ilike_array: JsonApiServer::PgJsonbIlikeArray,
83
+ model_query: JsonApiServer::ModelQuery
84
+ }.freeze
85
+
86
+ def initialize
87
+ @base_url = nil
88
+ @default_max_per_page = 100
89
+ @default_per_page = 20
90
+ @default_like_builder = :sql_like
91
+ @default_in_builder = :sql_in
92
+ @default_comparison_builder = :sql_comparison
93
+ @default_builder = :sql_eql
94
+ @serializer_options = DEFAULT_SERIALIZER_OPTIONS
95
+ @filter_builders = DEFAULT_FILTER_BUILDERS
96
+ @logger = Logger.new(STDOUT)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,164 @@
1
+ module JsonApiServer # :nodoc:
2
+ module Controller # :nodoc:
3
+ # Handles common controller errors. Returns JsonApi errors http://jsonapi.org/format/#errors.
4
+ #
5
+ # === Usage
6
+ #
7
+ # To use, include the JsonApiServer::Controller::ErrorHandling module in your
8
+ # base API controller class:
9
+ #
10
+ # i.e.,
11
+ #
12
+ # class Api::BaseController < ApplicationController
13
+ # include JsonApiServer::Controller::ErrorHandling
14
+ # end
15
+ #
16
+ # === I18n
17
+ #
18
+ # Messages are defined in config/locales/en.yml.
19
+ #
20
+ # Customize ActiveRecord::RecordNotFound and ActiveRecord::RecordNotUnique
21
+ # messages to reference a resource by name.
22
+ #
23
+ # *Example:*
24
+ #
25
+ # # Given a sandwiches controller...
26
+ # class Api::V1::SandwichesController < Api::BaseController
27
+ # ...
28
+ # end
29
+ #
30
+ # # config/locales/en.yml
31
+ # en:
32
+ # json_api_server:
33
+ # controller:
34
+ # sandwiches:
35
+ # name: 'sandwich'
36
+ #
37
+ # # messages now become:
38
+ # # 404 => "This sandwich does not exist."
39
+ # # 409 => "This sandwich already exists."
40
+ module ErrorHandling
41
+ def self.included(base)
42
+ # Overrides of exception handling.
43
+ base.rescue_from StandardError, with: :render_500
44
+ base.rescue_from JsonApiServer::BadRequest, with: :render_400
45
+ base.rescue_from ActionController::BadRequest, with: :render_400
46
+ base.rescue_from ActiveRecord::RecordNotFound, with: :render_404
47
+ base.rescue_from ActiveRecord::RecordNotUnique, with: :render_409
48
+ base.rescue_from ActionController::RoutingError, with: :render_404
49
+ base.rescue_from ActionController::UrlGenerationError, with: :render_404
50
+ base.rescue_from ActionController::UnknownController, with: :render_404
51
+ base.rescue_from ActionController::UnknownFormat, with: :render_unknown_format
52
+ end
53
+
54
+ protected
55
+
56
+ # Render 400 json and status.
57
+ def render_400(exception = nil)
58
+ message = (exception && known?(exception) && exception.message) || I18n.t('json_api_server.render_400.detail')
59
+ errors = JsonApiServer.errors(
60
+ status: 400,
61
+ title: I18n.t('json_api_server.render_400.title'),
62
+ detail: message
63
+ )
64
+ render json: errors.to_json, status: 400
65
+ end
66
+
67
+ # Render 401 json and status.
68
+ def render_401
69
+ errors = JsonApiServer.errors(
70
+ status: 401,
71
+ title: I18n.t('json_api_server.render_401.title'),
72
+ detail: I18n.t('json_api_server.render_401.detail')
73
+ )
74
+ render json: errors.to_json, status: 401
75
+ end
76
+
77
+ # Render 403 json and status.
78
+ def render_403
79
+ errors = JsonApiServer.errors(
80
+ status: 403,
81
+ title: I18n.t('json_api_server.render_403.title'),
82
+ detail: I18n.t('json_api_server.render_403.detail')
83
+ )
84
+ render json: errors.to_json, status: 403
85
+ end
86
+
87
+ # Render 404 json and status. Message customizable (see class description).
88
+ def render_404(_exception = nil)
89
+ errors = JsonApiServer.errors(
90
+ status: 404,
91
+ title: I18n.t('json_api_server.render_404.title'),
92
+ detail: I18n.t('json_api_server.render_404.detail', name: _i18n_name)
93
+ )
94
+ render json: errors.to_json, status: 404
95
+ end
96
+
97
+ # Render 409 json and status. Message customizable (see class description).
98
+ def render_409(_exception = nil)
99
+ errors = JsonApiServer.errors(
100
+ status: 409,
101
+ title: I18n.t('json_api_server.render_409.title'),
102
+ detail: I18n.t('json_api_server.render_409.detail', name: _i18n_name)
103
+ )
104
+ render json: errors.to_json, status: 409
105
+ end
106
+
107
+ # Render 422 json and status. For model validation error.
108
+ def render_422(object)
109
+ errors = JsonApiServer.validation_errors(object)
110
+ render json: errors.to_json, status: 422
111
+ end
112
+
113
+ # Render 500 json. Logs exception.
114
+ def render_500(exception = nil)
115
+ JsonApiServer.logger.error(exception.try(:message))
116
+ JsonApiServer.logger.error(exception.try(:backtrace))
117
+
118
+ errors = JsonApiServer.errors(
119
+ status: 500,
120
+ title: I18n.t('json_api_server.render_500.title'),
121
+ detail: I18n.t('json_api_server.render_500.detail')
122
+ )
123
+ render json: errors.to_json, status: 500
124
+ end
125
+
126
+ # Render 406 status code and message that the format is not supported.
127
+ def render_unknown_format
128
+ format = sanitize(params[:format]) || ''
129
+ errors = JsonApiServer.errors(
130
+ status: 406,
131
+ title: I18n.t('json_api_server.render_unknown_format.title'),
132
+ detail: I18n.t('json_api_server.render_unknown_format.detail', name: format)
133
+ )
134
+ render json: errors.to_json, status: 406
135
+ end
136
+
137
+ # Render 503 json and status (service unavailable).
138
+ def render_503(message = nil)
139
+ errors = JsonApiServer.errors(
140
+ status: 500,
141
+ title: I18n.t('json_api_server.render_503.title'),
142
+ detail: message || I18n.t('json_api_server.render_503.detail')
143
+ )
144
+ render json: errors.to_json, status: 503
145
+ end
146
+
147
+ private
148
+
149
+ def known?(exception)
150
+ !(exception.class.name =~ /JsonApiServer::BadRequest/).nil?
151
+ end
152
+
153
+ def sanitize(string)
154
+ ActionController::Base.helpers.sanitize(string.to_s)
155
+ end
156
+
157
+ def _i18n_name
158
+ I18n.t("json_api_server.controller.#{controller_name}.name", raise: true)
159
+ rescue
160
+ I18n.t('json_api_server.variables.defaults.name')
161
+ end
162
+ end
163
+ end
164
+ end