json_api_server 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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