haveapi-rust-client 0.30.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 +7 -0
- data/Gemfile.lock +200 -0
- data/LICENSE.txt +21 -0
- data/README.md +81 -0
- data/bin/haveapi-rust-client +3 -0
- data/docs/documentation.md +103 -0
- data/docs/generation.md +149 -0
- data/docs/releasing.md +61 -0
- data/lib/haveapi/rust_client/cli.rb +55 -0
- data/lib/haveapi/rust_client/documentation.rb +358 -0
- data/lib/haveapi/rust_client/generator.rb +307 -0
- data/lib/haveapi/rust_client/ir.rb +195 -0
- data/lib/haveapi/rust_client/naming.rb +46 -0
- data/lib/haveapi/rust_client/snapshot.rb +207 -0
- data/lib/haveapi/rust_client/version.rb +5 -0
- data/lib/haveapi/rust_client.rb +12 -0
- data/runtime/src/runtime/auth.rs +97 -0
- data/runtime/src/runtime/client.rs +380 -0
- data/runtime/src/runtime/error.rs +134 -0
- data/runtime/src/runtime/mod.rs +19 -0
- data/runtime/src/runtime/protocol.rs +421 -0
- data/runtime/src/runtime/transport.rs +152 -0
- data/runtime/src/runtime/types.rs +219 -0
- data/runtime/src/runtime/validation.rs +213 -0
- data/template/Cargo.toml.erb +19 -0
- data/template/action.rs.erb +103 -0
- data/template/mod.rs.erb +30 -0
- data/template/resource.rs.erb +30 -0
- data/template/resources.rs.erb +4 -0
- metadata +98 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'digest'
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module HaveAPI::RustClient
|
|
6
|
+
module Snapshot
|
|
7
|
+
KEYS = %w[schema_format api_version protocol_version source_url profile mount_path description].freeze
|
|
8
|
+
PROSE = %w[description label examples message comment title].freeze
|
|
9
|
+
|
|
10
|
+
def self.canonical(value)
|
|
11
|
+
normalized = case value
|
|
12
|
+
when Hash
|
|
13
|
+
value.keys.map(&:to_s).sort.to_h do |key|
|
|
14
|
+
[key, canonical_value(value.fetch(key))]
|
|
15
|
+
end
|
|
16
|
+
else
|
|
17
|
+
canonical_value(value)
|
|
18
|
+
end
|
|
19
|
+
"#{JSON.pretty_generate(normalized)}\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.canonical_value(value)
|
|
23
|
+
case value
|
|
24
|
+
when Hash
|
|
25
|
+
value.keys.sort.to_h { |key| [key, canonical_value(value.fetch(key))] }
|
|
26
|
+
when Array
|
|
27
|
+
value.map { |item| canonical_value(item) }
|
|
28
|
+
else
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.sha256(snapshot)
|
|
34
|
+
Digest::SHA256.hexdigest(canonical(snapshot))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.load(path)
|
|
38
|
+
validate(JSON.parse(File.read(path)))
|
|
39
|
+
rescue JSON::ParserError
|
|
40
|
+
raise Error, 'snapshot is not valid JSON'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.fetch(url:, api_version:, profile:, diagnostics: [])
|
|
44
|
+
require 'haveapi/client'
|
|
45
|
+
version!(api_version)
|
|
46
|
+
endpoint = endpoint!(url)
|
|
47
|
+
suffix = "/v#{api_version}/"
|
|
48
|
+
raise Error, 'fetch URL must select the exact requested version' unless endpoint.path.end_with?(suffix)
|
|
49
|
+
|
|
50
|
+
mount = endpoint.path.delete_suffix("v#{api_version}/")
|
|
51
|
+
# The shared metadata client owns parsing/protocol compatibility. Keep the
|
|
52
|
+
# explicit version request at the supplied mount and disable redirects.
|
|
53
|
+
root = endpoint.dup
|
|
54
|
+
root.path = mount
|
|
55
|
+
communicator = HaveAPI::Client::Communicator.new(root.to_s)
|
|
56
|
+
communicator.instance_variable_set(
|
|
57
|
+
:@rest,
|
|
58
|
+
RestClient::Resource.new(root.to_s, max_redirects: 0, open_timeout: 10, read_timeout: 30)
|
|
59
|
+
)
|
|
60
|
+
description = communicator.describe_api(api_version)
|
|
61
|
+
from_description(
|
|
62
|
+
description: JSON.parse(JSON.generate(description)),
|
|
63
|
+
api_version:, protocol_version: communicator.instance_variable_get(:@proto_version),
|
|
64
|
+
source_url: url, profile:, mount_path: mount, diagnostics:
|
|
65
|
+
)
|
|
66
|
+
rescue HaveAPI::Client::ProtocolError
|
|
67
|
+
raise Error, 'incompatible HaveAPI protocol version'
|
|
68
|
+
rescue RestClient::Exception, URI::InvalidURIError
|
|
69
|
+
raise Error, 'metadata acquisition failed (HTTP, redirect or URL error)'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.from_envelope(envelope, api_version:, source_url:, profile:, mount_path: '/', diagnostics: [])
|
|
73
|
+
raise Error, 'metadata envelope is not successful' unless envelope['status'] == true
|
|
74
|
+
|
|
75
|
+
from_description(
|
|
76
|
+
description: envelope.fetch('response'), protocol_version: envelope.fetch('version'),
|
|
77
|
+
api_version:, source_url:, profile:, mount_path:, diagnostics:
|
|
78
|
+
)
|
|
79
|
+
rescue KeyError
|
|
80
|
+
raise Error, 'metadata envelope is incomplete'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.from_description(description:, api_version:, protocol_version:, source_url:, profile:, mount_path: '/', diagnostics: [])
|
|
84
|
+
snapshot = {
|
|
85
|
+
'schema_format' => 1, 'api_version' => api_version, 'protocol_version' => protocol_version,
|
|
86
|
+
'source_url' => source_url, 'profile' => profile, 'mount_path' => mount_path,
|
|
87
|
+
'description' => sanitize(description, diagnostics:)
|
|
88
|
+
}
|
|
89
|
+
validate(snapshot)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Prose is removed only from structural description objects. Parameter names,
|
|
93
|
+
# validator choice keys and Custom/default JSON are data, never filter keys.
|
|
94
|
+
def self.sanitize(value, context = :description, diagnostics: [], path: [])
|
|
95
|
+
return value.map { |item| sanitize(item, context, diagnostics:, path:) } if value.is_a?(Array)
|
|
96
|
+
return value unless value.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
value.each_with_object({}) do |(key, item), result|
|
|
99
|
+
next if PROSE.include?(key) && !%i[map data choices].include?(context)
|
|
100
|
+
|
|
101
|
+
if context == :description && key == 'default' && value['protected'] == true && !item.nil?
|
|
102
|
+
diagnostics << "removed protected default: #{path.join('/')}"
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
validate_machine_endpoint(key, item) if context == :description
|
|
107
|
+
child_context = sanitization_context(context, key, item)
|
|
108
|
+
result[key] = if context == :validators && key == 'custom'
|
|
109
|
+
true
|
|
110
|
+
elsif context == :choices
|
|
111
|
+
nil
|
|
112
|
+
else
|
|
113
|
+
sanitize(item, child_context, diagnostics:, path: path + [key])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.validate_machine_endpoint(key, value)
|
|
119
|
+
return if value.nil?
|
|
120
|
+
return endpoint!(value) if key.end_with?('_url')
|
|
121
|
+
return unless %w[path help].include?(key) || key.end_with?('_path')
|
|
122
|
+
|
|
123
|
+
path, query = value.is_a?(String) ? value.split('?', 2) : [nil, nil]
|
|
124
|
+
unless path && path.start_with?('/') && !path.include?('#') && safe_segments?(path) &&
|
|
125
|
+
(query.nil? || (key == 'help' && query.match?(/\Amethod=(?:GET|POST|PUT|PATCH|DELETE|OPTIONS)\z/)))
|
|
126
|
+
raise Error, 'unsafe machine endpoint in API description'
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.sanitization_context(context, key, item)
|
|
131
|
+
return :data if %i[data choices].include?(context)
|
|
132
|
+
return :description if context == :map
|
|
133
|
+
return :map if %w[resources actions parameters authentication].include?(key)
|
|
134
|
+
return :data if %w[default format].include?(key) && context != :validator
|
|
135
|
+
return item.is_a?(Hash) ? :choices : :data if key == 'values'
|
|
136
|
+
return :validators if key == 'validators'
|
|
137
|
+
return :validator if context == :validators
|
|
138
|
+
|
|
139
|
+
:description
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.validate(snapshot)
|
|
143
|
+
raise Error, 'snapshot must be an object' unless snapshot.is_a?(Hash)
|
|
144
|
+
raise Error, 'unsupported snapshot envelope keys' unless snapshot.keys.sort == KEYS.sort
|
|
145
|
+
raise Error, 'unsupported snapshot format' unless snapshot['schema_format'] == 1
|
|
146
|
+
|
|
147
|
+
version!(snapshot['api_version'])
|
|
148
|
+
protocol = snapshot['protocol_version']
|
|
149
|
+
raise Error, 'unsupported HaveAPI protocol major' unless protocol.is_a?(String) && protocol.match?(/\A2\.\d+\z/)
|
|
150
|
+
raise Error, 'profile must be a nonempty label' unless snapshot['profile'].is_a?(String) && !snapshot['profile'].empty?
|
|
151
|
+
|
|
152
|
+
endpoint = endpoint!(snapshot['source_url'])
|
|
153
|
+
mount = snapshot['mount_path']
|
|
154
|
+
unless mount.is_a?(String) && mount.start_with?('/') && mount.end_with?('/') && safe_segments?(mount)
|
|
155
|
+
raise Error, 'invalid capture mount path'
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
expected = "#{mount}v#{snapshot['api_version']}/"
|
|
159
|
+
raise Error, 'snapshot source URL/version mismatch' unless endpoint.path == expected
|
|
160
|
+
|
|
161
|
+
description = snapshot['description']
|
|
162
|
+
unless description.is_a?(Hash) && description['resources'].is_a?(Hash) &&
|
|
163
|
+
description['authentication'].is_a?(Hash) && description['meta'].is_a?(Hash)
|
|
164
|
+
raise Error, 'invalid API description structure'
|
|
165
|
+
end
|
|
166
|
+
raise Error, 'snapshot description/version mismatch' unless description['help'] == expected
|
|
167
|
+
|
|
168
|
+
description.fetch('authentication').each do |provider, config|
|
|
169
|
+
raise Error, "invalid authentication description for #{provider}" unless config.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
config.each do |key, value|
|
|
172
|
+
endpoint!(value) if key.end_with?('_url') && value
|
|
173
|
+
next unless key.end_with?('_path') && value
|
|
174
|
+
next if value.is_a?(String) && value.start_with?('/') && !value.match?(/[?#]/) && safe_segments?(value)
|
|
175
|
+
|
|
176
|
+
raise Error, "unsafe authentication path for #{provider}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
snapshot
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.version!(version)
|
|
183
|
+
return if version.is_a?(String) && version.match?(/\A\d+(?:\.\d+)*\z/)
|
|
184
|
+
|
|
185
|
+
raise Error, 'an explicit numeric API version is required'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.endpoint!(url)
|
|
189
|
+
endpoint = URI.parse(url)
|
|
190
|
+
unless %w[http https].include?(endpoint.scheme) && endpoint.host &&
|
|
191
|
+
endpoint.userinfo.nil? && endpoint.query.nil? && endpoint.fragment.nil? && safe_segments?(endpoint.path)
|
|
192
|
+
raise Error, 'endpoint must be an HTTP URL without credentials, query or fragment'
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
endpoint
|
|
196
|
+
rescue URI::InvalidURIError, TypeError
|
|
197
|
+
raise Error, 'invalid endpoint URL'
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.safe_segments?(path)
|
|
201
|
+
!path.include?('\\') && !path.start_with?('//') && path.split('/').none? do |segment|
|
|
202
|
+
decoded = URI::DEFAULT_PARSER.unescape(segment)
|
|
203
|
+
%w[. ..].include?(decoded) || decoded.match?(%r{[/\\\x00-\x1f\x7f]})
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative 'rust_client/version'
|
|
2
|
+
|
|
3
|
+
module HaveAPI
|
|
4
|
+
module RustClient
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative 'rust_client/snapshot'
|
|
10
|
+
require_relative 'rust_client/naming'
|
|
11
|
+
require_relative 'rust_client/ir'
|
|
12
|
+
require_relative 'rust_client/generator'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
use std::fmt;
|
|
2
|
+
|
|
3
|
+
use crate::runtime::Error;
|
|
4
|
+
|
|
5
|
+
/// A credential requiring explicit access. Cloning does not zeroize its storage.
|
|
6
|
+
#[derive(Clone, PartialEq, Eq)]
|
|
7
|
+
pub struct SecretString(String);
|
|
8
|
+
|
|
9
|
+
impl SecretString {
|
|
10
|
+
pub fn expose_secret(&self) -> &str {
|
|
11
|
+
&self.0
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
impl From<String> for SecretString {
|
|
16
|
+
fn from(value: String) -> Self {
|
|
17
|
+
Self(value)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl From<&str> for SecretString {
|
|
22
|
+
fn from(value: &str) -> Self {
|
|
23
|
+
Self(value.to_owned())
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
impl fmt::Debug for SecretString {
|
|
28
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
29
|
+
f.write_str("[REDACTED]")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
impl fmt::Display for SecretString {
|
|
34
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
35
|
+
f.write_str("[REDACTED]")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[non_exhaustive]
|
|
40
|
+
pub enum TokenStep {
|
|
41
|
+
Complete {
|
|
42
|
+
token: SecretString,
|
|
43
|
+
},
|
|
44
|
+
Challenge {
|
|
45
|
+
token: SecretString,
|
|
46
|
+
next_action: String,
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl TokenStep {
|
|
51
|
+
pub fn from_parts(
|
|
52
|
+
complete: bool,
|
|
53
|
+
token: Option<String>,
|
|
54
|
+
next_action: Option<String>,
|
|
55
|
+
known_steps: &[&str],
|
|
56
|
+
) -> Result<Self, Error> {
|
|
57
|
+
let token = token
|
|
58
|
+
.filter(|token| !token.trim().is_empty())
|
|
59
|
+
.ok_or_else(|| Error::protocol(None, "authentication response is missing its token"))?;
|
|
60
|
+
if complete {
|
|
61
|
+
if next_action
|
|
62
|
+
.as_ref()
|
|
63
|
+
.is_some_and(|action| !action.is_empty())
|
|
64
|
+
{
|
|
65
|
+
return Err(Error::protocol(
|
|
66
|
+
None,
|
|
67
|
+
"completed authentication contains a continuation",
|
|
68
|
+
));
|
|
69
|
+
}
|
|
70
|
+
Ok(Self::Complete {
|
|
71
|
+
token: token.into(),
|
|
72
|
+
})
|
|
73
|
+
} else {
|
|
74
|
+
let next_action = next_action
|
|
75
|
+
.filter(|action| !action.is_empty())
|
|
76
|
+
.ok_or_else(|| {
|
|
77
|
+
Error::protocol(None, "authentication response is missing its continuation")
|
|
78
|
+
})?;
|
|
79
|
+
if !known_steps.contains(&next_action.as_str()) {
|
|
80
|
+
return Err(Error::UnsupportedAuthStep { next_action });
|
|
81
|
+
}
|
|
82
|
+
Ok(Self::Challenge {
|
|
83
|
+
token: token.into(),
|
|
84
|
+
next_action,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
impl fmt::Debug for TokenStep {
|
|
91
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
92
|
+
f.write_str(match self {
|
|
93
|
+
Self::Complete { .. } => "TokenStep::Complete { [REDACTED] }",
|
|
94
|
+
Self::Challenge { .. } => "TokenStep::Challenge { [REDACTED] }",
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
use std::{collections::BTreeMap, fmt, marker::PhantomData, sync::Arc, time::Duration};
|
|
2
|
+
|
|
3
|
+
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
|
|
4
|
+
use serde::{Serialize, de::DeserializeOwned};
|
|
5
|
+
|
|
6
|
+
#[cfg(feature = "blocking")]
|
|
7
|
+
use crate::runtime::BlockingTransport;
|
|
8
|
+
use crate::runtime::{
|
|
9
|
+
ActionSpec, ApiSpec, AsyncTransport, Error, JsonMap, Mode, PreparedRequest, RequestAuth,
|
|
10
|
+
RequestParts, Response, Validate, decode, prepare,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
pub struct ClientConfig {
|
|
14
|
+
pub(crate) base: url::Url,
|
|
15
|
+
pub(crate) headers: HeaderMap,
|
|
16
|
+
pub(crate) auth: Option<(HeaderName, HeaderValue)>,
|
|
17
|
+
pub(crate) timeout: Duration,
|
|
18
|
+
pub(crate) connect_timeout: Duration,
|
|
19
|
+
pub(crate) max_response_bytes: usize,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[derive(Clone)]
|
|
23
|
+
pub struct Client<C: Mode> {
|
|
24
|
+
config: Arc<ClientConfig>,
|
|
25
|
+
transport: C,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
impl<C: Mode> Client<C> {
|
|
29
|
+
pub fn config(&self) -> &ClientConfig {
|
|
30
|
+
&self.config
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
enum Credentials {
|
|
35
|
+
Basic(String, String),
|
|
36
|
+
Token(String),
|
|
37
|
+
OAuth2(String),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pub struct ClientBuilder<C: Mode> {
|
|
41
|
+
base: String,
|
|
42
|
+
api: &'static ApiSpec,
|
|
43
|
+
credentials: Option<Credentials>,
|
|
44
|
+
auth_selections: usize,
|
|
45
|
+
timeout: Duration,
|
|
46
|
+
connect_timeout: Duration,
|
|
47
|
+
max_response_bytes: usize,
|
|
48
|
+
user_agent: String,
|
|
49
|
+
mode: PhantomData<C>,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl<C: Mode> ClientBuilder<C> {
|
|
53
|
+
pub fn new(base: impl AsRef<str>, api: &'static ApiSpec) -> Self {
|
|
54
|
+
Self {
|
|
55
|
+
base: base.as_ref().to_owned(),
|
|
56
|
+
api,
|
|
57
|
+
credentials: None,
|
|
58
|
+
auth_selections: 0,
|
|
59
|
+
timeout: Duration::from_secs(30),
|
|
60
|
+
connect_timeout: Duration::from_secs(10),
|
|
61
|
+
max_response_bytes: 16 * 1024 * 1024,
|
|
62
|
+
user_agent: "haveapi-rust-client".into(),
|
|
63
|
+
mode: PhantomData,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub fn basic_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
|
|
68
|
+
self.credentials = Some(Credentials::Basic(username.into(), password.into()));
|
|
69
|
+
self.auth_selections += 1;
|
|
70
|
+
self
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub fn token(mut self, token: impl Into<String>) -> Self {
|
|
74
|
+
self.credentials = Some(Credentials::Token(token.into()));
|
|
75
|
+
self.auth_selections += 1;
|
|
76
|
+
self
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
pub fn oauth2_token(mut self, token: impl Into<String>) -> Self {
|
|
80
|
+
self.credentials = Some(Credentials::OAuth2(token.into()));
|
|
81
|
+
self.auth_selections += 1;
|
|
82
|
+
self
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pub fn timeout(mut self, timeout: Duration) -> Self {
|
|
86
|
+
self.timeout = timeout;
|
|
87
|
+
self
|
|
88
|
+
}
|
|
89
|
+
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
|
|
90
|
+
self.connect_timeout = timeout;
|
|
91
|
+
self
|
|
92
|
+
}
|
|
93
|
+
pub fn max_response_bytes(mut self, bytes: usize) -> Self {
|
|
94
|
+
self.max_response_bytes = bytes;
|
|
95
|
+
self
|
|
96
|
+
}
|
|
97
|
+
pub fn user_agent(mut self, value: impl Into<String>) -> Self {
|
|
98
|
+
self.user_agent = value.into();
|
|
99
|
+
self
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fn finish(self) -> Result<ClientConfig, Error> {
|
|
103
|
+
if self.api.protocol_major != 2 {
|
|
104
|
+
return Err(Error::configuration("unsupported HaveAPI protocol major"));
|
|
105
|
+
}
|
|
106
|
+
if self.api.api_version.is_empty() {
|
|
107
|
+
return Err(Error::configuration("API version is required"));
|
|
108
|
+
}
|
|
109
|
+
if self.auth_selections > 1 {
|
|
110
|
+
return Err(Error::configuration(
|
|
111
|
+
"select only one authentication method",
|
|
112
|
+
));
|
|
113
|
+
}
|
|
114
|
+
if self.timeout.is_zero() || self.connect_timeout.is_zero() {
|
|
115
|
+
return Err(Error::configuration("HTTP timeouts must be positive"));
|
|
116
|
+
}
|
|
117
|
+
if self.max_response_bytes == 0 {
|
|
118
|
+
return Err(Error::configuration("response size limit must be positive"));
|
|
119
|
+
}
|
|
120
|
+
let base = crate::runtime::protocol::parse_base(&self.base)?;
|
|
121
|
+
let mut headers = HeaderMap::new();
|
|
122
|
+
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
|
123
|
+
headers.insert(
|
|
124
|
+
USER_AGENT,
|
|
125
|
+
HeaderValue::from_str(&self.user_agent)
|
|
126
|
+
.map_err(|_| Error::configuration("invalid user agent"))?,
|
|
127
|
+
);
|
|
128
|
+
let auth = match self.credentials {
|
|
129
|
+
None => None,
|
|
130
|
+
Some(Credentials::Basic(username, password)) => {
|
|
131
|
+
if !self.api.basic_auth {
|
|
132
|
+
return Err(Error::configuration(
|
|
133
|
+
"Basic authentication is not described",
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
if username.contains(':') {
|
|
137
|
+
return Err(Error::configuration(
|
|
138
|
+
"Basic username cannot contain a colon",
|
|
139
|
+
));
|
|
140
|
+
}
|
|
141
|
+
let encoded = base64(format!("{username}:{password}").as_bytes());
|
|
142
|
+
let mut value = HeaderValue::from_str(&format!("Basic {encoded}"))
|
|
143
|
+
.map_err(|_| Error::configuration("invalid Basic authentication"))?;
|
|
144
|
+
value.set_sensitive(true);
|
|
145
|
+
Some((AUTHORIZATION, value))
|
|
146
|
+
}
|
|
147
|
+
Some(Credentials::Token(token)) => Some(token_header(self.api.token_header, token)?),
|
|
148
|
+
Some(Credentials::OAuth2(token)) => Some(token_header(self.api.oauth2_header, token)?),
|
|
149
|
+
};
|
|
150
|
+
Ok(ClientConfig {
|
|
151
|
+
base,
|
|
152
|
+
headers,
|
|
153
|
+
auth,
|
|
154
|
+
timeout: self.timeout,
|
|
155
|
+
connect_timeout: self.connect_timeout,
|
|
156
|
+
max_response_bytes: self.max_response_bytes,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
impl ClientBuilder<AsyncTransport> {
|
|
162
|
+
pub fn build(self) -> Result<Client<AsyncTransport>, Error> {
|
|
163
|
+
let config = self.finish()?;
|
|
164
|
+
let transport = AsyncTransport::new(&config)?;
|
|
165
|
+
Ok(Client {
|
|
166
|
+
config: Arc::new(config),
|
|
167
|
+
transport,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#[cfg(feature = "blocking")]
|
|
173
|
+
impl ClientBuilder<BlockingTransport> {
|
|
174
|
+
/// Build, use, and drop this client outside async runtime threads.
|
|
175
|
+
pub fn build(self) -> Result<Client<BlockingTransport>, Error> {
|
|
176
|
+
let config = self.finish()?;
|
|
177
|
+
let transport = BlockingTransport::new(&config)?;
|
|
178
|
+
Ok(Client {
|
|
179
|
+
config: Arc::new(config),
|
|
180
|
+
transport,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pub struct Request<'a, C: Mode, I, O, M> {
|
|
186
|
+
client: &'a Client<C>,
|
|
187
|
+
spec: &'static ActionSpec,
|
|
188
|
+
path_params: BTreeMap<String, String>,
|
|
189
|
+
input: I,
|
|
190
|
+
global_meta: Option<JsonMap>,
|
|
191
|
+
object_meta: Option<JsonMap>,
|
|
192
|
+
output: PhantomData<(O, M)>,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
impl<'a, C: Mode, I, O, M> Request<'a, C, I, O, M> {
|
|
196
|
+
pub fn new(
|
|
197
|
+
client: &'a Client<C>,
|
|
198
|
+
spec: &'static ActionSpec,
|
|
199
|
+
path_params: BTreeMap<String, String>,
|
|
200
|
+
input: I,
|
|
201
|
+
) -> Self {
|
|
202
|
+
Self {
|
|
203
|
+
client,
|
|
204
|
+
spec,
|
|
205
|
+
path_params,
|
|
206
|
+
input,
|
|
207
|
+
global_meta: None,
|
|
208
|
+
object_meta: None,
|
|
209
|
+
output: PhantomData,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
pub fn input(mut self, input: I) -> Self {
|
|
213
|
+
*self.input_mut() = input;
|
|
214
|
+
self
|
|
215
|
+
}
|
|
216
|
+
pub fn global_meta(mut self, meta: JsonMap) -> Self {
|
|
217
|
+
self.global_meta = Some(meta);
|
|
218
|
+
self
|
|
219
|
+
}
|
|
220
|
+
pub fn object_meta(mut self, meta: JsonMap) -> Self {
|
|
221
|
+
self.object_meta = Some(meta);
|
|
222
|
+
self
|
|
223
|
+
}
|
|
224
|
+
pub(crate) fn input_mut(&mut self) -> &mut I {
|
|
225
|
+
&mut self.input
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
impl<C: Mode, I: Serialize + Validate, O, M> Request<'_, C, I, O, M> {
|
|
230
|
+
fn prepared(self) -> Result<PreparedRequest, Error> {
|
|
231
|
+
self.input.validate()?;
|
|
232
|
+
let input = if self.spec.input.is_some() {
|
|
233
|
+
Some(serde_json::to_value(self.input).map_err(|_| Error::Encode)?)
|
|
234
|
+
} else {
|
|
235
|
+
None
|
|
236
|
+
};
|
|
237
|
+
prepare(
|
|
238
|
+
&self.client.config,
|
|
239
|
+
self.spec,
|
|
240
|
+
RequestParts {
|
|
241
|
+
path_params: self.path_params,
|
|
242
|
+
input,
|
|
243
|
+
global_meta: self.global_meta,
|
|
244
|
+
object_meta: self.object_meta,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
impl<I: Serialize + Validate, O: DeserializeOwned, M: DeserializeOwned>
|
|
251
|
+
Request<'_, AsyncTransport, I, O, M>
|
|
252
|
+
{
|
|
253
|
+
pub async fn send(self) -> Result<Response<O, M>, Error> {
|
|
254
|
+
let client = self.client;
|
|
255
|
+
let spec = self.spec;
|
|
256
|
+
let request = self.prepared()?;
|
|
257
|
+
decode(spec, client.transport.execute(request).await?)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[cfg(feature = "blocking")]
|
|
262
|
+
impl<I: Serialize + Validate, O: DeserializeOwned, M: DeserializeOwned>
|
|
263
|
+
Request<'_, BlockingTransport, I, O, M>
|
|
264
|
+
{
|
|
265
|
+
pub fn send(self) -> Result<Response<O, M>, Error> {
|
|
266
|
+
let client = self.client;
|
|
267
|
+
let spec = self.spec;
|
|
268
|
+
let request = self.prepared()?;
|
|
269
|
+
decode(spec, client.transport.execute(request)?)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fn token_header(header: Option<&str>, token: String) -> Result<(HeaderName, HeaderValue), Error> {
|
|
274
|
+
let name = HeaderName::from_bytes(
|
|
275
|
+
header
|
|
276
|
+
.ok_or_else(|| Error::configuration("requested token provider is not described"))?
|
|
277
|
+
.as_bytes(),
|
|
278
|
+
)
|
|
279
|
+
.map_err(|_| Error::configuration("invalid authentication header name"))?;
|
|
280
|
+
if matches!(
|
|
281
|
+
name.as_str(),
|
|
282
|
+
"host"
|
|
283
|
+
| "content-length"
|
|
284
|
+
| "content-type"
|
|
285
|
+
| "accept"
|
|
286
|
+
| "user-agent"
|
|
287
|
+
| "connection"
|
|
288
|
+
| "transfer-encoding"
|
|
289
|
+
| "proxy-authorization"
|
|
290
|
+
| "proxy-authenticate"
|
|
291
|
+
| "te"
|
|
292
|
+
| "trailer"
|
|
293
|
+
| "upgrade"
|
|
294
|
+
| "cookie"
|
|
295
|
+
| "referer"
|
|
296
|
+
| "location"
|
|
297
|
+
) {
|
|
298
|
+
return Err(Error::configuration("reserved authentication header name"));
|
|
299
|
+
}
|
|
300
|
+
if token.trim().is_empty() {
|
|
301
|
+
return Err(Error::configuration("token cannot be empty"));
|
|
302
|
+
}
|
|
303
|
+
let mut value = HeaderValue::from_str(&token)
|
|
304
|
+
.map_err(|_| Error::configuration("invalid token header value"))?;
|
|
305
|
+
value.set_sensitive(true);
|
|
306
|
+
Ok((name, value))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fn base64(bytes: &[u8]) -> String {
|
|
310
|
+
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
311
|
+
let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
|
312
|
+
for chunk in bytes.chunks(3) {
|
|
313
|
+
let first = chunk[0];
|
|
314
|
+
let second = chunk.get(1).copied().unwrap_or(0);
|
|
315
|
+
let third = chunk.get(2).copied().unwrap_or(0);
|
|
316
|
+
result.push(TABLE[usize::from(first >> 2)] as char);
|
|
317
|
+
result.push(TABLE[usize::from((first & 3) << 4 | second >> 4)] as char);
|
|
318
|
+
result.push(if chunk.len() > 1 {
|
|
319
|
+
TABLE[usize::from((second & 15) << 2 | third >> 6)] as char
|
|
320
|
+
} else {
|
|
321
|
+
'='
|
|
322
|
+
});
|
|
323
|
+
result.push(if chunk.len() > 2 {
|
|
324
|
+
TABLE[usize::from(third & 63)] as char
|
|
325
|
+
} else {
|
|
326
|
+
'='
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
result
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
impl fmt::Debug for ClientConfig {
|
|
333
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
334
|
+
f.write_str("ClientConfig { [REDACTED] }")
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
impl<C: Mode> fmt::Debug for Client<C> {
|
|
338
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
339
|
+
f.write_str("Client { [REDACTED] }")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
impl<C: Mode> fmt::Debug for ClientBuilder<C> {
|
|
343
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
344
|
+
f.write_str("ClientBuilder { [REDACTED] }")
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
impl<C: Mode, I, O, M> fmt::Debug for Request<'_, C, I, O, M> {
|
|
348
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
349
|
+
f.write_str("Request { [REDACTED] }")
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
impl ClientConfig {
|
|
354
|
+
pub(crate) fn request_headers(&self, auth: RequestAuth) -> HeaderMap {
|
|
355
|
+
let mut headers = self.headers.clone();
|
|
356
|
+
if auth == RequestAuth::Configured
|
|
357
|
+
&& let Some((name, value)) = &self.auth
|
|
358
|
+
{
|
|
359
|
+
headers.insert(name.clone(), value.clone());
|
|
360
|
+
}
|
|
361
|
+
headers
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[cfg(test)]
|
|
366
|
+
mod tests {
|
|
367
|
+
use super::*;
|
|
368
|
+
|
|
369
|
+
#[test]
|
|
370
|
+
fn basic_encoding_matches_rfc7617() {
|
|
371
|
+
assert_eq!(
|
|
372
|
+
base64(b"Aladdin:open sesame"),
|
|
373
|
+
"QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
|
|
374
|
+
);
|
|
375
|
+
assert_eq!(base64(b""), "");
|
|
376
|
+
assert_eq!(base64(b"a"), "YQ==");
|
|
377
|
+
assert_eq!(base64(b"ab"), "YWI=");
|
|
378
|
+
assert_eq!(base64(b"abc"), "YWJj");
|
|
379
|
+
}
|
|
380
|
+
}
|