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.
@@ -0,0 +1,134 @@
1
+ use std::fmt;
2
+
3
+ use crate::runtime::ValidationErrors;
4
+
5
+ /// Failures never include raw request/response values in Display or Debug.
6
+ #[non_exhaustive]
7
+ pub enum Error {
8
+ Configuration {
9
+ reason: &'static str,
10
+ },
11
+ Validation {
12
+ errors: ValidationErrors,
13
+ },
14
+ Encode,
15
+ Transport {
16
+ timeout: bool,
17
+ },
18
+ Http {
19
+ status: u16,
20
+ },
21
+ Api {
22
+ status: u16,
23
+ message: Option<String>,
24
+ errors: ValidationErrors,
25
+ },
26
+ Protocol {
27
+ status: Option<u16>,
28
+ reason: &'static str,
29
+ },
30
+ Decode {
31
+ status: u16,
32
+ },
33
+ ResponseTooLarge {
34
+ limit: usize,
35
+ },
36
+ UnsupportedAuthStep {
37
+ next_action: String,
38
+ },
39
+ }
40
+
41
+ impl Error {
42
+ pub fn validation(name: impl Into<String>, message: impl Into<String>) -> Self {
43
+ let mut errors = ValidationErrors::new();
44
+ errors.insert(name.into(), vec![message.into()]);
45
+ Self::Validation { errors }
46
+ }
47
+
48
+ pub fn http_status(&self) -> Option<u16> {
49
+ match self {
50
+ Self::Http { status } | Self::Api { status, .. } | Self::Decode { status } => {
51
+ Some(*status)
52
+ }
53
+ Self::Protocol { status, .. } => *status,
54
+ _ => None,
55
+ }
56
+ }
57
+
58
+ /// Explicit diagnostic access; server messages can contain sensitive values.
59
+ pub fn server_message(&self) -> Option<&str> {
60
+ match self {
61
+ Self::Api { message, .. } => message.as_deref(),
62
+ _ => None,
63
+ }
64
+ }
65
+
66
+ /// Explicit diagnostic access; validation details can contain sensitive values.
67
+ pub fn validation_errors(&self) -> Option<&ValidationErrors> {
68
+ match self {
69
+ Self::Validation { errors } | Self::Api { errors, .. } => Some(errors),
70
+ _ => None,
71
+ }
72
+ }
73
+
74
+ pub fn is_timeout(&self) -> bool {
75
+ matches!(self, Self::Transport { timeout: true })
76
+ }
77
+
78
+ pub fn unsupported_auth_step(&self) -> Option<&str> {
79
+ match self {
80
+ Self::UnsupportedAuthStep { next_action } => Some(next_action),
81
+ _ => None,
82
+ }
83
+ }
84
+
85
+ pub(crate) fn configuration(reason: &'static str) -> Self {
86
+ Self::Configuration { reason }
87
+ }
88
+
89
+ pub(crate) fn protocol(status: Option<u16>, reason: &'static str) -> Self {
90
+ Self::Protocol { status, reason }
91
+ }
92
+
93
+ pub(crate) fn transport(error: reqwest::Error) -> Self {
94
+ Self::Transport {
95
+ timeout: error.is_timeout(),
96
+ }
97
+ }
98
+ }
99
+
100
+ impl fmt::Display for Error {
101
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102
+ match self {
103
+ Self::Configuration { reason } => write!(f, "invalid client configuration: {reason}"),
104
+ Self::Validation { .. } => f.write_str("input validation failed"),
105
+ Self::Encode => f.write_str("request encoding failed"),
106
+ Self::Transport { timeout: true } => {
107
+ f.write_str("HTTP request timed out; execution may have occurred")
108
+ }
109
+ Self::Transport { timeout: false } => {
110
+ f.write_str("HTTP transport failed; execution may have occurred")
111
+ }
112
+ Self::Http { status } => write!(f, "HTTP request failed with status {status}"),
113
+ Self::Api { status, .. } => {
114
+ write!(f, "HaveAPI action failed with HTTP status {status}")
115
+ }
116
+ Self::Protocol { reason, .. } => write!(f, "invalid HaveAPI response: {reason}"),
117
+ Self::Decode { .. } => f.write_str("HaveAPI response has incompatible field types"),
118
+ Self::ResponseTooLarge { limit } => {
119
+ write!(f, "HTTP response exceeds the {limit} byte limit")
120
+ }
121
+ Self::UnsupportedAuthStep { .. } => {
122
+ f.write_str("unsupported authentication continuation step")
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ impl fmt::Debug for Error {
129
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130
+ fmt::Display::fmt(self, f)
131
+ }
132
+ }
133
+
134
+ impl std::error::Error for Error {}
@@ -0,0 +1,19 @@
1
+ //! Shared HaveAPI protocol and HTTP execution, copied into generated crates.
2
+
3
+ mod auth;
4
+ mod client;
5
+ mod error;
6
+ mod protocol;
7
+ mod transport;
8
+ mod types;
9
+ mod validation;
10
+
11
+ pub use auth::{SecretString, TokenStep};
12
+ pub use client::{Client, ClientBuilder, ClientConfig, Request};
13
+ pub use error::Error;
14
+ pub use protocol::{decode, prepare};
15
+ #[cfg(feature = "blocking")]
16
+ pub use transport::BlockingTransport;
17
+ pub use transport::{AsyncTransport, Mode};
18
+ pub use types::*;
19
+ pub use validation::Validate;
@@ -0,0 +1,421 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+
3
+ use reqwest::header::{CONTENT_TYPE, HeaderValue};
4
+ use serde::de::DeserializeOwned;
5
+ use serde_json::Value;
6
+ use url::Url;
7
+
8
+ use crate::runtime::validation::validate_map;
9
+ use crate::runtime::{
10
+ ActionSpec, ClientConfig, Error, JsonMap, PreparedRequest, RawResponse, RequestParts, Response,
11
+ ValidationErrors,
12
+ };
13
+
14
+ pub fn prepare(
15
+ config: &ClientConfig,
16
+ spec: &ActionSpec,
17
+ parts: RequestParts,
18
+ ) -> Result<PreparedRequest, Error> {
19
+ let mut url = action_url(&config.base, spec.path, &parts.path_params)?;
20
+ let has_global_meta = parts.global_meta.is_some();
21
+ let has_object_meta = parts.object_meta.is_some();
22
+ let mut root = JsonMap::new();
23
+ match spec.input {
24
+ None => {
25
+ if parts.input.is_some() {
26
+ return Err(Error::validation("input", "action has no described input"));
27
+ }
28
+ if parts.object_meta.is_some() {
29
+ return Err(Error::validation(
30
+ "object_meta",
31
+ "requires a named object input",
32
+ ));
33
+ }
34
+ }
35
+ Some(input_spec) => {
36
+ let mut input = parts.input.unwrap_or_else(|| {
37
+ if input_spec.layout.list() {
38
+ Value::Array(Vec::new())
39
+ } else {
40
+ Value::Object(JsonMap::new())
41
+ }
42
+ });
43
+ if input_spec.layout.list() {
44
+ if input_spec.namespace.is_none() {
45
+ return Err(Error::configuration("list input requires a namespace"));
46
+ }
47
+ if spec.method.query() {
48
+ return Err(Error::validation(
49
+ "input",
50
+ "list input is unsupported in query parameters",
51
+ ));
52
+ }
53
+ if parts.object_meta.is_some() {
54
+ return Err(Error::validation(
55
+ "object_meta",
56
+ "per-item input metadata is unsupported",
57
+ ));
58
+ }
59
+ let items = input
60
+ .as_array()
61
+ .ok_or_else(|| Error::validation("input", "expected an object list"))?;
62
+ for (index, item) in items.iter().enumerate() {
63
+ let object = item.as_object().ok_or_else(|| {
64
+ Error::validation(format!("input[{index}]"), "expected an object")
65
+ })?;
66
+ validate_map(object, input_spec.parameters, &format!("input[{index}]."))?;
67
+ }
68
+ } else {
69
+ let object = input
70
+ .as_object_mut()
71
+ .ok_or_else(|| Error::validation("input", "expected an object"))?;
72
+ validate_map(object, input_spec.parameters, "")?;
73
+ if let Some(object_meta) = parts.object_meta {
74
+ let namespace = spec.meta_namespace.ok_or_else(|| {
75
+ Error::validation("object_meta", "metadata namespace is not described")
76
+ })?;
77
+ if input_spec.namespace.is_none() {
78
+ return Err(Error::validation(
79
+ "object_meta",
80
+ "requires a named object input",
81
+ ));
82
+ }
83
+ validate_map(&object_meta, spec.object_meta, "object_meta.")?;
84
+ insert(object, namespace, Value::Object(object_meta))?;
85
+ } else {
86
+ validate_map(&JsonMap::new(), spec.object_meta, "object_meta.")?;
87
+ }
88
+ }
89
+ match input_spec.namespace {
90
+ Some(namespace) => {
91
+ insert(&mut root, namespace, input)?;
92
+ }
93
+ None => {
94
+ root = input.as_object().expect("validated object input").clone();
95
+ }
96
+ }
97
+ }
98
+ }
99
+ if let Some(global_meta) = parts.global_meta {
100
+ let namespace = spec.meta_namespace.ok_or_else(|| {
101
+ Error::validation("global_meta", "metadata namespace is not described")
102
+ })?;
103
+ validate_map(&global_meta, spec.global_meta, "global_meta.")?;
104
+ insert(&mut root, namespace, Value::Object(global_meta))?;
105
+ } else {
106
+ validate_map(&JsonMap::new(), spec.global_meta, "global_meta.")?;
107
+ }
108
+ let mut headers = config.request_headers(spec.auth);
109
+ let body = if spec.method.query() {
110
+ let mut pairs = Vec::new();
111
+ for (name, value) in &root {
112
+ let input_namespace = spec.input.and_then(|input| input.namespace);
113
+ if input_namespace == Some(name.as_str())
114
+ || (has_global_meta && spec.meta_namespace == Some(name.as_str()))
115
+ {
116
+ let object = value
117
+ .as_object()
118
+ .ok_or_else(|| Error::validation(name, "expected an object"))?;
119
+ for (field, value) in object {
120
+ if has_object_meta
121
+ && input_namespace == Some(name.as_str())
122
+ && spec.meta_namespace == Some(field.as_str())
123
+ {
124
+ let metadata = value.as_object().ok_or_else(|| {
125
+ Error::validation("object_meta", "expected an object")
126
+ })?;
127
+ for (key, value) in metadata {
128
+ query_value(&mut pairs, &format!("{name}[{field}][{key}]"), value)?;
129
+ }
130
+ } else {
131
+ query_value(&mut pairs, &format!("{name}[{field}]"), value)?;
132
+ }
133
+ }
134
+ } else {
135
+ query_value(&mut pairs, name, value)?;
136
+ }
137
+ }
138
+ if !pairs.is_empty() {
139
+ url.query_pairs_mut().extend_pairs(pairs);
140
+ }
141
+ None
142
+ } else {
143
+ headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
144
+ Some(serde_json::to_vec(&root).map_err(|_| Error::Encode)?)
145
+ };
146
+ Ok(PreparedRequest {
147
+ method: spec.method,
148
+ url,
149
+ headers,
150
+ body,
151
+ })
152
+ }
153
+
154
+ fn insert(map: &mut JsonMap, name: &str, value: Value) -> Result<(), Error> {
155
+ if map.insert(name.to_owned(), value).is_some() {
156
+ return Err(Error::configuration(
157
+ "input and metadata namespaces collide",
158
+ ));
159
+ }
160
+ Ok(())
161
+ }
162
+
163
+ fn query_value(pairs: &mut Vec<(String, String)>, name: &str, value: &Value) -> Result<(), Error> {
164
+ let text = match value {
165
+ Value::Null => String::new(),
166
+ Value::String(value) => value.clone(),
167
+ Value::Bool(value) => value.to_string(),
168
+ Value::Number(value) => value.to_string(),
169
+ Value::Array(_) | Value::Object(_) => {
170
+ return Err(Error::validation(
171
+ name,
172
+ "nested query values are unsupported",
173
+ ));
174
+ }
175
+ };
176
+ pairs.push((name.to_owned(), text));
177
+ Ok(())
178
+ }
179
+
180
+ pub fn decode<T: DeserializeOwned, M: DeserializeOwned>(
181
+ spec: &ActionSpec,
182
+ response: RawResponse,
183
+ ) -> Result<Response<T, M>, Error> {
184
+ let status = response.status;
185
+ let successful_http = (200..300).contains(&status);
186
+ let envelope = serde_json::from_slice::<Value>(&response.body).ok();
187
+ let object = envelope.as_ref().and_then(Value::as_object);
188
+ if !successful_http {
189
+ if let Some(object) = object
190
+ && object.get("status") == Some(&Value::Bool(false))
191
+ && let Some(error) = api_error(status, object)
192
+ {
193
+ return Err(error);
194
+ }
195
+ return Err(Error::Http { status });
196
+ }
197
+ let object =
198
+ object.ok_or_else(|| Error::protocol(Some(status), "expected a JSON envelope object"))?;
199
+ match object.get("status").and_then(Value::as_bool) {
200
+ Some(false) => {
201
+ return Err(api_error(status, object)
202
+ .unwrap_or_else(|| Error::protocol(Some(status), "invalid failure envelope")));
203
+ }
204
+ Some(true) => (),
205
+ None => {
206
+ return Err(Error::protocol(
207
+ Some(status),
208
+ "missing boolean envelope status",
209
+ ));
210
+ }
211
+ }
212
+ let response = object
213
+ .get("response")
214
+ .and_then(Value::as_object)
215
+ .ok_or_else(|| Error::protocol(Some(status), "missing response object"))?;
216
+ let output = if let Some(output) = spec.output {
217
+ let value = match response.get(output.namespace) {
218
+ Some(value) => value.clone(),
219
+ None if output.allow_empty_response && !output.layout.list() && response.is_empty() => {
220
+ Value::Object(JsonMap::new())
221
+ }
222
+ None => {
223
+ return Err(Error::protocol(
224
+ Some(status),
225
+ "missing declared output namespace",
226
+ ));
227
+ }
228
+ };
229
+ if output.layout.list() {
230
+ if !value
231
+ .as_array()
232
+ .is_some_and(|items| items.iter().all(Value::is_object))
233
+ {
234
+ return Err(Error::Decode { status });
235
+ }
236
+ } else if !value.is_object() {
237
+ return Err(Error::Decode { status });
238
+ }
239
+ value
240
+ } else {
241
+ if !response.is_empty() {
242
+ return Err(Error::protocol(
243
+ Some(status),
244
+ "unexpected output for no-output action",
245
+ ));
246
+ }
247
+ Value::Null
248
+ };
249
+ let metadata = match spec
250
+ .meta_namespace
251
+ .and_then(|namespace| response.get(namespace))
252
+ {
253
+ Some(Value::Object(map)) => Value::Object(map.clone()),
254
+ Some(_) => return Err(Error::Decode { status }),
255
+ None => Value::Object(JsonMap::new()),
256
+ };
257
+ Ok(Response {
258
+ output: serde_json::from_value(output).map_err(|_| Error::Decode { status })?,
259
+ meta: serde_json::from_value(metadata).map_err(|_| Error::Decode { status })?,
260
+ http_status: status,
261
+ })
262
+ }
263
+
264
+ fn api_error(status: u16, object: &JsonMap) -> Option<Error> {
265
+ let message = match object.get("message") {
266
+ None | Some(Value::Null) => None,
267
+ Some(Value::String(message)) => Some(message.clone()),
268
+ _ => return None,
269
+ };
270
+ let errors = match object.get("errors") {
271
+ None | Some(Value::Null) => ValidationErrors::new(),
272
+ Some(value) => serde_json::from_value::<ValidationErrors>(value.clone()).ok()?,
273
+ };
274
+ Some(Error::Api {
275
+ status,
276
+ message,
277
+ errors,
278
+ })
279
+ }
280
+
281
+ pub(crate) fn parse_base(base: &str) -> Result<Url, Error> {
282
+ if base
283
+ .bytes()
284
+ .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace() || byte == b'\\')
285
+ {
286
+ return Err(Error::configuration("invalid server mount URL"));
287
+ }
288
+ let mut parsed =
289
+ Url::parse(base).map_err(|_| Error::configuration("invalid server mount URL"))?;
290
+ if !matches!(parsed.scheme(), "https" | "http")
291
+ || parsed.host_str().is_none()
292
+ || !parsed.username().is_empty()
293
+ || parsed.password().is_some()
294
+ || parsed.query().is_some()
295
+ || parsed.fragment().is_some()
296
+ {
297
+ return Err(Error::configuration(
298
+ "server mount must be an HTTP URL without credentials, query or fragment",
299
+ ));
300
+ }
301
+ // Inspect the original string before Url can normalize dot segments away.
302
+ let after_scheme = base
303
+ .split_once("://")
304
+ .ok_or_else(|| Error::configuration("invalid server mount URL"))?
305
+ .1;
306
+ let authority = after_scheme.split('/').next().unwrap_or_default();
307
+ if authority.is_empty() || authority.contains('@') {
308
+ return Err(Error::configuration("invalid server mount authority"));
309
+ }
310
+ if let Some(start) = after_scheme.find('/') {
311
+ for segment in after_scheme[start..]
312
+ .split('/')
313
+ .filter(|segment| !segment.is_empty())
314
+ {
315
+ safe_literal_segment(segment)?;
316
+ }
317
+ }
318
+ if !parsed.path().ends_with('/') {
319
+ let path = format!("{}/", parsed.path());
320
+ parsed.set_path(&path);
321
+ }
322
+ Ok(parsed)
323
+ }
324
+
325
+ fn action_url(base: &Url, path: &str, params: &BTreeMap<String, String>) -> Result<Url, Error> {
326
+ if path.is_empty()
327
+ || path.starts_with('/')
328
+ || path.contains(['?', '#', '\\'])
329
+ || path
330
+ .bytes()
331
+ .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
332
+ {
333
+ return Err(Error::configuration(
334
+ "action path must be a safe mount-relative template",
335
+ ));
336
+ }
337
+ let mut used = BTreeSet::new();
338
+ let mut url = base.clone();
339
+ {
340
+ let mut segments = url
341
+ .path_segments_mut()
342
+ .map_err(|_| Error::configuration("server URL cannot hold path segments"))?;
343
+ segments.pop_if_empty();
344
+ for segment in path.split('/') {
345
+ if segment.is_empty() {
346
+ return Err(Error::configuration(
347
+ "action path contains an empty segment",
348
+ ));
349
+ }
350
+ if let Some(name) = segment
351
+ .strip_prefix('{')
352
+ .and_then(|value| value.strip_suffix('}'))
353
+ {
354
+ if name.is_empty() || name.contains(['{', '}']) {
355
+ return Err(Error::configuration("invalid path placeholder"));
356
+ }
357
+ let value = params
358
+ .get(name)
359
+ .ok_or_else(|| Error::validation(name, "path parameter is required"))?;
360
+ if value.is_empty()
361
+ || value == "."
362
+ || value == ".."
363
+ || value.chars().any(char::is_control)
364
+ {
365
+ return Err(Error::validation(name, "invalid path segment"));
366
+ }
367
+ used.insert(name);
368
+ segments.push(value);
369
+ } else {
370
+ if segment.contains(['{', '}', ':']) {
371
+ return Err(Error::configuration("invalid action path template"));
372
+ }
373
+ segments.push(&safe_literal_segment(segment)?);
374
+ }
375
+ }
376
+ }
377
+ if params.keys().any(|name| !used.contains(name.as_str())) {
378
+ return Err(Error::validation("path", "unexpected path parameter"));
379
+ }
380
+ // Constructing individual segments never changes the origin or mount.
381
+ if url.origin() != base.origin() || !url.path().starts_with(base.path()) {
382
+ return Err(Error::configuration("action path escapes the server mount"));
383
+ }
384
+ Ok(url)
385
+ }
386
+
387
+ fn safe_literal_segment(segment: &str) -> Result<String, Error> {
388
+ let mut decoded = Vec::new();
389
+ let mut bytes = segment.bytes();
390
+ while let Some(byte) = bytes.next() {
391
+ if byte == b'%' {
392
+ let high = bytes.next().and_then(hex);
393
+ let low = bytes.next().and_then(hex);
394
+ match (high, low) {
395
+ (Some(high), Some(low)) => decoded.push(high * 16 + low),
396
+ _ => return Err(Error::configuration("invalid encoded path segment")),
397
+ }
398
+ } else {
399
+ decoded.push(byte);
400
+ }
401
+ }
402
+ let decoded = String::from_utf8(decoded)
403
+ .map_err(|_| Error::configuration("invalid UTF-8 path segment"))?;
404
+ if decoded == "."
405
+ || decoded == ".."
406
+ || decoded.contains(['/', '\\', '?', '#'])
407
+ || decoded.chars().any(char::is_control)
408
+ {
409
+ return Err(Error::configuration("unsafe path segment"));
410
+ }
411
+ Ok(decoded)
412
+ }
413
+
414
+ fn hex(byte: u8) -> Option<u8> {
415
+ match byte {
416
+ b'0'..=b'9' => Some(byte - b'0'),
417
+ b'a'..=b'f' => Some(byte - b'a' + 10),
418
+ b'A'..=b'F' => Some(byte - b'A' + 10),
419
+ _ => None,
420
+ }
421
+ }