haveapi 0.29.5 → 0.29.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91faacf7a526f1d4a85e3ac834af09b31870084892a4fe423c89f788b785831f
4
- data.tar.gz: 81ece8ab753c147e13401933bd8086e11c6a20f8e1991cb86ac80df6afbfccac
3
+ metadata.gz: f3555b00c36ae85444baaa5d2da5e69e70343a8d9528e6fee181ed947bf762d6
4
+ data.tar.gz: 19613608cf068c2ec9ed2ecb8eda34fbdfab5da9515708a9786f8648059bfd48
5
5
  SHA512:
6
- metadata.gz: 4d2c6c8cc96b841f645193d248023ea939f36a55af8e7dc2ed710ed770a15de89c34897cd9f3db969c4e1867b4e70ebcbcdf0c15f702d8acf917cee3cbc543a2
7
- data.tar.gz: c7ff9c297474a183ba391e8567088494c0b98456f1cbb37b6a36599a955a4b6aca30ec2d5f631fc8d9fdc2c18319059eb5fda188fe75d2574d3875a52dda4544
6
+ metadata.gz: 8658ea7fb6d61350395fa2a3e9a7ac11467d732c27e4b460cb3105e53bbd2f3784f2426bcdc4760c672cce876af1ef111aaba61fa9a2b79be9bbda90fc7954a8
7
+ data.tar.gz: c03998dc485dca010fc82ab5156e463c3c673b2365e8caed997d08393203907addbb86ae8a01faf280be5b0453cd323c165ed6b6bf428a009a4412114a593b6d
data/haveapi.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
15
15
  s.required_ruby_version = ">= #{File.read('../../.ruby-version').strip}"
16
16
 
17
17
  s.add_dependency 'activesupport', '>= 7.1'
18
- s.add_dependency 'haveapi-client', '~> 0.29.5'
18
+ s.add_dependency 'haveapi-client', '~> 0.29.7'
19
19
  s.add_dependency 'i18n', '>= 1.6', '< 2'
20
20
  s.add_dependency 'json'
21
21
  s.add_dependency 'mail'
@@ -132,7 +132,7 @@ module HaveAPI
132
132
  @initialized = true
133
133
  end
134
134
 
135
- def validate_build
135
+ def validate_build(path: nil)
136
136
  check_build("#{self}.input") do
137
137
  input.validate_build
138
138
  end
@@ -140,6 +140,10 @@ module HaveAPI
140
140
  check_build("#{self}.output") do
141
141
  output.validate_build
142
142
  end
143
+
144
+ check_build("#{self}.examples") do
145
+ @examples&.validate_build(self, path:)
146
+ end
143
147
  end
144
148
 
145
149
  def model_adapter(layout)
@@ -95,7 +95,7 @@ module HaveAPI::ClientExamples
95
95
  end
96
96
 
97
97
  def resolve_path(method, path, path_params, input_params)
98
- ret = path.clone
98
+ ret = path.dup
99
99
 
100
100
  path_params.each do |v|
101
101
  ret.sub!(/\{[a-zA-Z\-_]+\}/, v.to_s)
@@ -101,18 +101,20 @@ module HaveAPI::ClientExamples
101
101
 
102
102
  case action[:output][:layout]
103
103
  when :hash
104
- out << "# reply is an instance of HaveAPI.Client.Response\n"
105
- out << "# reply.response() returns an object with output parameters:\n"
104
+ out << " // reply is an instance of HaveAPI.Client.Response\n"
105
+ out << " // reply.response() returns an object with output parameters:\n"
106
106
  out << JSON.pretty_generate(sample[:response] || {}).split("\n").map do |v|
107
107
  " // #{v}"
108
108
  end.join("\n")
109
+ out << "\n"
109
110
 
110
111
  when :hash_list
111
- out << "# reply is an instance of HaveAPI.Client.Response\n"
112
- out << "# reply.response() returns an array of objects:\n"
112
+ out << " // reply is an instance of HaveAPI.Client.Response\n"
113
+ out << " // reply.response() returns an array of objects:\n"
113
114
  out << JSON.pretty_generate(sample[:response] || []).split("\n").map do |v|
114
115
  " // #{v}"
115
116
  end.join("\n")
117
+ out << "\n"
116
118
 
117
119
  when :object
118
120
  out << " // reply is an instance of HaveAPI.Client.ResourceInstance\n"
@@ -1,5 +1,9 @@
1
+ require 'date'
2
+
1
3
  module HaveAPI
2
4
  class Example
5
+ JSON_SCALARS = [String, Numeric, TrueClass, FalseClass, NilClass].freeze
6
+
3
7
  def initialize(title)
4
8
  @title = title
5
9
  end
@@ -74,8 +78,179 @@ module HaveAPI
74
78
  end
75
79
  end
76
80
 
81
+ def validate_build(action, path:, index:)
82
+ return unless provided?
83
+ return unless structured_data_provided?
84
+
85
+ @validation_context = "#{action} example #{example_name(index)}"
86
+
87
+ validate_path_params(action, path) if path
88
+ validate_payload('request', @request, action.input, require_required: true) unless @request.nil?
89
+ validate_payload('response', @response, action.output, require_required: false) unless @response.nil?
90
+ validate_errors(action.input) unless @errors.nil?
91
+ ensure
92
+ @validation_context = nil
93
+ end
94
+
77
95
  protected
78
96
 
97
+ def structured_data_provided?
98
+ %i[
99
+ @path_params @request @response @status @message @errors @http_status
100
+ ].any? { |name| !instance_variable_get(name).nil? }
101
+ end
102
+
103
+ def example_name(index)
104
+ @title.to_s.empty? ? "##{index + 1}" : @title.inspect
105
+ end
106
+
107
+ def validate_path_params(action, path)
108
+ expected = action.path_param_names(path).length
109
+ actual = @path_params.nil? ? 0 : @path_params.length
110
+ return if actual == expected
111
+
112
+ invalid!("path_params has #{actual} values, but route #{path.inspect} requires #{expected}")
113
+ end
114
+
115
+ def validate_payload(name, value, params, require_required:)
116
+ unless params
117
+ invalid!("#{name} is provided, but the action has no #{name == 'request' ? 'input' : 'output'} parameters")
118
+ end
119
+
120
+ objects =
121
+ case params.layout
122
+ when :object, :hash
123
+ invalid!("#{name} must be an object") unless value.is_a?(Hash)
124
+ [value]
125
+ when :object_list, :hash_list
126
+ unless value.is_a?(Array) && value.all?(Hash)
127
+ invalid!("#{name} must be a list of objects")
128
+ end
129
+ value
130
+ else
131
+ invalid!("#{name} uses unsupported layout #{params.layout.inspect}")
132
+ end
133
+
134
+ objects.each_with_index do |object, index|
135
+ location = objects.length > 1 ? "#{name}[#{index}]" : name
136
+ validate_object(location, object, params, require_required:)
137
+ end
138
+ end
139
+
140
+ def validate_object(location, object, params, require_required:)
141
+ normalized = normalize_keys(location, object)
142
+ known = params.params.to_h { |param| [param.name.to_sym, param] }
143
+ unknown = normalized.keys - known.keys
144
+ invalid!("#{location} contains unknown parameters: #{unknown.join(', ')}") unless unknown.empty?
145
+
146
+ if require_required
147
+ missing = known.values.filter_map do |param|
148
+ param.name unless !param.required? || normalized.has_key?(param.name.to_sym)
149
+ end
150
+ invalid!("#{location} is missing required parameters: #{missing.join(', ')}") unless missing.empty?
151
+ end
152
+
153
+ normalized.each do |param_name, param_value|
154
+ validate_value("#{location}.#{param_name}", param_value, known.fetch(param_name))
155
+ end
156
+ end
157
+
158
+ def normalize_keys(location, object)
159
+ object.each_with_object({}) do |(key, value), ret|
160
+ unless key.is_a?(String) || key.is_a?(Symbol)
161
+ invalid!("#{location} parameter name #{key.inspect} is not a string or symbol")
162
+ end
163
+
164
+ normalized = key.to_sym
165
+ invalid!("#{location} repeats parameter #{normalized}") if ret.has_key?(normalized)
166
+
167
+ ret[normalized] = value
168
+ end
169
+ end
170
+
171
+ def validate_value(location, value, param)
172
+ if value.nil?
173
+ invalid!("#{location} cannot be null") unless param.nullable?
174
+ return
175
+ end
176
+
177
+ if param.is_a?(HaveAPI::Parameters::Resource)
178
+ validate_resource_value(location, value, param)
179
+ return
180
+ end
181
+
182
+ valid =
183
+ case param.type.to_s
184
+ when 'Integer'
185
+ value.is_a?(Integer)
186
+ when 'Float'
187
+ value.is_a?(Numeric) && value.finite?
188
+ when 'Boolean'
189
+ [true, false].include?(value)
190
+ when 'Datetime'
191
+ valid_datetime?(value)
192
+ when 'String', 'Text'
193
+ value.is_a?(String)
194
+ else
195
+ json_value?(value)
196
+ end
197
+
198
+ invalid!("#{location} has invalid #{param.type} value #{value.inspect}") unless valid
199
+ end
200
+
201
+ def validate_resource_value(location, value, param)
202
+ return if value.is_a?(Integer) || value.is_a?(String)
203
+
204
+ unless value.is_a?(Hash)
205
+ invalid!("#{location} must be a resource ID or object")
206
+ end
207
+
208
+ normalized = normalize_keys(location, value)
209
+ id_name = param.value_id.to_sym
210
+ invalid!("#{location} resource object is missing #{id_name}") unless normalized.has_key?(id_name)
211
+ invalid!("#{location}.#{id_name} must be an integer or string") unless [
212
+ Integer,
213
+ String
214
+ ].any? { |klass| normalized.fetch(id_name).is_a?(klass) }
215
+ invalid!("#{location} is not JSON-compatible") unless json_value?(value)
216
+ end
217
+
218
+ def valid_datetime?(value)
219
+ return true if value.is_a?(Time) || value.is_a?(DateTime)
220
+ return false unless value.is_a?(String)
221
+
222
+ DateTime.iso8601(value)
223
+ true
224
+ rescue ArgumentError
225
+ false
226
+ end
227
+
228
+ def json_value?(value)
229
+ return value.finite? if value.is_a?(Float)
230
+ return true if JSON_SCALARS.any? { |klass| value.is_a?(klass) }
231
+ return value.all? { |item| json_value?(item) } if value.is_a?(Array)
232
+
233
+ if value.is_a?(Hash)
234
+ return value.all? do |key, item|
235
+ (key.is_a?(String) || key.is_a?(Symbol)) && json_value?(item)
236
+ end
237
+ end
238
+
239
+ false
240
+ end
241
+
242
+ def validate_errors(input)
243
+ invalid!('errors must be an object') unless @errors.is_a?(Hash)
244
+
245
+ known = input ? input.params.map { |param| param.name.to_sym } : []
246
+ unknown = @errors.keys.map(&:to_sym) - known
247
+ invalid!("errors refer to unknown input parameters: #{unknown.join(', ')}") unless unknown.empty?
248
+ end
249
+
250
+ def invalid!(message)
251
+ raise "#{@validation_context}: #{message}"
252
+ end
253
+
79
254
  def filter_input_params(context, input)
80
255
  return nil if input.nil?
81
256
 
@@ -19,6 +19,12 @@ module HaveAPI
19
19
  ret
20
20
  end
21
21
 
22
+ def validate_build(action, path:)
23
+ @examples.each_with_index do |example, index|
24
+ example.validate_build(action, path:, index:)
25
+ end
26
+ end
27
+
22
28
  def each(&)
23
29
  @examples.each(&)
24
30
  end
@@ -640,7 +640,9 @@ module HaveAPI
640
640
 
641
641
  def validate_resources(resources)
642
642
  resources.each_value do |r|
643
- r[:actions].each_key(&:validate_build)
643
+ r[:actions].each do |action, path|
644
+ action.validate_build(path:)
645
+ end
644
646
 
645
647
  validate_resources(r[:resources])
646
648
  end
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.29.5'.freeze
3
+ VERSION = '0.29.7'.freeze
4
4
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'open3'
6
+ require 'tempfile'
7
+ require 'haveapi/client_examples/curl'
8
+ require 'haveapi/client_examples/http'
9
+ require 'haveapi/client_examples/js_client'
10
+ require 'haveapi/client_examples/php_client'
11
+ require 'haveapi/client_examples/ruby_client'
12
+
13
+ describe HaveAPI::ClientExamples do
14
+ let(:action) do
15
+ {
16
+ method: 'POST',
17
+ path: '/v1/widgets/{widget_id}/update',
18
+ input: {
19
+ layout: :hash,
20
+ namespace: :widget,
21
+ parameters: {
22
+ name: { type: 'String' },
23
+ enabled: { type: 'Boolean' }
24
+ }
25
+ },
26
+ output: {
27
+ layout: :hash,
28
+ namespace: :widget,
29
+ parameters: {
30
+ id: { type: 'Integer' },
31
+ name: { type: 'String' }
32
+ }
33
+ }
34
+ }
35
+ end
36
+ let(:sample) do
37
+ {
38
+ path_params: [101],
39
+ request: { name: 'documented widget', enabled: true },
40
+ response: { id: 101, name: 'documented widget' },
41
+ status: true,
42
+ message: nil,
43
+ errors: nil,
44
+ http_status: 200
45
+ }
46
+ end
47
+ let(:client_args) do
48
+ [
49
+ 'api.example.test',
50
+ 'https://api.example.test',
51
+ 1,
52
+ %w[widget],
53
+ {},
54
+ 'update',
55
+ action
56
+ ]
57
+ end
58
+
59
+ def syntax_check(command, source, suffix)
60
+ Tempfile.create(['haveapi-example-', suffix]) do |file|
61
+ file.write(source)
62
+ file.flush
63
+ output, status = Open3.capture2e(*command, file.path)
64
+ [status, output]
65
+ end
66
+ end
67
+
68
+ it 'generates valid JavaScript' do
69
+ source = HaveAPI::ClientExamples::JsClient.new(*client_args).example(sample)
70
+ status, output = syntax_check(%w[node --check], source, '.mjs')
71
+
72
+ expect(status).to be_success, output
73
+ end
74
+
75
+ it 'generates valid PHP' do
76
+ source = HaveAPI::ClientExamples::PhpClient.new(*client_args).example(sample)
77
+ status, output = syntax_check(%w[php -l], "<?php\n#{source}", '.php')
78
+
79
+ expect(status).to be_success, output
80
+ end
81
+
82
+ it 'generates valid Ruby' do
83
+ source = HaveAPI::ClientExamples::RubyClient.new(*client_args).example(sample)
84
+ status, output = syntax_check(%w[ruby -c], source, '.rb')
85
+
86
+ expect(status).to be_success, output
87
+ end
88
+
89
+ it 'generates shell-parseable curl requests' do
90
+ source = HaveAPI::ClientExamples::Curl.new(*client_args).request(sample)
91
+ status, output = syntax_check(%w[bash -n], source, '.sh')
92
+
93
+ expect(status).to be_success, output
94
+ end
95
+
96
+ it 'generates structurally valid HTTP requests and responses' do
97
+ generator = HaveAPI::ClientExamples::Http.new(*client_args)
98
+ request_headers, request_body = generator.request(sample).split("\n\n", 2)
99
+ request_lines = request_headers.lines(chomp: true)
100
+
101
+ expect(request_lines.shift).to eq('POST /v1/widgets/101/update HTTP/1.1')
102
+ expect(request_lines).to include('Host: api.example.test', 'Content-Type: application/json')
103
+ expect(JSON.parse(request_body)).to eq(
104
+ 'widget' => {
105
+ 'name' => 'documented widget',
106
+ 'enabled' => true
107
+ }
108
+ )
109
+
110
+ response_headers, response_body = generator.response(sample).split("\n\n", 2)
111
+ response_lines = response_headers.lines(chomp: true)
112
+ expect(response_lines.shift).to eq('HTTP/1.1 200 OK')
113
+ content_length = response_lines
114
+ .find { |line| line.start_with?('Content-Length: ') }
115
+ .split(': ', 2)
116
+ .last
117
+ .to_i
118
+ expect(content_length).to eq(response_body.bytesize)
119
+ expect(JSON.parse(response_body)).to include('status' => true)
120
+ end
121
+ end
@@ -56,6 +56,43 @@ describe HaveAPI::Example do
56
56
  { name: 'alpha' }
57
57
  end
58
58
  end
59
+
60
+ define_action(:ValidatedExample) do
61
+ route 'validated/{widget_id}'
62
+ http_method :post
63
+ authorize { allow }
64
+
65
+ input(:hash) do
66
+ string :name, required: true
67
+ integer :count
68
+ bool :active
69
+ datetime :created_at
70
+ custom :metadata
71
+ end
72
+
73
+ output(:hash) do
74
+ integer :count
75
+ bool :accepted
76
+ end
77
+
78
+ # rubocop:disable RSpec/NoExpectationExample
79
+ example 'valid structured data' do
80
+ path_params 101
81
+ request({
82
+ name: 'alpha',
83
+ count: 2,
84
+ active: true,
85
+ created_at: '2026-07-26T10:30:00Z',
86
+ metadata: { source: 'documentation' }
87
+ })
88
+ response({ count: 2 })
89
+ end
90
+ # rubocop:enable RSpec/NoExpectationExample
91
+
92
+ def exec
93
+ { count: input[:count], accepted: true }
94
+ end
95
+ end
59
96
  end
60
97
  end
61
98
 
@@ -94,4 +131,129 @@ describe HaveAPI::Example do
94
131
  expect(api_response[:examples].first[:request]).to be_nil
95
132
  expect(api_response[:examples].first[:response]).to be_nil
96
133
  end
134
+
135
+ describe 'build validation' do
136
+ def example_for(action, path:, title: 'invalid example', &)
137
+ example = HaveAPI::Example.new(title)
138
+ example.instance_eval(&)
139
+ example.validate_build(action, path:, index: 0)
140
+ end
141
+
142
+ def validated_example_action
143
+ self.class.const_get(:ApiModule)::Widget::ValidatedExample
144
+ end
145
+
146
+ it 'accepts valid structured and prose-only examples' do
147
+ expect do
148
+ example_for(
149
+ validated_example_action,
150
+ path: '/v1/widgets/validated/{widget_id}',
151
+ title: 'valid'
152
+ ) do
153
+ path_params 101
154
+ request(name: 'alpha', count: 2, active: false)
155
+ response(count: 2)
156
+ end
157
+ end.not_to raise_error
158
+
159
+ expect do
160
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
161
+ comment 'The prose describes a request that is not represented as structured data.'
162
+ end
163
+ end.not_to raise_error
164
+ end
165
+
166
+ it 'rejects path parameter count and request layout errors' do
167
+ expect do
168
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
169
+ request([{ name: 'alpha' }])
170
+ end
171
+ end.to raise_error(/path_params has 0 values.*requires 1/)
172
+
173
+ expect do
174
+ example_for(validated_example_action, path: '/v1/widgets/validated') do
175
+ request([{ name: 'alpha' }])
176
+ end
177
+ end.to raise_error(/request must be an object/)
178
+ end
179
+
180
+ it 'rejects unknown, missing, and incorrectly typed request parameters' do
181
+ expect do
182
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
183
+ path_params 101
184
+ request(name: 'alpha', mystery: true)
185
+ end
186
+ end.to raise_error(/unknown parameters: mystery/)
187
+
188
+ expect do
189
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
190
+ path_params 101
191
+ request(count: 2)
192
+ end
193
+ end.to raise_error(/missing required parameters: name/)
194
+
195
+ expect do
196
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
197
+ path_params 101
198
+ request(name: 'alpha', count: 'two')
199
+ end
200
+ end.to raise_error(/request.count has invalid Integer value/)
201
+ end
202
+
203
+ it 'allows abbreviated responses but validates present fields and error keys' do
204
+ expect do
205
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
206
+ path_params 101
207
+ request(name: 'alpha')
208
+ response(count: 1)
209
+ end
210
+ end.not_to raise_error
211
+
212
+ expect do
213
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
214
+ path_params 101
215
+ request(name: 'alpha')
216
+ response(accepted: 'yes')
217
+ end
218
+ end.to raise_error(/response.accepted has invalid Boolean value/)
219
+
220
+ expect do
221
+ example_for(validated_example_action, path: '/v1/widgets/validated/{widget_id}') do
222
+ path_params 101
223
+ request(name: 'alpha')
224
+ status false
225
+ errors missing: ['is invalid']
226
+ end
227
+ end.to raise_error(/errors refer to unknown input parameters: missing/)
228
+ end
229
+
230
+ it 'accepts resource IDs and rejects malformed resource objects' do
231
+ output = HaveAPI::Params.new(:output, validated_example_action)
232
+ output.add_block(proc do
233
+ resource HaveAPI::Resources::ActionState, name: :state
234
+ end)
235
+ output.exec
236
+ action = Struct.new(:input, :output) do
237
+ def path_param_names(_path)
238
+ []
239
+ end
240
+
241
+ def to_s
242
+ 'ResourceExampleAction'
243
+ end
244
+ end.new(nil, output)
245
+
246
+ expect do
247
+ example_for(action, path: '/v1/resource-example') do
248
+ response(state: 101)
249
+ end
250
+ end.not_to raise_error
251
+
252
+ expect do
253
+ example_for(action, path: '/v1/resource-example') do
254
+ response(state: { label: 'missing ID' })
255
+ end
256
+ end.to raise_error(/resource object is missing id/)
257
+ end
258
+ end
97
259
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: haveapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.5
4
+ version: 0.29.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Skokan
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.29.5
32
+ version: 0.29.7
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.29.5
39
+ version: 0.29.7
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: i18n
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -325,6 +325,7 @@ files:
325
325
  - spec/authentication/token_spec.rb
326
326
  - spec/authentication/token_version_routes_spec.rb
327
327
  - spec/authorization_spec.rb
328
+ - spec/client_examples/syntax_spec.rb
328
329
  - spec/common_spec.rb
329
330
  - spec/documentation/auth_filtering_spec.rb
330
331
  - spec/documentation/current_user_html_escaping_spec.rb