model-api 0.8.3

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.
@@ -0,0 +1,32 @@
1
+ module ModelApi
2
+ class BypassParseMiddleware
3
+ def initialize(app)
4
+ @app = app
5
+ @api_roots = nil
6
+ end
7
+
8
+ def call(env)
9
+ request = ActionDispatch::Request.new(env)
10
+ unless @api_roots.present?
11
+ options = Rails.application.config.class.class_variable_get(:@@options)
12
+ options ||= {}
13
+ @api_roots = options[:api_middleware_root_paths] || ['api']
14
+ @api_roots = [@api_roots] unless @api_roots.is_a?(Array)
15
+ @api_roots = @api_roots.map { |path| path.starts_with?('/') ? path : "/#{path}" }
16
+ end
17
+ @api_roots.each do |path|
18
+ next unless env['REQUEST_PATH'].to_s.starts_with?(path)
19
+ api_format = nil
20
+ if request.content_type.to_s.downcase.ends_with?('json')
21
+ api_format = :json
22
+ elsif request.content_type.to_s.downcase.ends_with?('xml')
23
+ api_format = :xml
24
+ end
25
+ env['action_dispatch.request.content_type'] = 'application/x-api'
26
+ env['API_CONTENT_TYPE'] = api_format
27
+ break
28
+ end
29
+ @app.call(env)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,48 @@
1
+ module ModelApi
2
+ class HashMetadata
3
+ class << self
4
+ def process_metadata(type, obj, args)
5
+ type = type.to_sym
6
+ instance_var = :"@api_#{type}_metadata"
7
+ metadata = obj.instance_variable_get(instance_var) || {}
8
+ if args.present?
9
+ if args.size == 1 && args[0].is_a?(Hash)
10
+ new_metadata = args[0].symbolize_keys
11
+ elsif args.size == 1 && args[0].is_a?(Array)
12
+ new_metadata = Hash[args[0].map { |key| [key.to_sym, {}] }]
13
+ else
14
+ new_metadata = Hash[args.map { |key| [key.to_sym, {}] }]
15
+ end
16
+ new_metadata.symbolize_keys.each do |key, item_metadata|
17
+ if (existing_item_metadata = metadata[key]).is_a?(Hash)
18
+ existing_item_metadata.merge!(item_metadata)
19
+ else
20
+ item_metadata[:key] = key
21
+ metadata[key] = item_metadata
22
+ end
23
+ end
24
+ obj.instance_variable_set(instance_var, metadata)
25
+ end
26
+ metadata.dup
27
+ end
28
+
29
+ def merge_superclass_metadata(type, sc, metadata)
30
+ metadata_method = :"api_#{type}"
31
+ return metadata if sc == ActiveRecord::Base || !sc.respond_to?(metadata_method)
32
+ superclass_metadata = sc.send(metadata_method)
33
+ merged_metadata = {}
34
+ superclass_metadata.each do |item, item_metadata|
35
+ merged_metadata[item] = item_metadata.dup
36
+ end
37
+ metadata.each do |key, item_metadata|
38
+ if (existing_item_metadata = merged_metadata[key]).is_a?(Hash)
39
+ existing_item_metadata.merge!(item_metadata)
40
+ else
41
+ merged_metadata[key] = item_metadata
42
+ end
43
+ end
44
+ merged_metadata
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,49 @@
1
+ module ModelApi
2
+ module Model
3
+ module ClassMethods
4
+ def api_model(*args)
5
+ metadata = ModelApi::SimpleMetadata.process_metadata(:model, self, args)
6
+ ModelApi::SimpleMetadata.merge_superclass_metadata(:model, superclass, metadata,
7
+ exclude_keys: [:alias])
8
+ end
9
+
10
+ def api_attributes(*args)
11
+ metadata = ModelApi::HashMetadata.process_metadata(:attributes, self, args)
12
+ metadata = ModelApi::HashMetadata.merge_superclass_metadata(:attributes, superclass, metadata)
13
+ if args.present?
14
+ id_attrs = []
15
+ metadata.each { |attr, attr_metadata| id_attrs << attr if attr_metadata[:id] }
16
+ if id_attrs.present?
17
+ id_attrs = id_attrs.map(&:to_sym)
18
+ existing_id_attrs = (api_model[:id_attributes] || []).map(&:to_sym)
19
+ if (id_attrs - existing_id_attrs).present?
20
+ api_model id_attributes: (id_attrs - existing_id_attrs).uniq
21
+ end
22
+ end
23
+ end
24
+ if self < ActiveRecord::Base && (args.present? ||
25
+ !self.instance_variable_get(:@api_attrs_characterized))
26
+ metadata.each do |attr, attr_metadata|
27
+ if (assoc = self.reflect_on_association(attr)).present?
28
+ attr_metadata[:type] = :association
29
+ attr_metadata[:association] = assoc
30
+ else
31
+ attr_metadata[:type] = :attribute
32
+ end
33
+ end
34
+ self.instance_variable_set(:@api_attrs_characterized, true)
35
+ end
36
+ metadata
37
+ end
38
+
39
+ def api_links(*args)
40
+ metadata = ModelApi::HashMetadata.process_metadata(:links, self, args)
41
+ ModelApi::HashMetadata.merge_superclass_metadata(:links, superclass, metadata)
42
+ end
43
+ end
44
+
45
+ def self.included(base)
46
+ base.extend(ClassMethods)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,10 @@
1
+ module ModelApi
2
+ class NotFoundException < Exception
3
+ attr_reader :field
4
+
5
+ def initialize(field = nil, message = nil)
6
+ super(message)
7
+ @field = field
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,287 @@
1
+ module ModelApi
2
+ module OpenApiExtensions
3
+ module ClassMethods
4
+ def add_open_api_action(action, operation, opts = {})
5
+ return unless respond_to?(:open_api_action) # Must have open_api gem installed
6
+ opts = opts.merge(action: action, operation: operation)
7
+ if ENV['ADMIN'].present? && ENV['ADMIN'].to_s != '0'
8
+ opts[:admin_content] = true
9
+ elsif opts[:admin_only]
10
+ open_api_action action, hidden: :true
11
+ return
12
+ end
13
+ open_api_spec = {}
14
+ open_api_spec[:description] = opts[:description] if opts.include?(:description)
15
+ response_class = opts[:response] || model_class
16
+ if operation == :index || opts[:collection]
17
+ response = Utils.define_api_collection_response(self, response_class, opts)
18
+ open_api_spec[:query_string] = Utils.filter_and_sort_params(self, response_class, opts)
19
+ else
20
+ response = Utils.define_api_response(self, response_class,
21
+ opts.merge(operation: :show))
22
+ end
23
+ open_api_spec[:responses] = { 200 => { schema: response } } if response.present?
24
+ if [:create, :update, :patch].include?(operation)
25
+ payload = opts[:payload] || model_class
26
+ if payload.present?
27
+ open_api_spec[:body] = { description: 'Payload', schema: Utils.define_open_api_object(
28
+ self, payload, opts.merge(object_context: :payload)) }
29
+ end
30
+ end
31
+ open_api_action action, open_api_spec
32
+ end
33
+ end
34
+
35
+ def self.included(base)
36
+ base.extend(ClassMethods)
37
+ end
38
+
39
+ class Utils
40
+ class << self
41
+ def define_api_response(controller_class, model_class, opts = {})
42
+ object_name = define_open_api_object(controller_class, model_class, opts)
43
+ return nil unless object_name.present?
44
+ wrapper_object_name = "#{object_name}:response"
45
+ response_object_metadata = { type: :object, required: true }
46
+ response_object_metadata[:'$ref'] = object_name if object_name.present?
47
+ controller_class.send(:open_api_object, wrapper_object_name,
48
+ successful: { type: :boolean, required: true,
49
+ description: 'Returns true if successfully processed; otherwise false' },
50
+ status: { type: :string, required: true, description: 'HTTP status' },
51
+ statusCode: { type: :integer, required: true,
52
+ description: 'Numeric HTTP status code' },
53
+ ModelApi::Utils.ext_attr(opts[:root] || ModelApi::Utils.model_name(model_class).singular) =>
54
+ response_object_metadata)
55
+ wrapper_object_name
56
+ end
57
+
58
+ def filter_and_sort_params(controller_class, model_class, opts = {})
59
+ params = opts[:parameters] || {}
60
+ opts = opts.merge(attr_types: attr_types_from_columns(model_class))
61
+ attr_prefix = opts[:attr_prefix]
62
+ sort_attrs = []
63
+
64
+ filter_metadata = ModelApi::Utils.filtered_ext_attrs(model_class, :filter, opts)
65
+ filter_metadata.each do |attr, attr_metadata|
66
+ if attr_metadata[:type] == :association
67
+ next unless (assoc = attr_metadata[:association]).present? &&
68
+ assoc.respond_to?(:klass)
69
+ assoc_params = filter_and_sort_params(controller_class, assoc.klass,
70
+ opts.merge(attr_prefix: "#{attr}."))
71
+ sort_attrs += assoc_params.delete(:sort_by) || []
72
+ assoc_params.each { |assoc_attr, property_hash| params[assoc_attr] ||= property_hash }
73
+ else
74
+ property_hash = open_api_attr_hash(controller_class, model_class, attr, attr_metadata,
75
+ opts)
76
+ property_hash.merge!(
77
+ description: filter_description(attr_prefix, attr, property_hash, attr_metadata),
78
+ required: false)
79
+ next if property_hash.include?(:'$ref')
80
+ params[:"#{attr_prefix}#{attr}"] ||= property_hash
81
+ end
82
+ end
83
+
84
+ add_sort_params(params, model_class, sort_attrs, opts)
85
+ end
86
+
87
+ def add_sort_params(params, model_class, addl_sort_attrs, opts)
88
+ attr_prefix = opts[:attr_prefix]
89
+ if (sort_metadata = ModelApi::Utils.filtered_ext_attrs(model_class, :sort, opts)).present?
90
+ if attr_prefix.present?
91
+ params[:sort_by] = sort_metadata.keys.sort.map { |k| :"#{attr_prefix}#{k}" }
92
+ else
93
+ addl_sort_attrs = (sort_metadata.keys + addl_sort_attrs).compact.sort
94
+ params[:sort_by] = { type: :string,
95
+ description: "Sortable fields: #{addl_sort_attrs.join(', ')} " \
96
+ '(optionally append " asc" or " desc" on field(s) to indicate sort order)' }
97
+ end
98
+ end
99
+ params
100
+ end
101
+
102
+ def filter_description(attr_prefix, attr, property_hash, attr_metadata)
103
+ return attr_metadata[:filter_description] if attr_metadata[:filter_description].present?
104
+ desc = "Filter by #{attr_prefix}#{attr}"
105
+ case property_hash[:type]
106
+ when :string
107
+ case property_hash[:format]
108
+ when :date, :'date-time'
109
+ "#{desc} (supports <, <=, !=, >= > operator prefixes, comma-delimited criteria)"
110
+ else
111
+ "#{desc} (supports comma-delimited values)"
112
+ end
113
+ when :boolean
114
+ "#{desc} (must be true or false)"
115
+ when :integer, :number
116
+ "#{desc} (supports comma-delimited values, <, <=, !=, >= > operator prefixes)"
117
+ else
118
+ desc
119
+ end
120
+ end
121
+
122
+ def define_api_collection_response(controller_class, model_class, opts = {})
123
+ object_name = define_open_api_object(controller_class, model_class, opts)
124
+ return nil unless object_name.present?
125
+ wrapper_object_name = "#{object_name}:response"
126
+ array_metadata = { type: :array, required: true }
127
+ array_metadata[:'$ref'] = object_name if object_name.present?
128
+ controller_class.send(:open_api_object, wrapper_object_name,
129
+ successful: { type: :boolean, required: true,
130
+ description: 'Returns true if successfully processed; otherwise false' },
131
+ status: { type: :string, description: 'HTTP status', required: true },
132
+ statusCode: { type: :integer, description: 'Numeric HTTP status code',
133
+ required: true },
134
+ ModelApi::Utils.model_name(model_class).plural => array_metadata,
135
+ count: { type: :integer, description: 'Total items available', required: true },
136
+ page: { type: :integer, description: 'Index (1-based) of page returned',
137
+ required: true },
138
+ pageCount: { type: :integer, description: 'Total number of pages available',
139
+ required: true },
140
+ pageSize: { type: :integer, description: 'Maximum item count per page returned',
141
+ required: true })
142
+ wrapper_object_name
143
+ end
144
+
145
+ def define_open_api_object(controller_class, define_open_api_object, opts = {})
146
+ define_open_api_objects(controller_class, define_open_api_object, opts).first
147
+ end
148
+
149
+ def define_open_api_objects(controller_class, *define_open_api_objects)
150
+ if define_open_api_objects.size > 1 && (opts = define_open_api_objects.last).is_a?(Hash)
151
+ define_open_api_objects = define_open_api_objects[0..-2]
152
+ else
153
+ opts = {}
154
+ end
155
+ object_names = []
156
+ define_open_api_objects.compact.uniq.each do |model_class|
157
+ object_names << define_open_api_object_from_model(controller_class, model_class, opts)
158
+ end
159
+ object_names.compact
160
+ end
161
+
162
+ def define_open_api_object_from_model(controller_class, model_class, opts = {})
163
+ operation = opts[:operation] || :show
164
+ metadata_opts = opts.merge(ModelApi::Utils.contextual_metadata_opts(opts))
165
+ metadata = ModelApi::Utils.filtered_attrs(model_class, operation, metadata_opts)
166
+ return nil unless metadata.present?
167
+
168
+ action = (opts = opts.dup).delete(:action) # Prevent inheritance
169
+
170
+ object_name = ModelApi::Utils.model_name(model_class).name
171
+ if (parent_object_context = opts[:object_context]).present?
172
+ object_name = "#{parent_object_context}|#{object_name}"
173
+ end
174
+ object_name = "#{object_name}|#{action}" if action.present?
175
+
176
+ @open_api_views_processed ||= {}
177
+ return object_name if @open_api_views_processed[object_name]
178
+ @open_api_views_processed[object_name] = model_class
179
+
180
+ class_model_base(metadata, controller_class, model_class,
181
+ opts.merge(object_context: object_name))
182
+ object_name
183
+ end
184
+
185
+ def class_model_base(metadata, controller_class, model_class, opts)
186
+ properties = {}
187
+ opts = opts.merge(attr_types: attr_types_from_columns(model_class))
188
+ metadata.each do |attr, attr_metadata|
189
+ properties[ModelApi::Utils.ext_attr(attr, attr_metadata)] = open_api_attr_hash(
190
+ controller_class, model_class, attr, attr_metadata, opts)
191
+ end
192
+ controller_class.send(:open_api_object, opts[:object_context].to_sym, properties)
193
+ end
194
+
195
+ # rubocop:disable Metrics/ParameterLists
196
+ def open_api_attr_hash(controller_class, model_class, attr, attr_metadata, opts)
197
+ property_hash = class_model_base_property(model_class, attr, attr_metadata)
198
+ if (attr_type = attr_metadata[:type]).is_a?(Symbol) &&
199
+ ![:attribute, :association].include?(attr_type)
200
+ ModelApi::Utils.set_open_api_type_and_format(property_hash, attr_type)
201
+ else
202
+ attr_types = opts[:attr_types] || attr_types_from_columns(model_class)
203
+ if (attr_type = attr_types[attr_metadata[:key]]).present?
204
+ ModelApi::Utils.set_open_api_type_and_format(property_hash, attr_type)
205
+ elsif (assoc = model_class.reflect_on_association(attr)).present?
206
+ property_hash = class_model_assoc_property(property_hash, controller_class,
207
+ attr_metadata, opts.merge(association: assoc))
208
+ end
209
+ end
210
+ property_hash[:type] ||= :string unless property_hash.include?(:'$ref')
211
+ property_hash
212
+ end
213
+
214
+ # rubocop:enable Metrics/ParameterLists
215
+
216
+ def class_model_base_property(model_class, attr, attr_metadata)
217
+ if (required = attr_metadata[:required]).nil?
218
+ required_attrs ||= required_attrs_from_validators(model_class)
219
+ required = required_attrs.include?(attr)
220
+ end
221
+ property_hash = { required: required ? true : false }
222
+ if (description = attr_metadata[:description]).present?
223
+ property_hash[:description] = description
224
+ end
225
+ property_hash
226
+ end
227
+
228
+ def class_model_assoc_property(property_hash, controller_class, attr_metadata, opts)
229
+ assoc = opts[:association]
230
+ return property_hash unless assoc.present?
231
+ assoc_class = assoc.class_name.constantize
232
+ assoc_opts = ModelApi::Utils.assoc_opts(assoc, attr_metadata, opts)
233
+ assoc_opts = assoc_opts.reject do |k, _v|
234
+ [:result, :collection_result].include?(k)
235
+ end
236
+ assoc_model = define_open_api_object(controller_class, assoc_class, assoc_opts)
237
+ if assoc.collection?
238
+ property_hash[:type] = :array
239
+ property_hash[:items] = { :'$ref' => assoc_model } if assoc_model.present?
240
+ else
241
+ property_hash[:'$ref'] = assoc_model if assoc_model.present?
242
+ end
243
+ property_hash
244
+ end
245
+
246
+ def attr_types_from_columns(model_class)
247
+ Hash[model_class.columns.map { |col| [col.name.to_sym, col.type] }]
248
+ end
249
+
250
+ def required_attrs_from_validators(model_class)
251
+ model_class.validators
252
+ .select { |v| v.is_a?(ActiveRecord::Validations::PresenceValidator) }
253
+ .map(&:attributes)
254
+ .flatten
255
+ end
256
+
257
+ # def add_open_api_view_params(parameters, operation, _opts = {})
258
+ # parameters = {}
259
+ # if operation == :index
260
+ # api.param :query, :page, :integer, :optional,
261
+ # 'Index (1-based) of the result set page to return'
262
+ # api.param :query, :page_size, :integer, :optional,
263
+ # 'Number of records to return per page (cannot exceed 1000)'
264
+ # end
265
+ # if [:index, :show].include?(operation)
266
+ # api.param :query, :fields, :array, :optional, 'Field(s) to include ' \
267
+ # 'in the response', 'items' => { 'type' => 'string' }
268
+ # end
269
+ # end
270
+ #
271
+ # def add_open_api_common_errors(api, operation, _opts = {})
272
+ # if [:show, :update, :delete].include?(operation)
273
+ # api.response :not_found, 'No entity found matching the ID provided'
274
+ # end
275
+ # api.response :unauthorized,
276
+ # 'Missing a valid access token (access_token parameter or X-Access-Token header)'
277
+ # api.response :bad_request, 'One or more malformed / invalid ' \
278
+ # 'parameters identified for the request'
279
+ # api.param :query, :access_token, :string, :optional, 'Access token ' \
280
+ # 'used to authenticate client'
281
+ # api.param :header, :'X-Access-Token', :string, :optional,
282
+ # 'Access token used to authenticate client'
283
+ # end
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,504 @@
1
+ require 'rexml/document'
2
+
3
+ module ModelApi
4
+ module Renderer
5
+ class << self
6
+ def render(controller, response_obj, opts = {})
7
+ opts = opts.symbolize_keys
8
+ format = (opts[:format] ||= get_format(controller))
9
+ opts[:action] ||= controller.action_name.to_sym
10
+ if format == :xml
11
+ render_xml_response(response_obj, controller, opts)
12
+ else
13
+ render_json_response(response_obj, controller, opts)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def serializable_object(obj, opts = {})
20
+ obj = obj.first if obj.is_a?(ActiveRecord::Relation)
21
+ opts = opts.symbolize_keys
22
+ return nil if obj.nil?
23
+ operation = opts[:operation] || :show
24
+ metadata_opts = opts.merge(ModelApi::Utils.contextual_metadata_opts(opts))
25
+ metadata = ModelApi::Utils.filtered_attrs(obj, operation, metadata_opts)
26
+ render_values, render_assoc = attrs_by_type(obj, metadata)
27
+ hash = {}
28
+ serialize_values(hash, obj, render_values, opts)
29
+ serialize_associations(hash, obj, render_assoc, opts)
30
+ process_serializable_hash(metadata, hash, opts)
31
+ end
32
+
33
+ def attrs_by_type(obj, metadata)
34
+ render_values = []
35
+ render_assoc = []
36
+ metadata.each do |attr, attr_metadata|
37
+ if (value = attr_metadata[:value]).present?
38
+ render_values << [attr, value, attr_metadata]
39
+ elsif obj.respond_to?(attr.to_s)
40
+ render_values << [attr, obj.send(attr.to_sym), attr_metadata]
41
+ elsif (assoc = obj.class.reflect_on_association(attr)).present?
42
+ render_assoc << [assoc, attr_metadata]
43
+ elsif obj.is_a?(ActiveRecord::Base)
44
+ fail "Invalid API attribute for #{obj.class.model_name.human} instance: #{attr}"
45
+ else
46
+ fail "Invalid API attribute for #{obj.class.name} instance: #{attr}"
47
+ end
48
+ end
49
+ [render_values, render_assoc]
50
+ end
51
+
52
+ def serialize_values(hash, obj, value_procs, opts = {})
53
+ value_procs.each do |attr, value, attr_metadata|
54
+ if value.respond_to?(:call) && value.respond_to?(:parameters)
55
+ proc_opts = opts.merge(attr: attr, attr_metadata: attr_metadata)
56
+ value = value.send(*([:call, obj, proc_opts][0..value.parameters.size]))
57
+ end
58
+ hash[attr] = serialize_value(value, attr_metadata, opts)
59
+ end
60
+ end
61
+
62
+ def serialize_associations(hash, obj, associations, opts = {})
63
+ associations.each do |assoc, attr_metadata|
64
+ return nil if assoc.nil?
65
+ assoc_opts = ModelApi::Utils.assoc_opts(assoc, attr_metadata, opts)
66
+ next if assoc_opts.nil?
67
+ attr = assoc.name
68
+ assoc = assoc_opts[:association]
69
+ render_proc ||= ->(o) { serialize_value(o, attr_metadata, assoc_opts) }
70
+ if !assoc.nil? && assoc.collection?
71
+ value = obj.send(attr).map { |o| render_proc.call(o) }
72
+ else
73
+ value = render_proc.call(obj.send(attr))
74
+ end
75
+ hash[attr] = value
76
+ end
77
+ end
78
+
79
+ def serialize_value(value, attr_metadata, opts)
80
+ if (render_method = attr_metadata[:render_method]).present?
81
+ if render_method.respond_to?(:call)
82
+ value = serialize_value_proc(render_method, value)
83
+ else
84
+ value = serialize_value_obj_attr(render_method, value)
85
+ end
86
+ end
87
+ opts = ModelApi::Utils.contextual_metadata_opts(attr_metadata, opts)
88
+ opts[:operation] = :show
89
+ if value.respond_to?(:map)
90
+ return value.map do |elem|
91
+ elem.is_a?(ActiveRecord::Base) ? serializable_object(elem, opts) : elem
92
+ end
93
+ elsif value.is_a?(ActiveRecord::Base)
94
+ return serializable_object(value, opts)
95
+ end
96
+ value
97
+ end
98
+
99
+ def serialize_value_proc(render_method, value)
100
+ if render_method.parameters.count > 1
101
+ render_method.call(value, opts)
102
+ else
103
+ render_method.call(value)
104
+ end
105
+ end
106
+
107
+ def serialize_value_obj_attr(render_method, value)
108
+ render_method = render_method.to_s.to_sym
109
+ if value.is_a?(ActiveRecord::Associations::CollectionProxy) || value.is_a?(Array)
110
+ (value.map do |obj|
111
+ obj.respond_to?(render_method) ? obj.send(render_method) : nil
112
+ end).compact
113
+ elsif value.respond_to?(render_method)
114
+ value.send(render_method)
115
+ end
116
+ end
117
+
118
+ def process_serializable_hash(api_attrs_metadata, hash, opts)
119
+ updated_hash_array = (hash.map do |key, value|
120
+ attr_metadata = api_attrs_metadata[key.to_sym] || {}
121
+ value = ModelApi::Utils.format_value(value, attr_metadata, opts)
122
+ next nil if value.nil? && attr_metadata[:hide_when_nil]
123
+ [ModelApi::Utils.ext_attr(key, attr_metadata).to_sym, value]
124
+ end).compact
125
+ api_attrs = api_attrs_metadata.map { |k, m| ModelApi::Utils.ext_attr(k, m).to_sym }
126
+ updated_hash_array.sort_by! do |key, _value|
127
+ api_attrs.find_index(key.to_s.to_sym) || api_attrs.size
128
+ end
129
+ Hash[updated_hash_array]
130
+ end
131
+
132
+ def get_format(controller)
133
+ format = controller.request.format.symbol rescue :json
134
+ format == :xml ? :xml : :json
135
+ end
136
+
137
+ def get_object_root_elem(obj, opts)
138
+ if (root_elem = opts[:root]).present?
139
+ return root_elem
140
+ end
141
+ if obj.respond_to?(:klass)
142
+ item_class = obj.klass
143
+ elsif obj.nil?
144
+ item_class = Object
145
+ else
146
+ item_class = obj.class
147
+ end
148
+ return 'response' if item_class == Hash
149
+ ModelApi::Utils.model_name(item_class).singular
150
+ end
151
+
152
+ def get_collection_root_elem(collection, opts)
153
+ if (root_elem = opts[:root]).present?
154
+ return root_elem
155
+ end
156
+ if collection.respond_to?(:klass)
157
+ item_class = collection.klass
158
+ elsif collection.respond_to?(:first) && (first_obj = collection.first).present?
159
+ item_class = first_obj.class
160
+ else
161
+ item_class = Object
162
+ end
163
+ ModelApi::Utils.model_name(item_class).plural
164
+ end
165
+
166
+ def hateoas_link_xml(links, _opts = {})
167
+ '<_links>' +
168
+ links.map do |link|
169
+ '<link' + link.map { |k, v| " #{k}=\"#{CGI.escapeHTML(v)}\"" }.join + ' />'
170
+ end.join +
171
+ '</_links>'
172
+ end
173
+
174
+ def hateoas_pagination_values_json(count, page, page_count, page_size)
175
+ json = []
176
+ if count.present?
177
+ json << ",\"#{ModelApi::Utils.ext_attr(:count)}\":#{[count.to_i, 0].max}"
178
+ end
179
+ json << ",\"#{ModelApi::Utils.ext_attr(:page)}\":#{[page.to_i, 0].max}" if page.present?
180
+ if page_count.present?
181
+ json << ",\"#{ModelApi::Utils.ext_attr(:page_count)}\":#{[page_count.to_i, 0].max}"
182
+ end
183
+ if page_size.present?
184
+ json << ",\"#{ModelApi::Utils.ext_attr(:page_size)}\":#{[page_size.to_i, 0].max}"
185
+ end
186
+ json.join
187
+ end
188
+
189
+ def hateoas_pagination_values_xml(count, page, page_count, page_size)
190
+ xml = []
191
+ count_attr = ModelApi::Utils.ext_attr(:count)
192
+ page_attr = ModelApi::Utils.ext_attr(:page)
193
+ page_count_attr = ModelApi::Utils.ext_attr(:page_count)
194
+ page_size_attr = ModelApi::Utils.ext_attr(:page_size)
195
+ xml << "<#{count_attr}>#{[count.to_i, 0].max}</#{count_attr}>" if count.present?
196
+ xml << "<#{page_attr}>#{[page.to_i, 0].max}</#{page_attr}>" if page.present?
197
+ if page_count.present?
198
+ xml << "<#{page_count_attr}>#{[page_count.to_i, 0].max}</#{page_count_attr}>"
199
+ end
200
+ if page_size.present?
201
+ xml << "<#{page_size_attr}>#{[page_size.to_i, 0].max}</#{page_size_attr}>"
202
+ end
203
+ xml.join
204
+ end
205
+
206
+ def object_hateoas_links(object_links, obj, controller, opts = {})
207
+ return {} if obj.blank?
208
+ custom_links = ModelApi::Utils.filtered_links(obj, opts[:operation], opts)
209
+ links = (object_links || {}).merge(custom_links).map do |rel, route|
210
+ next { rel: rel.to_s, href: route.to_s } if route.is_a?(URI)
211
+ if route.is_a?(Hash)
212
+ link_opts = opts.merge(route)
213
+ route = link_opts.delete(:route)
214
+ next nil if route.blank?
215
+ else
216
+ link_opts = opts
217
+ end
218
+ next { rel: rel.to_s, href: route.to_s } if route.is_a?(URI)
219
+ route_args = build_object_hateoas_route_args(obj, controller, route, link_opts)
220
+ if route_args[0].respond_to?(:url_for)
221
+ next { rel: rel.to_s, href: route_args[0].url_for(*route_args.slice(1..-1)) }
222
+ elsif !controller.respond_to?(route_args[0].to_s.to_sym)
223
+ route_args[0] = :"#{route_args[0]}_url"
224
+ next nil unless controller.respond_to?(route_args[0])
225
+ end
226
+ begin
227
+ param_count = controller.method(route_args[0]).parameters.size
228
+ { rel: rel.to_s, href: controller.send(*(route_args[0..param_count])) }
229
+ rescue Exception => e
230
+ Rails.logger.warn "Error encountered generating \"#{rel}\" " \
231
+ "link (\"#{e.message}\") for #{obj.class.name}: " \
232
+ "#{(obj.respond_to?(:guid) ? obj.guid : obj.id)}"
233
+ nil
234
+ end
235
+ end
236
+ links.compact
237
+ end
238
+
239
+ def build_object_hateoas_route_args(obj, controller, route, opts = {})
240
+ id_attr = opts[:id_attribute] || :id
241
+ link_opts = (opts[:object_link_options] || {}).dup
242
+ format = controller.request.format.symbol rescue nil
243
+ link_opts[:format] ||= format if format.present?
244
+ if route.is_a?(Array)
245
+ if route.size > 1 && (route_opts = route.last).is_a?(Hash)
246
+ route.first(route.size - 1).append(link_opts.merge(route_opts))
247
+ else
248
+ route + [object.send(id_attr), link_opts]
249
+ end
250
+ else
251
+ id = obj.is_a?(Hash) ? obj[id_attr] : obj.send(id_attr)
252
+ path_params = Rails.application.routes.routes.named_routes[route.to_s].parts rescue []
253
+ path_params.each do |param|
254
+ param = param.to_sym
255
+ next if link_opts.include?(param)
256
+ value = controller.params[param]
257
+ link_opts[param] = value if value.present?
258
+ end
259
+ [route, { (opts[:id_param] || :id) => id }.merge(link_opts)]
260
+ end
261
+ end
262
+
263
+ def hateoas_links(links, common_link_opts, controller, opts = {})
264
+ links = (links || {}).map do |rel, route|
265
+ if route.is_a?(Hash)
266
+ link_opts = route.merge(common_link_opts)
267
+ route = link_opts.delete(:route)
268
+ else
269
+ link_opts = common_link_opts
270
+ end
271
+ next { rel: rel.to_s, href: route.to_s } if route.is_a?(URI)
272
+ route_args = build_hateoas_route_args(controller, route, link_opts, opts)
273
+ if route_args[0].respond_to?(:url_for)
274
+ next { rel: rel.to_s, href: route_args[0].url_for(*route_args.slice(1..-1)) }
275
+ elsif !controller.respond_to?(route_args[0])
276
+ route_args[0] = :"#{route_args[0]}_url"
277
+ next nil unless controller.respond_to?(route_args[0])
278
+ end
279
+ begin
280
+ { rel: rel.to_s, href: controller.send(*route_args) }
281
+ rescue Exception => e
282
+ Rails.logger.warn "Error encountered generating \"#{rel}\" link " \
283
+ "(\"#{e.message}\") for collection."
284
+ nil
285
+ end
286
+ end
287
+ links.compact
288
+ end
289
+
290
+ def build_hateoas_route_args(controller, route, link_opts, _opts = {})
291
+ link_opts ||= {}
292
+ format = controller.request.format.symbol rescue nil
293
+ link_opts[:format] ||= format if format.present?
294
+ if route.is_a?(Array)
295
+ if route.size > 1 && (route_opts = route.last).is_a?(Hash)
296
+ route.first(route.size - 1).append(link_opts.merge(route_opts))
297
+ else
298
+ route.append(link_opts)
299
+ end
300
+ else
301
+ [route, link_opts]
302
+ end
303
+ end
304
+
305
+ def hateoas_object(obj, controller, format, opts = {})
306
+ object_links = object_hateoas_links(opts[:object_links], obj, controller, opts)
307
+ hash = serializable_object(obj, opts)
308
+ if format == :xml
309
+ root_tag = ModelApi::Utils.ext_attr(opts[:root] || get_object_root_elem(obj, opts) || :obj)
310
+ end_tag = "</#{root_tag}>"
311
+ if object_links.present?
312
+ pretty_xml(hash
313
+ .to_xml(opts.merge(root: root_tag, skip_instruct: true))
314
+ .sub(Regexp.new('(' + Regexp.escape(end_tag) + ')\\w*\\Z'),
315
+ hateoas_link_xml(object_links, opts) + end_tag))
316
+ else
317
+ pretty_xml(hash.to_xml(opts.merge(root: root_tag, skip_instruct: true)))
318
+ end
319
+ else
320
+ hash[:_links] = object_links
321
+ hash.to_json(opts)
322
+ end
323
+ end
324
+
325
+ def hateoas_collection(collection, controller, format, opts = {})
326
+ opts = (opts || {}).symbolize_keys
327
+ count = opts[:count]
328
+ page = opts[:page]
329
+ page_count = opts[:page_count]
330
+ page_size = opts[:page_size]
331
+ root_tag = ModelApi::Utils.ext_attr(opts[:root] || get_collection_root_elem(collection, opts) ||
332
+ :objects)
333
+ if format == :xml
334
+ children_tag = opts.delete(:children) || root_tag.to_s.singularize
335
+ response_xml = []
336
+ response_xml << "<#{root_tag}>"
337
+ collection.each do |obj|
338
+ response_xml << hateoas_object(obj, controller, format, opts.merge(root: children_tag))
339
+ end
340
+ response_xml << "</#{root_tag}>"
341
+ response_xml << hateoas_pagination_values_xml(count, page, page_count, page_size)
342
+ pretty_xml(response_xml.join)
343
+ else
344
+ "\"#{root_tag}\":[" + collection.map do |obj|
345
+ hateoas_object(obj, controller, format, opts)
346
+ end.join(',') + ']' +
347
+ hateoas_pagination_values_json(count, page, page_count, page_size)
348
+ end
349
+ end
350
+
351
+ def render_xml_response(response_obj, controller, opts = {})
352
+ http_status, http_status_code = http_status_and_status_code(controller, opts)
353
+ successful = ModelApi::Utils.response_successful?(http_status_code)
354
+ response_xml = render_xml_response_heading(http_status, opts)
355
+ render_xml_response_body(response_xml, response_obj, controller, opts)
356
+ response_xml << "</#{ModelApi::Utils.ext_attr(:response)}>"
357
+ return pretty_xml(response_xml.join) if opts[:generate_body_only]
358
+ set_location_header(response_obj, controller, successful, opts)
359
+ controller.render status: http_status, xml: pretty_xml(response_xml.join)
360
+ successful
361
+ end
362
+
363
+ def render_xml_response_heading(status, opts = {})
364
+ http_status_code = ModelApi::Utils.http_status_code(status)
365
+ successful = ModelApi::Utils.response_successful?(http_status_code)
366
+ successful_tag = ModelApi::Utils.ext_attr(:successful)
367
+ status_tag = ModelApi::Utils.ext_attr(:status)
368
+ status_code_tag = ModelApi::Utils.ext_attr(:status_code)
369
+ response_xml = []
370
+ response_xml << "<#{ModelApi::Utils.ext_attr(:response)}>"
371
+ response_xml << "<#{successful_tag}>#{successful ? 'true' : 'false'}</#{successful_tag}>"
372
+ response_xml << "<#{status_tag}>#{status}</#{status_tag}>"
373
+ response_xml << "<#{status_code_tag}>#{http_status_code}</#{status_code_tag}>"
374
+ if opts[:messages].present?
375
+ response_xml << xml_collection_elem_tags_with_attrs(
376
+ ModelApi::Utils.ext_attr(successful ? :messages : :errors), opts[:messages])
377
+ end
378
+ response_xml
379
+ end
380
+
381
+ def render_xml_response_body(response_xml, response_obj, controller,
382
+ opts = {})
383
+ collection = false
384
+ if response_obj.is_a?(ActiveRecord::Base)
385
+ response_xml << hateoas_object(response_obj, controller, opts[:format] || :xml, opts)
386
+ elsif !response_obj.is_a?(Hash) && response_obj.respond_to?(:map)
387
+ response_xml << hateoas_collection(response_obj, controller, opts[:format] || :xml, opts)
388
+ collection = true
389
+ elsif response_obj.present?
390
+ root = ModelApi::Utils.ext_attr(opts[:root] || get_object_root_elem(response_obj, opts) ||
391
+ :response)
392
+ response_xml << response_obj.to_xml(opts.merge(skip_instruct: true, root: root)).rstrip
393
+ end
394
+ if opts[:ignored_fields].present?
395
+ response_xml << xml_collection_elem_tags_with_attrs(
396
+ ModelApi::Utils.ext_attr(:ignored_fields), opts[:ignored_fields])
397
+ end
398
+ if collection
399
+ if (links = hateoas_links(opts[:collection_links],
400
+ opts[:collection_link_options], controller, opts)).present?
401
+ response_xml << hateoas_link_xml(links, opts)
402
+ end
403
+ elsif (links = hateoas_links(opts[:links], opts[:link_opts], controller, opts)).present?
404
+ response_xml << hateoas_link_xml(links, opts)
405
+ end
406
+ response_xml
407
+ end
408
+
409
+ def http_status_and_status_code(controller, opts = {})
410
+ if opts[:status].present?
411
+ return [opts[:status].to_sym, ModelApi::Utils.http_status_code(opts[:status].to_sym)]
412
+ elsif opts[:status_code].present?
413
+ return [ModelApi::Utils.http_status(opts[:status_code].to_i), opts[:status_code].to_i]
414
+ elsif controller.response.status.present? && controller.response.status > 0
415
+ return [ModelApi::Utils.http_status(controller.response.status), controller.response.status]
416
+ else
417
+ return [:ok, ModelApi::Utils.http_status_code(:ok)]
418
+ end
419
+ end
420
+
421
+ def render_json_response(response_obj, controller, opts = {})
422
+ http_status, http_status_code = http_status_and_status_code(controller, opts)
423
+ successful = ModelApi::Utils.response_successful?(http_status_code)
424
+ response_json = "\"#{ModelApi::Utils.ext_attr(:successful)}\":#{successful ? 'true' : 'false'},"
425
+ response_json += "\"#{ModelApi::Utils.ext_attr(:status)}\":\"#{http_status}\","
426
+ response_json += "\"#{ModelApi::Utils.ext_attr(:status_code)}\":#{http_status_code}"
427
+ if opts[:messages].present?
428
+ response_json += ",\"#{ModelApi::Utils.ext_attr(successful ? :messages : :errors)}\":" +
429
+ opts[:messages].to_json(opts)
430
+ end
431
+ response_json += build_response_obj_json(response_obj, controller, opts)
432
+ if opts[:ignored_fields].present?
433
+ response_json += ",\"#{ModelApi::Utils.ext_attr(:ignored_fields)}\":" +
434
+ opts[:ignored_fields].to_json(opts)
435
+ end
436
+ return "{#{response_json}}" if opts[:generate_body_only]
437
+ set_location_header(response_obj, controller, successful, opts)
438
+ controller.render status: http_status, json: "{#{response_json}}"
439
+ successful
440
+ end
441
+
442
+ def build_response_obj_json(response_obj, controller, opts = {})
443
+ if !response_obj.nil?
444
+ if response_obj.is_a?(ActiveRecord::Base)
445
+ root_elem_json = ModelApi::Utils.ext_attr(get_object_root_elem(response_obj, opts)).to_json
446
+ response_json = ",#{root_elem_json}:" +
447
+ hateoas_object(response_obj, controller, opts[:format] || :json, opts)
448
+ links = hateoas_links(opts[:links], opts[:link_opts], controller, opts)
449
+ elsif !response_obj.is_a?(Hash) && response_obj.respond_to?(:map)
450
+ response_json = ',' + hateoas_collection(response_obj, controller,
451
+ opts[:format] || :json, opts)
452
+ links = hateoas_links(opts[:collection_links],
453
+ opts[:collection_link_options], controller, opts)
454
+ else
455
+ root_elem_json = ModelApi::Utils.ext_attr(get_object_root_elem(response_obj, opts)).to_json
456
+ response_json = ",#{root_elem_json}:" + response_obj.to_json(opts)
457
+ links = hateoas_links(opts[:links], opts[:link_opts], controller, opts)
458
+ end
459
+ if links.present?
460
+ response_json += ",\"_links\":" + links.to_json(opts)
461
+ end
462
+ response_json
463
+ else
464
+ ''
465
+ end
466
+ end
467
+
468
+ def xml_elem_tags_with_attrs(element, hash)
469
+ tags = hash.map do |attr, value|
470
+ " #{attr}=\"#{CGI.escapeHTML(value.to_s)}\""
471
+ end
472
+ "<#{element}#{tags.join} />"
473
+ end
474
+
475
+ def xml_collection_elem_tags_with_attrs(element, array, opts = {})
476
+ child_element = opts[:children] || element.to_s.singularize
477
+ tags = array.map do |hash|
478
+ xml_elem_tags_with_attrs(child_element, hash)
479
+ end
480
+ "<#{element}>#{tags.join}</#{element}>"
481
+ end
482
+
483
+ def pretty_xml(xml, _indent = 2)
484
+ xml_doc = REXML::Document.new(xml) rescue nil
485
+ return xml unless xml_doc.present?
486
+ formatter = REXML::Formatters::Pretty.new
487
+ formatter.compact = true
488
+ out = ''
489
+ formatter.write(xml_doc, out)
490
+ out || xml
491
+ end
492
+
493
+ def set_location_header(response_obj, controller, successful, opts = {})
494
+ return unless successful && opts[:location_header] &&
495
+ response_obj.is_a?(ActiveRecord::Base)
496
+ links = opts[:object_links]
497
+ return unless links.is_a?(Hash) && links.include?(:self)
498
+ link = object_hateoas_links({ self: links[:self] }, response_obj, controller,
499
+ opts.merge(exclude_api_links: true)).find { |l| l[:rel].to_s == 'self' }
500
+ controller.response.header['Location'] = link[:href] if link.include?(:href)
501
+ end
502
+ end
503
+ end
504
+ end