praxis 2.0.pre.14 → 2.0.pre.18

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/bin/praxis +6 -0
  4. data/lib/praxis/action_definition.rb +2 -2
  5. data/lib/praxis/api_definition.rb +8 -4
  6. data/lib/praxis/blueprint.rb +22 -7
  7. data/lib/praxis/collection.rb +11 -0
  8. data/lib/praxis/dispatcher.rb +3 -3
  9. data/lib/praxis/docs/open_api/response_object.rb +21 -6
  10. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +63 -16
  11. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +1 -2
  12. data/lib/praxis/mapper/resource.rb +2 -2
  13. data/lib/praxis/mapper/selector_generator.rb +2 -2
  14. data/lib/praxis/media_type_identifier.rb +11 -1
  15. data/lib/praxis/request.rb +5 -0
  16. data/lib/praxis/request_stages/validate_params_and_headers.rb +0 -6
  17. data/lib/praxis/request_stages/validate_payload.rb +0 -1
  18. data/lib/praxis/response_definition.rb +46 -66
  19. data/lib/praxis/responses/http.rb +3 -1
  20. data/lib/praxis/tasks/routes.rb +6 -6
  21. data/lib/praxis/types/multipart_array.rb +14 -5
  22. data/lib/praxis/version.rb +1 -1
  23. data/praxis.gemspec +1 -1
  24. data/spec/praxis/action_definition_spec.rb +6 -3
  25. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +92 -34
  26. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +17 -2
  27. data/spec/praxis/extensions/support/spec_resources_active_model.rb +2 -0
  28. data/spec/praxis/mapper/resource_spec.rb +3 -3
  29. data/spec/praxis/mapper/selector_generator_spec.rb +34 -0
  30. data/spec/praxis/media_type_identifier_spec.rb +15 -1
  31. data/spec/praxis/request_spec.rb +3 -3
  32. data/spec/praxis/response_definition_spec.rb +37 -129
  33. data/spec/praxis/trait_spec.rb +3 -2
  34. data/spec/spec_app/design/media_types/instance.rb +1 -1
  35. data/spec/spec_app/design/resources/instances.rb +2 -2
  36. data/spec/spec_helper.rb +1 -0
  37. data/spec/support/spec_blueprints.rb +3 -3
  38. data/spec/support/spec_resources.rb +4 -0
  39. data/tasks/thor/templates/generator/example_app/app/v1/concerns/href.rb +33 -0
  40. data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +4 -0
  41. data/tasks/thor/templates/generator/example_app/config/environment.rb +1 -1
  42. data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +2 -2
  43. metadata +9 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6af11de4fdf970e848d638ed6fd1f47db86dcd27562bca90e50805398a82b346
4
- data.tar.gz: dd4b664b1b8afde291e0402eeb0bb57178214ac1d8d6a4dc691f532fff7ae29f
3
+ metadata.gz: 269b2403359c2a9ec0b591d695a5146ff648d16046d82ec52162086d0006bbb7
4
+ data.tar.gz: 3f28eebb4e3ceecd39a7ba5969df7ece5feafe93c3fd6b5d0dfb17df92f22fcd
5
5
  SHA512:
6
- metadata.gz: 525c64c37e1cfe27fe74c1ddbd02d3c6e45860acd0f8a79a5d9a0832274368c728dacc8096b3b37f63c302fe265161793320a64dcf44a84d99ee5074d9de9da2
7
- data.tar.gz: 79a70e175f17a14d98beddd66add981c03a8c720dd45855221b5b3223cb021d056381a5fd87880e3cbe2886755445182cf6cad3427de9f38b44220a26b23955d
6
+ metadata.gz: 800cf53e02f7322a8db37212c16b3e53827bc50acbf630a58d2027397120c2c9044c81ae919a2855b7c3635786113526712cb6b882e505fbc0c1f46042ed8f5c
7
+ data.tar.gz: bd0969b6aa8618b78d3a2f42b9e14d5c5f5d776019bd1b697a66e3dbbbe3a6535c475d806edb64f82d16b370ba76cf8effd4fe73d03adc5934cd48446e3096b5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 2.0.pre.18
6
+ * Upgraded to newest Attributor, which cleans up the required: true semantics to only work on keys, and introduces null: true for nullability of values (independent from presence of keys or not)
7
+ * Fixed a selector generator bug that would occur when using deep nested resource dependencies as strings 'foo.bar.baz.bam'. In this cases only partial tracking of relationships would be built, which could cause to not fully eager load DB queries.
8
+ ## 2.0.pre.17
9
+ * Changed the Parameter Filtering to use left outer joins (and extra conditions), to allow for the proper results when OR clauses are involved in certain configurations.
10
+ * Built support for allowing filtering directly on associations using `!` and `!!` operators. This allows to filter results where
11
+ there are no associated rows (`!!`) or if there are some associated rows (`!`)
12
+ * Allow implicit definition of `filters_mapping` for filter names that match top-level associations of the model (i.e., like we do for the columns)
13
+ ## 2.0.pre.16
14
+
15
+ * Updated `Resource.property` signature to only accept known named arguments (`dependencies` and `though` at this time) to spare anyone else from going insane wondering why their `depednencies` aren't working.
16
+ * Fixed issue with Filtering Params, that occurred with using the ! or !! operators on String-typed fields.
17
+
5
18
  ## 2.0.pre.14
6
19
 
7
20
  * More encoding/decoding robustness for filters.
data/bin/praxis CHANGED
@@ -9,6 +9,12 @@ rescue Bundler::GemfileNotFound
9
9
  # no-op: we might be installed as a system gem
10
10
  end
11
11
 
12
+ if ARGV[0] == "version"
13
+ require 'praxis/version'
14
+ puts "Praxis version #{Praxis::VERSION}"
15
+ exit 0
16
+ end
17
+
12
18
  if ["routes","docs","console"].include? ARGV[0]
13
19
  require 'rake'
14
20
  require 'praxis'
@@ -124,8 +124,8 @@ module Praxis
124
124
  def payload(type=Attributor::Struct, **opts, &block)
125
125
  return @payload if !block && ( opts.nil? || opts.empty? ) && type == Attributor::Struct
126
126
 
127
- unless( opts.key? :required )
128
- opts[:required] = true # Make the payload required by default
127
+ unless opts.key?(:required)
128
+ opts = {required: true, null: false}.merge(opts) # Make the payload required and non-nullable by default
129
129
  end
130
130
 
131
131
  if @payload
@@ -97,8 +97,10 @@ module Praxis
97
97
  description( description || 'Standard response for successful HTTP requests.' )
98
98
 
99
99
  media_type media_type
100
- location location
101
- headers headers if headers
100
+ location if location
101
+ headers&.each do |(name, value)|
102
+ header(name: name, value: value)
103
+ end
102
104
  end
103
105
 
104
106
  api.response_template :created do |media_type: nil, location: nil, headers: nil, description: nil|
@@ -106,8 +108,10 @@ module Praxis
106
108
  description( description || 'The request has been fulfilled and resulted in a new resource being created.' )
107
109
 
108
110
  media_type media_type if media_type
109
- location location
110
- headers headers if headers
111
+ location if location
112
+ headers&.each do |(name, value)|
113
+ header(name: name, value: value)
114
+ end
111
115
  end
112
116
  end
113
117
 
@@ -321,18 +321,33 @@ module Praxis
321
321
  @validating = true
322
322
 
323
323
  errors = []
324
- self.class.attributes.each do |sub_attribute_name, sub_attribute|
325
- sub_context = self.class.generate_subcontext(context, sub_attribute_name)
326
- value = self.send(sub_attribute_name)
327
- keys_with_values << sub_attribute_name unless value.nil?
324
+ keys_provided = []
325
+
326
+ self.class.attributes.each do |key, attribute|
327
+ sub_context = self.class.generate_subcontext(context, key)
328
+ value = _get_attr(key)
329
+ keys_provided << key if @object.key?(key)
328
330
 
329
331
  if value.respond_to?(:validating) # really, it's a thing with sub-attributes
330
332
  next if value.validating
331
333
  end
332
- errors.concat(sub_attribute.validate(value, sub_context))
334
+
335
+ # Isn't this handled by the requirements validation? NO! we might want to combine
336
+ if attribute.options[:required] && !@object.key?(key)
337
+ errors.concat ["Attribute #{Attributor.humanize_context(sub_context)} is required."]
338
+ end
339
+ if @object[key].nil?
340
+ if !Attributor::Attribute.nullable_attribute?(attribute.options) && @object.key?(key) # It is only nullable if there's an explicite null: true (undefined defaults to false)
341
+ errors.concat ["Attribute #{Attributor.humanize_context(sub_context)} is not nullable."]
342
+ end
343
+ # No need to validate the attribute further if the key wasn't passed...(or we would get nullable errors etc..cause the attribute has no
344
+ # context if its containing key was even passed (and there might not be a containing key for a top level attribute anyways))
345
+ else
346
+ errors.concat attribute.validate(value, sub_context)
347
+ end
333
348
  end
334
- self.class.attribute.type.requirements.each do |req|
335
- validation_errors = req.validate(keys_with_values, context)
349
+ self.class.attribute.type.requirements.each do |requirement|
350
+ validation_errors = requirement.validate(keys_provided, context)
336
351
  errors.concat(validation_errors) unless validation_errors.empty?
337
352
  end
338
353
  errors
@@ -32,5 +32,16 @@ module Praxis
32
32
  @member_type.domain_model
33
33
  end
34
34
 
35
+ def self.json_schema_type
36
+ :array
37
+ end
38
+
39
+ def self.as_json_schema(**args)
40
+ the_type = @attribute && @attribute.type || member_type
41
+ {
42
+ type: json_schema_type,
43
+ items: { '$ref': "#/components/schemas/#{the_type.id}" }
44
+ }
45
+ end
35
46
  end
36
47
  end
@@ -1,9 +1,9 @@
1
1
  module Praxis
2
2
 
3
3
  CONTEXT_FOR = {
4
- params: [Attributor::AttributeResolver::ROOT_PREFIX, "params".freeze],
5
- headers: [Attributor::AttributeResolver::ROOT_PREFIX, "headers".freeze],
6
- payload: [Attributor::AttributeResolver::ROOT_PREFIX, "payload".freeze]
4
+ params: [Attributor::ROOT_PREFIX, "params".freeze],
5
+ headers: [Attributor::ROOT_PREFIX, "headers".freeze],
6
+ payload: [Attributor::ROOT_PREFIX, "payload".freeze]
7
7
  }.freeze
8
8
 
9
9
  class Dispatcher
@@ -16,12 +16,27 @@ module Praxis
16
16
 
17
17
  def dump_response_headers_object( headers )
18
18
  headers.each_with_object({}) do |(name,data),accum|
19
- # data is a hash with :value and :type keys
20
- # How did we say in that must match a value in json schema again??
21
- accum[name] = {
22
- schema: SchemaObject.new(info: data[:type])
23
- # allowed values: [ data[:value] ] ??? is this the right json schema way?
24
- }
19
+ # each header comes from Praxis::ResponseDefinition
20
+ # the keys are the header names, and value can be:
21
+ # "true" => means it only needs to exist
22
+ # String => which means that it has to fully match
23
+ # Regex => which means it has to regexp match it
24
+
25
+ # Get the schema from the type (defaulting to string in case the type doesn't have the as_json_schema defined)
26
+ schema = data[:attribute].type.as_json_schema rescue { type: :string }
27
+ hash = { description: data[:description] || '', schema: schema }
28
+ # Note, our Headers in response definition are not full types...they're basically only
29
+ # strings, which can either match anything, match the exact word or match a regex
30
+ # they don't even have a description...
31
+ data_value = data[:value]
32
+ if data_value.is_a? String
33
+ hash[:pattern] = "^#{data_value}$" # Exact String match
34
+ elsif data_value.is_a? Regexp
35
+ sanitized_pattern = data_value.inspect[1..-2] #inspect returns enclosing '/' characters
36
+ hash[:pattern] = sanitized_pattern
37
+ end
38
+
39
+ accum[name] = hash
25
40
  end
26
41
  end
27
42
 
@@ -23,7 +23,8 @@ module Praxis
23
23
  end
24
24
 
25
25
  class ActiveRecordFilterQueryBuilder
26
- attr_reader :model, :filters_map
26
+ REFERENCES_STRING_SEPARATOR = '/'
27
+ attr_reader :model, :filters_map
27
28
 
28
29
  # Base query to build upon
29
30
  def initialize(query: , model:, filters_map:, debug: false)
@@ -48,9 +49,8 @@ module Praxis
48
49
  end
49
50
 
50
51
  def craft_filter_query(nodetree, for_model:)
51
- result = _compute_joins_and_conditions_data(nodetree, model: for_model)
52
+ result = _compute_joins_and_conditions_data(nodetree, model: for_model, parent_reflection: nil)
52
53
  return @initial_query if result[:conditions].empty?
53
-
54
54
 
55
55
  # Find the root group (usually an AND group) but can be an OR group, or nil if there's only 1 condition
56
56
  root_parent_group = result[:conditions].first[:node_object].parent_group || result[:conditions].first[:node_object]
@@ -59,21 +59,35 @@ module Praxis
59
59
  end
60
60
 
61
61
  # Process the joins
62
- query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.joins(result[:associations_hash])
62
+ query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.left_outer_joins(result[:associations_hash])
63
63
 
64
64
  # Proc to apply a single condition
65
65
  apply_single_condition = Proc.new do |condition, associated_query|
66
66
  colo = condition[:model].columns_hash[condition[:name].to_s]
67
67
  column_prefix = condition[:column_prefix]
68
- #Mark where clause referencing the appropriate alias
69
- associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query))
68
+ association_key_column = \
69
+ if ref = condition[:parent_reflection]
70
+ # get the target model of the association(where the assoc pk is)
71
+ target_model = condition[:parent_reflection].klass
72
+ target_model.columns_hash[condition[:parent_reflection].association_primary_key]
73
+ else
74
+ nil
75
+ end
76
+
77
+ # Mark where clause referencing the appropriate alias IF it's not the root table, as there is no association to reference
78
+ # If we added root table as a reference, we better make sure it is not quoted, as it actually makes AR to see it as an
79
+ # unmatched reference and eager loads the whole association (it means eager load ALL the things). Not good.
80
+ unless for_model.table_name == column_prefix
81
+ associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query))
82
+ end
70
83
  self.class.add_clause(
71
84
  query: associated_query,
72
85
  column_prefix: column_prefix,
73
86
  column_object: colo,
74
87
  op: condition[:op],
75
88
  value: condition[:value],
76
- fuzzy: condition[:fuzzy]
89
+ fuzzy: condition[:fuzzy],
90
+ association_key_column: association_key_column,
77
91
  )
78
92
  end
79
93
 
@@ -138,7 +152,8 @@ module Praxis
138
152
  def _mapped_filter(name)
139
153
  target = @filters_map[name]
140
154
  unless target
141
- if @model.attribute_names.include?(name.to_s)
155
+ filter_name = name.to_s
156
+ if (@model.attribute_names + @model.reflections.keys).include?(filter_name)
142
157
  # Cache it in the filters mapping (to avoid later lookups), and return it.
143
158
  @filters_map[name] = name
144
159
  target = name
@@ -176,32 +191,64 @@ module Praxis
176
191
  end
177
192
 
178
193
  # Calculate join tree and conditions array for the nodetree object and its children
179
- def _compute_joins_and_conditions_data(nodetree, model:)
194
+ def _compute_joins_and_conditions_data(nodetree, model:, parent_reflection:)
180
195
  h = {}
181
196
  conditions = []
182
197
  nodetree.children.each do |name, child|
183
- child_model = model.reflections[name.to_s].klass
184
- result = _compute_joins_and_conditions_data(child, model: child_model)
185
- h[name] = result[:associations_hash]
198
+ child_reflection = model.reflections[name.to_s]
199
+ result = _compute_joins_and_conditions_data(child, model: child_reflection.klass, parent_reflection: child_reflection)
200
+ h[name] = result[:associations_hash]
201
+
186
202
  conditions += result[:conditions]
187
203
  end
188
- column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? model.table_name : nodetree.path.join('/')
189
- #column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? nil : nodetree.path.join('/')
204
+
205
+ column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? model.table_name : nodetree.path.join(REFERENCES_STRING_SEPARATOR)
190
206
  nodetree.conditions.each do |condition|
191
- conditions += [condition.merge(column_prefix: column_prefix, model: model)]
207
+ # If it's a final ! or !! operation on an association from the parent, it means we need to add a condition
208
+ # on the existence (or lack of) of the whole associated table
209
+ ref = model.reflections[condition[:name].to_s]
210
+ if ref && ['!','!!'].include?(condition[:op])
211
+ cp = (nodetree.path + [condition[:name].to_s]).join(REFERENCES_STRING_SEPARATOR)
212
+ conditions += [condition.merge(column_prefix: cp, model: model, parent_reflection: ref)]
213
+ h[condition[:name]] = {}
214
+ else
215
+ # Save the parent reflection where the condition applies as well (used later to get assoc keys)
216
+ conditions += [condition.merge(column_prefix: column_prefix, model: model, parent_reflection: parent_reflection)]
217
+ end
218
+
192
219
  end
193
220
  {associations_hash: h, conditions: conditions}
194
221
  end
195
222
 
196
- def self.add_clause(query:, column_prefix:, column_object:, op:, value:,fuzzy:)
223
+ def self.add_clause(query:, column_prefix:, column_object:, op:, value:,fuzzy:, association_key_column:)
197
224
  likeval = get_like_value(value,fuzzy)
225
+
226
+ association_op = nil
198
227
  case op
199
228
  when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
200
229
  op = '!='
201
230
  value = nil # Enforce it is indeed nil (should be)
231
+ association_op = :not_null if association_key_column && !column_object
202
232
  when '!!'
203
233
  op = '='
204
234
  value = nil # Enforce it is indeed nil (should be)
235
+ association_op = :null if association_key_column && !column_object
236
+ end
237
+
238
+ if association_op
239
+ neg = association_op == :not_null ? true : false
240
+ qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: neg)
241
+ return query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: association_key_column)} #{qr}")
242
+ end
243
+
244
+ # Add an AND along with the condition, which ensures the left outter join 'exists' for it
245
+ # Normally this wouldn't be necessary as a condition on a given value mathing would imply the related row was there
246
+ # but this is not the case for NULL conditions, as the foreign column would match a NULL value, but not because the related column
247
+ # is NULL, but because the whole missing related row would appear with all fields null
248
+ # NOTE: we don't need to do it for conditions applying to the root of the tree (there isn't a join to it)
249
+ if association_key_column
250
+ qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: true)
251
+ query = query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: association_key_column)} #{qr}")
205
252
  end
206
253
 
207
254
  case op
@@ -222,8 +222,7 @@ module Praxis
222
222
  value_type = attr_filters[:value_type]
223
223
  next unless value_type == Attributor::String
224
224
 
225
- value = item[:value]
226
- unless value.empty?
225
+ if item[:value].presence
227
226
  fuzzy_match = attr_filters[:fuzzy_match]
228
227
  if item[:fuzzy] && !item[:fuzzy].empty? && !fuzzy_match
229
228
  errors << "Fuzzy matching for #{attr_name} is not allowed (yet '*' was found in the value)"
@@ -46,8 +46,8 @@ module Praxis::Mapper
46
46
  end
47
47
  end
48
48
 
49
- def self.property(name, **options)
50
- self.properties[name] = options
49
+ def self.property(name, dependencies: nil, through: nil)
50
+ self.properties[name] = {dependencies: dependencies, through: through}
51
51
  end
52
52
 
53
53
  def self._finalize!
@@ -98,10 +98,10 @@ module Praxis::Mapper
98
98
  when Symbol
99
99
  map_property(dependency, true)
100
100
  when String
101
- head, tail = dependency.split('.').collect(&:to_sym)
101
+ head, *tail = dependency.split('.').collect(&:to_sym)
102
102
  raise "String dependencies can not be singular" if tail.nil?
103
103
 
104
- add_association(head, {tail => true})
104
+ add_association(head, tail.reverse.inject({}) { |hash, dep| { dep => hash } })
105
105
  end
106
106
  end
107
107
 
@@ -198,10 +198,20 @@ module Praxis
198
198
  obj = self.class.new
199
199
  obj.type = self.type
200
200
  obj.subtype = self.subtype
201
- obj.suffix = suffix || self.suffix || ''
201
+ target_suffix = suffix || self.suffix
202
+ obj.suffix = redundant_suffix(target_suffix) ? '' : target_suffix
202
203
  obj.parameters = self.parameters.merge(parameters)
203
204
 
204
205
  obj
205
206
  end
207
+
208
+ def redundant_suffix(suffix)
209
+ # application/json does not need to be suffixed with +json (same for application/xml)
210
+ # we're supporting text/json and text/xml for older formats as well
211
+ if (self.type == 'application' || self.type == 'text') && self.subtype == suffix
212
+ return true
213
+ end
214
+ false
215
+ end
206
216
  end
207
217
  end
@@ -142,18 +142,23 @@ module Praxis
142
142
  def validate_headers(context)
143
143
  return [] unless action.headers
144
144
 
145
+ return ["Attribute #{Attributor.humanize_context(context)} is required."] if action.headers.options[:required] == true && self.headers.nil?
146
+
145
147
  action.headers.validate(self.headers, context)
146
148
  end
147
149
 
148
150
  def validate_params(context)
149
151
  return [] unless action.params
150
152
 
153
+ return ["Attribute #{Attributor.humanize_context(context)} is required."] if action.params.options[:required] == true && self.params.nil?
154
+
151
155
  action.params.validate(self.params, context)
152
156
  end
153
157
 
154
158
  def validate_payload(context)
155
159
  return [] unless action.payload
156
160
 
161
+ return ["Attribute #{Attributor.humanize_context(context)} is required."] if action.payload.options[:required] == true && self.payload.nil?
157
162
  action.payload.validate(self.payload, context)
158
163
  end
159
164
 
@@ -38,12 +38,6 @@ module Praxis
38
38
  )
39
39
  end
40
40
 
41
- attribute_resolver = Attributor::AttributeResolver.new
42
- Attributor::AttributeResolver.current = attribute_resolver
43
-
44
- attribute_resolver.register("headers",request.headers)
45
- attribute_resolver.register("params",request.params)
46
-
47
41
  errors = request.validate_headers(CONTEXT_FOR[:headers])
48
42
  errors += request.validate_params(CONTEXT_FOR[:params])
49
43
  if errors.any?
@@ -27,7 +27,6 @@ module Praxis
27
27
  stage: name
28
28
  )
29
29
  end
30
- Attributor::AttributeResolver.current.register("payload",request.payload)
31
30
 
32
31
  errors = request.validate_payload(CONTEXT_FOR[:payload])
33
32
  if errors.any?
@@ -55,52 +55,36 @@ module Praxis
55
55
  end
56
56
  end
57
57
 
58
- def location(loc=nil)
59
- return @spec[:location] if loc.nil?
60
- unless ( loc.is_a?(Regexp) || loc.is_a?(String) )
61
- raise Exceptions::InvalidConfiguration.new(
62
- "Invalid location specification. Location in response must be either a regular expression or a string."
63
- )
64
- end
65
- @spec[:location] = loc
66
- end
58
+ def location(loc=nil, description: nil)
59
+ return headers.dig('Location',:value) if loc.nil?
67
60
 
68
- def headers(hdrs = nil)
69
- return @spec[:headers] if hdrs.nil?
61
+ header('Location', loc, description: description)
62
+ end
70
63
 
71
- case hdrs
72
- when Array
73
- hdrs.each {|header_name| header(header_name) }
74
- when Hash
75
- header(hdrs)
76
- when String
77
- header(hdrs)
78
- else
79
- raise Exceptions::InvalidConfiguration.new(
80
- "Invalid headers specification: Arrays, Hash, or String must be used. Received: #{hdrs.inspect}"
81
- )
82
- end
64
+ def headers
65
+ @spec[:headers]
83
66
  end
84
67
 
85
- def header(hdr)
86
- case hdr
87
- when String
88
- @spec[:headers][hdr] = true
89
- when Hash
90
- hdr.each do | k, v |
91
- unless v.is_a?(Regexp) || v.is_a?(String)
92
- raise Exceptions::InvalidConfiguration.new(
93
- "Header definitions for #{k.inspect} can only match values of type String or Regexp. Received: #{v.inspect}"
94
- )
95
- end
96
- @spec[:headers][k] = v
97
- end
68
+ def header(name, value, description: nil)
69
+ the_type, args = case value
70
+ when nil,String
71
+ [String, {}]
72
+ when Regexp
73
+ # A regexp means it's gonna be a String typed, attached to a regexp
74
+ [String, { regexp: value }]
98
75
  else
99
76
  raise Exceptions::InvalidConfiguration.new(
100
- "A header definition can only take a String (to match the name) or" +
101
- " a Hash (to match both the name and the value). Received: #{hdr.inspect}"
77
+ "A header definition for a response can only take String, Regexp or nil values (to match anything)." +
78
+ "Received the following value for header name #{name}: #{value}"
102
79
  )
103
80
  end
81
+
82
+ info = {
83
+ value: value,
84
+ attribute: Attributor::Attribute.new(the_type, **args)
85
+ }
86
+ info[:description] = description if description
87
+ @spec[:headers][name] = info
104
88
  end
105
89
 
106
90
  def example(context=nil)
@@ -123,13 +107,14 @@ module Praxis
123
107
  :status => status,
124
108
  :headers => {}
125
109
  }
126
- content[:location] = _describe_header(location) unless location == nil
127
110
 
128
111
  unless headers == nil
129
112
  headers.each do |name, value|
130
113
  content[:headers][name] = _describe_header(value)
131
114
  end
132
115
  end
116
+ content[:location] = content[:headers]['Location']
117
+
133
118
 
134
119
  if self.media_type
135
120
  payload = media_type.describe(true)
@@ -173,14 +158,14 @@ module Praxis
173
158
  end
174
159
 
175
160
  def _describe_header(data)
176
- data_type = data.is_a?(Regexp) ? :regexp : :string
177
- data_value = data.is_a?(Regexp) ? data.inspect : data
161
+
162
+ data_type = data[:value].is_a?(Regexp) ? :regexp : :string
163
+ data_value = data[:value].is_a?(Regexp) ? data[:value].inspect : data[:value]
178
164
  { :value => data_value, :type => data_type }
179
165
  end
180
166
 
181
167
  def validate(response, validate_body: false)
182
168
  validate_status!(response)
183
- validate_location!(response)
184
169
  validate_headers!(response)
185
170
  validate_content_type!(response)
186
171
  validate_parts!(response)
@@ -222,23 +207,13 @@ module Praxis
222
207
  end
223
208
  end
224
209
 
225
-
226
- # Validates 'Location' header
227
- #
228
- # @raise [Exceptions::Validation] When location header does not match to the defined one.
229
- #
230
- def validate_location!(response)
231
- return if location.nil? || location === response.headers['Location']
232
- raise Exceptions::Validation.new("LOCATION does not match #{location.inspect}")
233
- end
234
-
235
-
236
210
  # Validates Headers
237
211
  #
238
212
  # @raise [Exceptions::Validation] When there is a missing required header..
239
213
  #
240
214
  def validate_headers!(response)
241
215
  return unless headers
216
+
242
217
  headers.each do |name, value|
243
218
  if name.is_a? Symbol
244
219
  raise Exceptions::Validation.new(
@@ -252,20 +227,25 @@ module Praxis
252
227
  )
253
228
  end
254
229
 
255
- case value
256
- when String
257
- if response.headers[name] != value
258
- raise Exceptions::Validation.new(
259
- "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name]}."
260
- )
261
- end
262
- when Regexp
263
- if response.headers[name] !~ value
264
- raise Exceptions::Validation.new(
265
- "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name].inspect}."
266
- )
267
- end
230
+ errors = value[:attribute].validate(response.headers[name])
231
+
232
+ unless errors.empty?
233
+ raise Exceptions::Validation.new("Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name]}.")
268
234
  end
235
+ # case value
236
+ # when String
237
+ # if response.headers[name] != value
238
+ # raise Exceptions::Validation.new(
239
+ # "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name]}."
240
+ # )
241
+ # end
242
+ # when Regexp
243
+ # if response.headers[name] !~ value
244
+ # raise Exceptions::Validation.new(
245
+ # "Header #{name.inspect}, with value #{value.inspect} does not match #{response.headers[name].inspect}."
246
+ # )
247
+ # end
248
+ # end
269
249
  end
270
250
  end
271
251
 
@@ -160,7 +160,9 @@ module Praxis
160
160
 
161
161
  media_type media_type if media_type
162
162
  location location if location
163
- headers headers if headers
163
+ headers&.each do |(name, value)|
164
+ header(name: name, value: value)
165
+ end
164
166
  end
165
167
  end
166
168