haveapi-client 0.26.5 → 0.27.0

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: e1b98aab9f8e937585fa078d6fa5bc38e68f83ed7b70c800dbaa9a0cf1f4ec28
4
- data.tar.gz: 2df727132132091b6422d8d4dbc858f6911ed21d96b5e82197a7cb43ad955d9c
3
+ metadata.gz: a6231f3629c9e015d7e916dc7cf97466b30849db2a1fe86a521f5fd1b99fbb72
4
+ data.tar.gz: 46cb500d05af0f1bdd25f4a2749d7d459ff8460d1477dea59b64e816162dc9e7
5
5
  SHA512:
6
- metadata.gz: 7baed8b41fd39094bbf765cf6e48d295a7265dd99038cc85a9142a25b34d18369e9676793f396ee5958922d5b0d9a8f7614b41935a4605afb9f35400cef83c6d
7
- data.tar.gz: b1c00e04bcc29a60b32e6425c880752eb7c367380366a96662cd64d8de8534393fb7b30a4edf2e592796017e3a9ee2d926115fa75c8bf7e6f1e55552c3ce6f26
6
+ metadata.gz: 5afb270a053317c22c6a5748739423690743a02a1b3f8e58d1e045d33b4446bd0d96f5427ffc7c37f6cfbb3266b2cfd8801692d9f3c557843fe586e4a60a9535
7
+ data.tar.gz: e42801e205282f72038c3777d819799be33569e63a9864ef13786e7346785cca469dd6128f9c3241f257d098bd089c9f9b3d8d47473f778019b8c30361833207
data/Gemfile CHANGED
@@ -6,3 +6,7 @@ group :development do
6
6
  gem 'bundler'
7
7
  gem 'rake'
8
8
  end
9
+
10
+ group :test do
11
+ gem 'rspec'
12
+ end
@@ -5,7 +5,11 @@ module HaveAPI::Client
5
5
  attr_reader :response
6
6
 
7
7
  def initialize(response)
8
- super("#{response.action.name} failed: #{response.message}")
8
+ if response.respond_to?(:action)
9
+ super("#{response.action.name} failed: #{response.message}")
10
+ else
11
+ super(response.to_s)
12
+ end
9
13
 
10
14
  @response = response
11
15
  end
@@ -18,6 +18,8 @@ module HaveAPI::Client
18
18
  protected
19
19
 
20
20
  def coerce(v)
21
+ return nil if v.nil?
22
+
21
23
  if !v.is_a?(::Integer) && /\A\d+\z/ !~ v
22
24
  @errors << 'not a valid resource id'
23
25
  nil
@@ -2,20 +2,6 @@ require 'date'
2
2
 
3
3
  module HaveAPI::Client
4
4
  class Parameters::Typed
5
- module Boolean
6
- def self.to_b(str)
7
- return true if str === true
8
- return false if str === false
9
-
10
- if str.respond_to?(:=~)
11
- return true if str =~ /^(true|t|yes|y|1)$/i
12
- return false if str =~ /^(false|f|no|n|0)$/i
13
- end
14
-
15
- false
16
- end
17
- end
18
-
19
5
  attr_reader :errors, :value
20
6
 
21
7
  def initialize(params, desc, value)
@@ -26,6 +12,8 @@ module HaveAPI::Client
26
12
  end
27
13
 
28
14
  def valid?
15
+ return false unless @errors.empty?
16
+
29
17
  ret = Validator.validate(@desc[:validators], @value, @params)
30
18
 
31
19
  @errors.concat(ret) unless ret === true
@@ -44,39 +32,146 @@ module HaveAPI::Client
44
32
  protected
45
33
 
46
34
  def coerce(raw)
35
+ return nil if raw.nil?
36
+
47
37
  type = @desc[:type]
48
38
 
49
- if type == 'Integer'
50
- raw.to_i
39
+ case type
40
+ when 'Integer'
41
+ coerce_integer(raw)
42
+ when 'Float'
43
+ coerce_float(raw)
44
+ when 'Boolean'
45
+ coerce_boolean(raw)
46
+ when 'Datetime'
47
+ coerce_datetime(raw)
48
+ when 'String', 'Text'
49
+ coerce_string(raw)
50
+ else
51
+ raw
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def coerce_integer(raw)
58
+ case raw
59
+ when ::Integer
60
+ raw
61
+
62
+ when ::Float
63
+ return raw.to_i if raw.finite? && raw == raw.to_i
64
+
65
+ invalid_integer
66
+
67
+ when ::String
68
+ s = raw.strip
69
+ return invalid_integer if s.empty?
70
+ return invalid_integer unless s.match?(/\A[+-]?\d+\z/)
51
71
 
52
- elsif type == 'Float'
53
- raw.to_f
72
+ Integer(s, 10)
54
73
 
55
- elsif type == 'Boolean'
56
- Boolean.to_b(raw)
74
+ else
75
+ invalid_integer
76
+ end
77
+ rescue ArgumentError
78
+ invalid_integer
79
+ end
57
80
 
58
- elsif type == 'Datetime'
59
- if raw.is_a?(::Time)
60
- raw
81
+ def coerce_float(raw)
82
+ if raw.is_a?(::Numeric)
83
+ value = raw.to_f
84
+ return value if value.finite?
61
85
 
62
- elsif raw.is_a?(::Date) || raw.is_a?(::DateTime)
63
- raw.to_time
86
+ @errors << 'not a valid float'
87
+ nil
64
88
 
65
- else
66
- begin
67
- DateTime.iso8601(raw).to_time
68
- rescue ArgumentError
69
- @errors << 'not in ISO 8601 format'
70
- nil
71
- end
72
- end
89
+ elsif raw.is_a?(::String)
90
+ s = raw.strip
91
+ return invalid_float if s.empty?
73
92
 
74
- elsif %w[String Text].include?(type)
75
- raw.to_s
93
+ value = Float(s)
94
+ return value if value.finite?
95
+
96
+ invalid_float
76
97
 
77
98
  else
99
+ invalid_float
100
+ end
101
+ rescue ArgumentError
102
+ invalid_float
103
+ end
104
+
105
+ def coerce_boolean(raw)
106
+ return raw if [true, false].include?(raw)
107
+
108
+ if raw.is_a?(::Integer)
109
+ return true if raw == 1
110
+ return false if raw == 0
111
+
112
+ return invalid_boolean
113
+ end
114
+
115
+ if raw.is_a?(::String)
116
+ s = raw.strip.downcase
117
+ return invalid_boolean if s.empty?
118
+
119
+ return true if %w[true t yes y 1].include?(s)
120
+ return false if %w[false f no n 0].include?(s)
121
+ end
122
+
123
+ invalid_boolean
124
+ end
125
+
126
+ def coerce_datetime(raw)
127
+ case raw
128
+ when ::Time
78
129
  raw
130
+
131
+ when ::Date, ::DateTime
132
+ raw.to_time
133
+
134
+ when ::String
135
+ return invalid_datetime if raw.strip.empty?
136
+
137
+ DateTime.iso8601(raw).to_time
138
+
139
+ else
140
+ invalid_datetime
79
141
  end
142
+ rescue ArgumentError
143
+ invalid_datetime
144
+ end
145
+
146
+ def coerce_string(raw)
147
+ return invalid_string if raw.is_a?(::Array) || raw.is_a?(::Hash)
148
+
149
+ raw.to_s
150
+ end
151
+
152
+ def invalid_integer
153
+ @errors << 'not a valid integer'
154
+ nil
155
+ end
156
+
157
+ def invalid_float
158
+ @errors << 'not a valid float'
159
+ nil
160
+ end
161
+
162
+ def invalid_boolean
163
+ @errors << 'not a valid boolean'
164
+ nil
165
+ end
166
+
167
+ def invalid_datetime
168
+ @errors << 'not in ISO 8601 format'
169
+ nil
170
+ end
171
+
172
+ def invalid_string
173
+ @errors << 'not a valid string'
174
+ nil
80
175
  end
81
176
  end
82
177
  end
@@ -29,9 +29,12 @@ module HaveAPI::Client
29
29
  @action.input_params.each do |name, p|
30
30
  next if p[:validators].nil?
31
31
 
32
- if p[:validators][:presence] && @params[name].nil?
33
- error(name, 'required parameter missing')
32
+ presence_validator =
33
+ p[:validators][:presence] || p[:validators][:present] || p[:validators][:required]
34
34
 
35
+ if presence_validator && @params[name].nil?
36
+ error(name, 'required parameter missing')
37
+ next
35
38
  elsif @params[name].nil?
36
39
  next
37
40
  end
@@ -1,6 +1,6 @@
1
1
  module HaveAPI
2
2
  module Client
3
3
  PROTOCOL_VERSION = '2.0'.freeze
4
- VERSION = '0.26.5'.freeze
4
+ VERSION = '0.27.0'.freeze
5
5
  end
6
6
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe HaveAPI::Client::Client do
6
+ let(:base_url) { TEST_SERVER.base_url }
7
+
8
+ it 'exposes versions and compatibility' do
9
+ client = described_class.new(base_url)
10
+
11
+ versions = client.versions
12
+ expect(versions[:default]).to eq('1.0')
13
+ expect(versions[:versions]).to include('1.0')
14
+ expect(client.compatible?).to(
15
+ satisfy { |value| [:compatible, :imperfect, false].include?(value) }
16
+ )
17
+ end
18
+
19
+ it 'filters docs until authenticated' do
20
+ unauthenticated = described_class.new(base_url)
21
+ unauthenticated.setup
22
+
23
+ expect { unauthenticated.project }.to raise_error(NoMethodError)
24
+
25
+ authenticated = described_class.new(base_url)
26
+ authenticated.authenticate(:basic, user: 'user', password: 'pass')
27
+
28
+ expect { authenticated.project }.not_to raise_error
29
+ expect(authenticated.project).to be_a(HaveAPI::Client::Resource)
30
+ end
31
+
32
+ it 'supports CRUD flow for projects' do
33
+ client = described_class.new(base_url)
34
+ client.authenticate(:basic, user: 'user', password: 'pass')
35
+
36
+ projects = client.project.list
37
+ expect(projects).to be_a(Array)
38
+ expect(projects.size).to be >= 2
39
+
40
+ project = client.project.find(projects.first.id)
41
+ expect(project.id).to eq(projects.first.id)
42
+
43
+ created = client.project.create(name: 'Gamma')
44
+ expect(created.id).to be_a(Integer)
45
+ expect(created.name).to eq('Gamma')
46
+ end
47
+
48
+ it 'handles nested task resources' do
49
+ client = described_class.new(base_url)
50
+ client.authenticate(:basic, user: 'user', password: 'pass')
51
+
52
+ project = client.project.list.first
53
+ tasks = client.project.task.list(project.id)
54
+ expect(tasks).to be_a(Array)
55
+
56
+ task = client.project(project.id).task.create(label: 'Do it')
57
+ expect(task.id).to be_a(Integer)
58
+ expect(task.label).to eq('Do it')
59
+ end
60
+
61
+ it 'raises validation error on missing required params' do
62
+ client = described_class.new(base_url)
63
+ client.authenticate(:basic, user: 'user', password: 'pass')
64
+
65
+ expect { client.project.create({}) }
66
+ .to raise_error(HaveAPI::Client::ValidationError)
67
+ end
68
+
69
+ it 'supports blocking actions and action state polling' do
70
+ client = described_class.new(base_url)
71
+ client.authenticate(:basic, user: 'user', password: 'pass')
72
+
73
+ project = client.project.create(name: 'Blocky')
74
+ task = client.project(project.id).task.create(label: 'Run me')
75
+
76
+ response = client.project.task.run(project.id, task.id, meta: { block: false })
77
+ expect(response.meta[:action_state_id]).to be_a(Integer)
78
+
79
+ result = response.wait_for_completion(interval: 0.05, update_in: 0.05, timeout: 2)
80
+ expect(result).to be(true)
81
+ end
82
+
83
+ it 'surfaces HaveAPI errors as ActionFailed' do
84
+ client = described_class.new(base_url)
85
+
86
+ expect { client.test.fail }
87
+ .to raise_error(HaveAPI::Client::ActionFailed) do |err|
88
+ expect(err.response.message).to eq('forced failure')
89
+ expect(err.response.errors).to include(:base)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe HaveAPI::Client::Client do
6
+ let(:client) { described_class.new(TEST_SERVER.base_url) }
7
+ let(:valid_params) do
8
+ {
9
+ i: 1,
10
+ f: 1.0,
11
+ b: true,
12
+ dt: '2020-01-01T00:00:00Z',
13
+ s: 'x',
14
+ t: 'y'
15
+ }
16
+ end
17
+
18
+ it 'coerces valid typed inputs' do
19
+ res = client.test.echo(
20
+ i: ' 42 ',
21
+ f: 5,
22
+ b: 'yes',
23
+ dt: '2020-01-01T00:00:00Z',
24
+ s: 123,
25
+ t: false
26
+ )
27
+
28
+ expect(res).to be_a(HaveAPI::Client::Response)
29
+ expect(res[:i]).to eq(42)
30
+ expect(res[:f]).to eq(5.0)
31
+ expect(res[:b]).to be(true)
32
+ expect(res[:dt]).to match(/\A2020-01-01T00:00:00(?:Z|\+00:00)\z/)
33
+ expect(res[:s]).to eq('123')
34
+ expect(res[:t]).to eq('false')
35
+ end
36
+
37
+ it 'accepts exponent float strings' do
38
+ res = client.test.echo(
39
+ i: 1,
40
+ f: '1e3',
41
+ b: true,
42
+ dt: '2020-01-01',
43
+ s: 'ok',
44
+ t: 'ok'
45
+ )
46
+
47
+ expect(res[:f]).to eq(1000.0)
48
+ end
49
+
50
+ it 'rejects invalid integer strings' do
51
+ expect { client.test.echo(valid_params.merge(i: 'abc')) }
52
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
53
+ expect(err.errors).to include(:i)
54
+ expect(err.errors[:i]).to include(a_string_matching(/not a valid integer/))
55
+ end
56
+ end
57
+
58
+ it 'rejects non-integral floats for integers' do
59
+ expect { client.test.echo(valid_params.merge(i: 12.3)) }
60
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
61
+ expect(err.errors).to include(:i)
62
+ expect(err.errors[:i]).to include(a_string_matching(/not a valid integer/))
63
+ end
64
+ end
65
+
66
+ it 'rejects invalid floats' do
67
+ expect { client.test.echo(valid_params.merge(f: 'abc')) }
68
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
69
+ expect(err.errors).to include(:f)
70
+ expect(err.errors[:f]).to include(a_string_matching(/not a valid float/))
71
+ end
72
+ end
73
+
74
+ it 'rejects invalid boolean strings' do
75
+ expect { client.test.echo(valid_params.merge(b: 'maybe')) }
76
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
77
+ expect(err.errors).to include(:b)
78
+ expect(err.errors[:b]).to include(a_string_matching(/not a valid boolean/))
79
+ end
80
+ end
81
+
82
+ it 'rejects invalid boolean integers' do
83
+ expect { client.test.echo(valid_params.merge(b: 2)) }
84
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
85
+ expect(err.errors).to include(:b)
86
+ expect(err.errors[:b]).to include(a_string_matching(/not a valid boolean/))
87
+ end
88
+ end
89
+
90
+ it 'rejects invalid datetimes' do
91
+ expect { client.test.echo(valid_params.merge(dt: 'yesterday')) }
92
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
93
+ expect(err.errors).to include(:dt)
94
+ expect(err.errors[:dt]).to include(a_string_matching(/ISO 8601/))
95
+ end
96
+ end
97
+
98
+ it 'rejects arrays for string params' do
99
+ expect { client.test.echo(valid_params.merge(s: [1, 2])) }
100
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
101
+ expect(err.errors).to include(:s)
102
+ expect(err.errors[:s]).to include(a_string_matching(/not a valid string/))
103
+ end
104
+ end
105
+
106
+ it 'rejects hashes for text params' do
107
+ expect { client.test.echo(valid_params.merge(t: { a: 1 })) }
108
+ .to raise_error(HaveAPI::Client::ValidationError) do |err|
109
+ expect(err.errors).to include(:t)
110
+ expect(err.errors[:t]).to include(a_string_matching(/not a valid string/))
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'haveapi/client'
5
+
6
+ require_relative 'support/test_server'
7
+
8
+ TEST_SERVER = ClientTestServer.new
9
+
10
+ RSpec.configure do |config|
11
+ config.order = :random
12
+
13
+ config.before(:suite) do
14
+ TEST_SERVER.start
15
+ end
16
+
17
+ config.after(:suite) do
18
+ TEST_SERVER.stop!
19
+ end
20
+
21
+ config.before do
22
+ TEST_SERVER.reset!
23
+ end
24
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'net/http'
5
+ require 'timeout'
6
+ require 'uri'
7
+
8
+ class ClientTestServer
9
+ READY_PREFIX = 'HAVEAPI_TEST_SERVER_READY'
10
+
11
+ attr_reader :base_url
12
+
13
+ def initialize
14
+ @root = File.expand_path('../../../..', __dir__)
15
+ @server_script = File.join(@root, 'servers', 'ruby', 'test_support', 'client_test_server.rb')
16
+ @gemfile = File.join(@root, 'servers', 'ruby', 'Gemfile')
17
+ @cwd = File.join(@root, 'clients', 'ruby')
18
+ end
19
+
20
+ def start
21
+ return if @wait_thr
22
+
23
+ env = { 'BUNDLE_GEMFILE' => @gemfile }
24
+ cmd = ['bundle', 'exec', 'ruby', @server_script, '--port', '0']
25
+ @stdin, @stdout, @wait_thr = Open3.popen2e(env, *cmd, chdir: @cwd)
26
+
27
+ read_ready!
28
+ wait_for_health!
29
+ end
30
+
31
+ def reset!
32
+ ensure_started!
33
+
34
+ uri = URI.join(@base_url, '/__reset')
35
+ req = Net::HTTP::Post.new(uri)
36
+ req['Content-Type'] = 'application/json'
37
+ res = Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
38
+
39
+ return if res.is_a?(Net::HTTPSuccess)
40
+
41
+ raise "reset failed: #{res.code} #{res.body}"
42
+ end
43
+
44
+ def stop!
45
+ return unless @wait_thr
46
+
47
+ Process.kill('TERM', @wait_thr.pid)
48
+ @wait_thr.value
49
+ rescue Errno::ESRCH
50
+ nil
51
+ ensure
52
+ @stdin&.close
53
+ @stdout&.close
54
+ @wait_thr = nil
55
+ end
56
+
57
+ private
58
+
59
+ def ensure_started!
60
+ start unless @wait_thr
61
+ end
62
+
63
+ def read_ready!
64
+ Timeout.timeout(10) do
65
+ while (line = @stdout.gets)
66
+ next unless line.include?(READY_PREFIX)
67
+
68
+ @base_url = line.split.last&.strip
69
+ break
70
+ end
71
+ end
72
+
73
+ raise 'server did not start' unless @base_url
74
+ rescue Timeout::Error
75
+ raise 'server did not start in time'
76
+ end
77
+
78
+ def wait_for_health!
79
+ Timeout.timeout(5) do
80
+ loop do
81
+ begin
82
+ uri = URI.join(@base_url, '/__health')
83
+ res = Net::HTTP.get_response(uri)
84
+ return if res.is_a?(Net::HTTPSuccess)
85
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, IOError
86
+ # retry
87
+ end
88
+
89
+ sleep 0.05
90
+ end
91
+ end
92
+ rescue Timeout::Error
93
+ raise 'server did not become healthy in time'
94
+ end
95
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: haveapi-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.5
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Skokan
@@ -151,6 +151,10 @@ files:
151
151
  - lib/haveapi/client/version.rb
152
152
  - lib/restclient_ext/resource.rb
153
153
  - shell.nix
154
+ - spec/integration/client_spec.rb
155
+ - spec/integration/typed_input_spec.rb
156
+ - spec/spec_helper.rb
157
+ - spec/support/test_server.rb
154
158
  homepage: ''
155
159
  licenses:
156
160
  - MIT