1use reqwest::header::InvalidHeaderValue;
2use reqwest::StatusCode;
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5use serde_urlencoded::ser::Error as UrlEncodedError;
6use std::fmt;
7use thiserror::Error;
8use url::ParseError;
9
10use crate::{license::License, license_file::LicenseFileDataset, machine_file::MachineFileDataset};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum ApiErrorKind {
15 InvalidCredentials,
16 EmailRequired,
17 EmailInvalid,
18 PasswordRequired,
19 PasswordInvalid,
20 PasswordNotSupported,
21 SsoRequired,
22 OtpRequired,
23 OtpInvalid,
24 TokenNotAllowed,
25 TokenFormatInvalid,
26 TokenInvalid,
27 TokenExpired,
28 LicenseSuspended,
29 LicenseExpired,
30 LicenseNotAllowed,
31 LicenseNotActivated,
32 LicenseKeyInvalid,
33 LicenseTokenInvalid,
34 LicenseTooManyMachines,
35 LicenseTooManyCores,
36 LicenseTooManyProcesses,
37 MachineAlreadyActivated,
38 MachineLimitExceeded,
39 ProcessLimitExceeded,
40 ComponentConflict,
41 ComponentAlreadyActivated,
42 ComponentNotActivated,
43 HeartbeatDead,
44 HeartbeatPingFailed,
45 HeartbeatRequired,
46 ValidationFingerprintMissing,
47 ValidationComponentsMissing,
48 ValidationProductMissing,
49 NotFound,
50 Unknown,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55#[non_exhaustive]
56pub struct ApiErrorSource {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub pointer: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub parameter: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub header: Option<String>,
63 #[serde(flatten)]
64 pub extensions: Map<String, Value>,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69#[non_exhaustive]
70pub struct ApiErrorObject {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 id: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 status: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 code: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 title: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 detail: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 source: Option<ApiErrorSource>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 links: Option<Value>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 meta: Option<Value>,
87 #[serde(flatten)]
88 extensions: Map<String, Value>,
89}
90
91impl ApiErrorObject {
92 pub fn id(&self) -> Option<&str> {
93 self.id.as_deref()
94 }
95
96 pub fn status(&self) -> Option<&str> {
97 self.status.as_deref()
98 }
99
100 pub fn code(&self) -> Option<&str> {
101 self.code.as_deref()
102 }
103
104 pub fn title(&self) -> Option<&str> {
105 self.title.as_deref()
106 }
107
108 pub fn detail(&self) -> Option<&str> {
109 self.detail.as_deref()
110 }
111
112 pub fn source(&self) -> Option<&ApiErrorSource> {
113 self.source.as_ref()
114 }
115
116 pub fn links(&self) -> Option<&Value> {
117 self.links.as_ref()
118 }
119
120 pub fn meta(&self) -> Option<&Value> {
121 self.meta.as_ref()
122 }
123
124 pub fn extensions(&self) -> &Map<String, Value> {
125 &self.extensions
126 }
127
128 pub fn kind(&self) -> ApiErrorKind {
129 match self.code() {
130 Some("CREDENTIALS_INVALID") => ApiErrorKind::InvalidCredentials,
131 Some("EMAIL_REQUIRED") => ApiErrorKind::EmailRequired,
132 Some("EMAIL_INVALID") => ApiErrorKind::EmailInvalid,
133 Some("PASSWORD_REQUIRED") => ApiErrorKind::PasswordRequired,
134 Some("PASSWORD_INVALID") => ApiErrorKind::PasswordInvalid,
135 Some("PASSWORD_NOT_SUPPORTED") => ApiErrorKind::PasswordNotSupported,
136 Some("SSO_REQUIRED") => ApiErrorKind::SsoRequired,
137 Some("OTP_REQUIRED") => ApiErrorKind::OtpRequired,
138 Some("OTP_INVALID") => ApiErrorKind::OtpInvalid,
139 Some("TOKEN_NOT_ALLOWED") => ApiErrorKind::TokenNotAllowed,
140 Some("TOKEN_FORMAT_INVALID") => ApiErrorKind::TokenFormatInvalid,
141 Some("TOKEN_INVALID") => ApiErrorKind::TokenInvalid,
142 Some("TOKEN_EXPIRED") => ApiErrorKind::TokenExpired,
143 Some("LICENSE_SUSPENDED") => ApiErrorKind::LicenseSuspended,
144 Some("LICENSE_EXPIRED") => ApiErrorKind::LicenseExpired,
145 Some("LICENSE_NOT_ALLOWED") => ApiErrorKind::LicenseNotAllowed,
146 Some("LICENSE_NOT_ACTIVATED") => ApiErrorKind::LicenseNotActivated,
147 Some("LICENSE_KEY_INVALID") => ApiErrorKind::LicenseKeyInvalid,
148 Some("LICENSE_TOKEN_INVALID") => ApiErrorKind::LicenseTokenInvalid,
149 Some("LICENSE_TOO_MANY_MACHINES") => ApiErrorKind::LicenseTooManyMachines,
150 Some("LICENSE_TOO_MANY_CORES") => ApiErrorKind::LicenseTooManyCores,
151 Some("LICENSE_TOO_MANY_PROCESSES") => ApiErrorKind::LicenseTooManyProcesses,
152 Some("MACHINE_ALREADY_ACTIVATED") => ApiErrorKind::MachineAlreadyActivated,
153 Some("MACHINE_LIMIT_EXCEEDED") => ApiErrorKind::MachineLimitExceeded,
154 Some("PROCESS_LIMIT_EXCEEDED") => ApiErrorKind::ProcessLimitExceeded,
155 Some("COMPONENT_CONFLICT") => ApiErrorKind::ComponentConflict,
156 Some("COMPONENT_ALREADY_ACTIVATED") => ApiErrorKind::ComponentAlreadyActivated,
157 Some("COMPONENT_NOT_ACTIVATED") => ApiErrorKind::ComponentNotActivated,
158 Some("HEARTBEAT_DEAD") => ApiErrorKind::HeartbeatDead,
159 Some("HEARTBEAT_PING_FAILED") => ApiErrorKind::HeartbeatPingFailed,
160 Some("HEARTBEAT_REQUIRED") => ApiErrorKind::HeartbeatRequired,
161 Some("VALIDATION_FINGERPRINT_SCOPE_MISSING") => {
162 ApiErrorKind::ValidationFingerprintMissing
163 }
164 Some("VALIDATION_COMPONENTS_SCOPE_MISSING") => {
165 ApiErrorKind::ValidationComponentsMissing
166 }
167 Some("VALIDATION_PRODUCT_SCOPE_MISSING") => ApiErrorKind::ValidationProductMissing,
168 Some("NOT_FOUND") => ApiErrorKind::NotFound,
169 _ => ApiErrorKind::Unknown,
170 }
171 }
172}
173
174#[derive(Debug, Clone)]
175#[non_exhaustive]
176pub struct ApiErrorDocument {
177 status: StatusCode,
178 errors: Vec<ApiErrorObject>,
179 body: Value,
180}
181
182impl ApiErrorDocument {
183 pub(crate) fn from_response(status: StatusCode, body: Value) -> Self {
184 #[derive(Deserialize)]
185 struct ErrorEnvelope {
186 #[serde(default)]
187 errors: Vec<ApiErrorObject>,
188 }
189
190 let errors = serde_json::from_value::<ErrorEnvelope>(body.clone())
191 .map(|envelope| envelope.errors)
192 .unwrap_or_default();
193
194 Self {
195 status,
196 errors,
197 body,
198 }
199 }
200
201 pub fn status(&self) -> StatusCode {
202 self.status
203 }
204
205 pub fn errors(&self) -> &[ApiErrorObject] {
206 &self.errors
207 }
208
209 pub fn primary(&self) -> Option<&ApiErrorObject> {
210 self.errors.first()
211 }
212
213 pub fn kind(&self) -> ApiErrorKind {
214 self.primary()
215 .map(ApiErrorObject::kind)
216 .unwrap_or(ApiErrorKind::Unknown)
217 }
218
219 pub fn contains_kind(&self, expected: ApiErrorKind) -> bool {
220 self.errors.iter().any(|error| error.kind() == expected)
221 }
222
223 pub fn has_code(&self, expected: &str) -> bool {
224 self.errors
225 .iter()
226 .any(|error| error.code() == Some(expected))
227 }
228
229 pub fn body(&self) -> &Value {
230 &self.body
231 }
232}
233
234impl fmt::Display for ApiErrorDocument {
235 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
236 if let Some(error) = self.primary() {
237 let code = error.code().unwrap_or("API_ERROR");
238 let detail = error.detail().unwrap_or("unknown API error");
239 write!(formatter, "Keygen API error {code}: {detail}")
240 } else {
241 write!(formatter, "Keygen API error with status {}", self.status)
242 }
243 }
244}
245
246#[derive(Error, Debug)]
247#[non_exhaustive]
248pub enum Error {
249 #[error("Unexpected error: {0}")]
250 UnexpectedError(String),
251
252 #[error("Invalid URL")]
253 InvalidUrl,
254
255 #[error("System clock is out of sync")]
256 SystemClockUnsynced,
257
258 #[error("Decryption error: {0}")]
259 DecryptionError(String),
260
261 #[error("HTTP client error: {0}")]
262 HttpClient(#[from] reqwest::Error),
263
264 #[error("URL parse error: {0}")]
265 UrlParse(#[from] ParseError),
266
267 #[error("JSON error: {0}")]
268 JsonError(#[from] serde_json::Error),
269
270 #[error("Invalid API response: {0}")]
271 InvalidResponse(String),
272
273 #[error("Invalid header value: {0}")]
274 InvalidHeader(#[from] InvalidHeaderValue),
275
276 #[error("URL encoding error: {0}")]
277 UrlEncode(#[from] UrlEncodedError),
278
279 #[error("Unsupported Keygen API version: {0}")]
280 UnsupportedApiVersion(String),
281
282 #[error("{0}")]
283 Api(ApiErrorDocument),
284
285 #[error("Rate limit exceeded")]
286 RateLimitExceeded {
287 window: String,
288 count: u32,
289 limit: u32,
290 remaining: u32,
291 reset: u64,
292 retry_after: u64,
293 },
294
295 #[error("License key is missing")]
296 LicenseKeyMissing,
297
298 #[error("License scheme is missing")]
299 LicenseSchemeMissing,
300
301 #[error("License scheme is not supported")]
302 LicenseSchemeNotSupported,
303
304 #[error("License is not signed")]
305 LicenseNotSigned,
306
307 #[error("License key is not genuine")]
308 LicenseKeyNotGenuine,
309
310 #[error("Public key is missing")]
311 PublicKeyMissing,
312
313 #[error("Public key is invalid")]
314 PublicKeyInvalid,
315
316 #[error("License scheme unsupported")]
317 LicenseSchemeUnsupported,
318
319 #[error("{0}")]
320 CertificateFileInvalid(String),
321
322 #[error("{0}")]
323 CertificateFileNotGenuine(String),
324
325 #[error("{0}")]
326 CertificateFileNotSupported(String),
327
328 #[error("Certificate file expired")]
329 CertificateFileExpired,
330
331 #[error("License file invalid: {0}")]
332 LicenseFileInvalid(String),
333
334 #[error("License file not genuine: {0}")]
335 LicenseFileNotGenuine(String),
336
337 #[error("License file not supported: {0}")]
338 LicenseFileNotSupported(String),
339
340 #[error("License file algorithm mismatch: expected {expected}, got {actual}")]
341 LicenseFileAlgorithmMismatch { expected: String, actual: String },
342
343 #[error("License file not encrypted")]
344 LicenseFileNotEncrypted,
345
346 #[error("License file expired")]
347 LicenseFileExpired(Box<LicenseFileDataset>),
348
349 #[error("Machine file invalid")]
350 MachineFileInvalid(String),
351
352 #[error("Machine file not genuine")]
353 MachineFileNotGenuine(String),
354
355 #[error("Machine file not supported")]
356 MachineFileNotSupported(String),
357
358 #[error("License file expired")]
359 MachineFileExpired(Box<MachineFileDataset>),
360
361 #[error("API error: {detail}")]
362 KeygenApiError {
363 code: String,
364 detail: String,
365 body: serde_json::Value,
366 },
367
368 #[error("Token not allowed")]
369 TokenNotAllowed { code: String, detail: String },
370
371 #[error("Token format invalid")]
372 TokenFormatInvalid { code: String, detail: String },
373
374 #[error("Token invalid")]
375 TokenInvalid { code: String, detail: String },
376
377 #[error("Token expired")]
378 TokenExpired { code: String, detail: String },
379
380 #[error("License suspended")]
381 LicenseSuspended { code: String, detail: String },
382
383 #[error("License expired")]
384 LicenseExpired { code: String, detail: String },
385
386 #[error("License not allowed")]
387 LicenseNotAllowed { code: String, detail: String },
388
389 #[error("License not activated")]
390 LicenseNotActivated {
391 code: String,
392 detail: String,
393 license: Box<License>,
394 },
395
396 #[error("License key invalid")]
397 LicenseKeyInvalid { code: String, detail: String },
398
399 #[error("License token invalid")]
400 LicenseTokenInvalid { code: String, detail: String },
401
402 #[error("License has too many machines")]
403 LicenseTooManyMachines { code: String, detail: String },
404
405 #[error("License has too many cores")]
406 LicenseTooManyCores { code: String, detail: String },
407
408 #[error("License has too many processes")]
409 LicenseTooManyProcesses { code: String, detail: String },
410
411 #[error("Machine already activated")]
412 MachineAlreadyActivated { code: String, detail: String },
413
414 #[error("Machine limit exceeded")]
415 MachineLimitExceeded { code: String, detail: String },
416
417 #[error("Machine no longer exists")]
418 MachineNotFound,
419
420 #[error("Process limit exceeded")]
421 ProcessLimitExceeded { code: String, detail: String },
422
423 #[error("Process no longer exists")]
424 ProcessNotFound,
425
426 #[error("Component conflict")]
427 ComponentConflict { code: String, detail: String },
428
429 #[error("Component already activated")]
430 ComponentAlreadyActivated { code: String, detail: String },
431
432 #[error("Component is not activated")]
433 ComponentNotActivated { code: String, detail: String },
434
435 #[error("Environment error")]
436 EnvironmentError { code: String, detail: String },
437
438 #[error("Heartbeat dead")]
439 HeartbeatDead { code: String, detail: String },
440
441 #[error("Heartbeat ping failed")]
442 HeartbeatPingFailed { code: String, detail: String },
443
444 #[error("Heartbeat is required")]
445 HeartbeatRequired { code: String, detail: String },
446
447 #[error("Validation fingerprint scope is missing")]
448 ValidationFingerprintMissing { code: String, detail: String },
449
450 #[error("Validation components scope is missing")]
451 ValidationComponentsMissing { code: String, detail: String },
452
453 #[error("Validation product scope is missing")]
454 ValidationProductMissing { code: String, detail: String },
455
456 #[error("Not found")]
457 NotFound { code: String, detail: String },
458
459 #[error("Keygen signature validation failed: {reason}")]
460 KeygenSignatureInvalid { reason: String },
461
462 #[error("Keygen signature missing")]
463 KeygenSignatureMissing,
464
465 #[error("Configuration missing")]
466 MissingConfiguration,
467}
468
469impl Error {
470 pub fn api_error(&self) -> Option<&ApiErrorDocument> {
471 match self {
472 Self::Api(document) => Some(document),
473 _ => None,
474 }
475 }
476
477 pub fn is_api_code(&self, expected: &str) -> bool {
478 self.api_error()
479 .is_some_and(|document| document.has_code(expected))
480 }
481
482 pub fn api_error_kind(&self) -> ApiErrorKind {
483 match self {
484 Self::Api(document) => document.kind(),
485 Self::TokenNotAllowed { .. } => ApiErrorKind::TokenNotAllowed,
486 Self::TokenFormatInvalid { .. } => ApiErrorKind::TokenFormatInvalid,
487 Self::TokenInvalid { .. } => ApiErrorKind::TokenInvalid,
488 Self::TokenExpired { .. } => ApiErrorKind::TokenExpired,
489 Self::LicenseSuspended { .. } => ApiErrorKind::LicenseSuspended,
490 Self::LicenseExpired { .. } => ApiErrorKind::LicenseExpired,
491 Self::LicenseNotAllowed { .. } => ApiErrorKind::LicenseNotAllowed,
492 Self::LicenseNotActivated { .. } => ApiErrorKind::LicenseNotActivated,
493 Self::LicenseKeyInvalid { .. } => ApiErrorKind::LicenseKeyInvalid,
494 Self::LicenseTokenInvalid { .. } => ApiErrorKind::LicenseTokenInvalid,
495 Self::LicenseTooManyMachines { .. } => ApiErrorKind::LicenseTooManyMachines,
496 Self::LicenseTooManyCores { .. } => ApiErrorKind::LicenseTooManyCores,
497 Self::LicenseTooManyProcesses { .. } => ApiErrorKind::LicenseTooManyProcesses,
498 Self::MachineAlreadyActivated { .. } => ApiErrorKind::MachineAlreadyActivated,
499 Self::MachineLimitExceeded { .. } => ApiErrorKind::MachineLimitExceeded,
500 Self::ProcessLimitExceeded { .. } => ApiErrorKind::ProcessLimitExceeded,
501 Self::ComponentConflict { .. } => ApiErrorKind::ComponentConflict,
502 Self::ComponentAlreadyActivated { .. } => ApiErrorKind::ComponentAlreadyActivated,
503 Self::ComponentNotActivated { .. } => ApiErrorKind::ComponentNotActivated,
504 Self::HeartbeatDead { .. } => ApiErrorKind::HeartbeatDead,
505 Self::HeartbeatPingFailed { .. } => ApiErrorKind::HeartbeatPingFailed,
506 Self::HeartbeatRequired { .. } => ApiErrorKind::HeartbeatRequired,
507 Self::ValidationFingerprintMissing { .. } => ApiErrorKind::ValidationFingerprintMissing,
508 Self::ValidationComponentsMissing { .. } => ApiErrorKind::ValidationComponentsMissing,
509 Self::ValidationProductMissing { .. } => ApiErrorKind::ValidationProductMissing,
510 Self::NotFound { .. } | Self::MachineNotFound | Self::ProcessNotFound => {
511 ApiErrorKind::NotFound
512 }
513 _ => ApiErrorKind::Unknown,
514 }
515 }
516}
517
518pub trait ErrorMeta {
519 fn code(&self) -> String;
520 fn detail(&self) -> String;
521}
522
523impl ErrorMeta for Error {
524 fn code(&self) -> String {
525 match self {
526 Error::Api(document) => document
527 .primary()
528 .and_then(ApiErrorObject::code)
529 .unwrap_or("API_ERROR")
530 .to_string(),
531 Error::KeygenApiError { code, .. }
532 | Error::TokenNotAllowed { code, .. }
533 | Error::TokenFormatInvalid { code, .. }
534 | Error::TokenInvalid { code, .. }
535 | Error::TokenExpired { code, .. }
536 | Error::LicenseSuspended { code, .. }
537 | Error::LicenseExpired { code, .. }
538 | Error::LicenseNotAllowed { code, .. }
539 | Error::LicenseNotActivated { code, .. }
540 | Error::LicenseKeyInvalid { code, .. }
541 | Error::LicenseTokenInvalid { code, .. }
542 | Error::LicenseTooManyMachines { code, .. }
543 | Error::LicenseTooManyCores { code, .. }
544 | Error::LicenseTooManyProcesses { code, .. }
545 | Error::MachineAlreadyActivated { code, .. }
546 | Error::MachineLimitExceeded { code, .. }
547 | Error::ProcessLimitExceeded { code, .. }
548 | Error::ComponentConflict { code, .. }
549 | Error::ComponentAlreadyActivated { code, .. }
550 | Error::ComponentNotActivated { code, .. }
551 | Error::EnvironmentError { code, .. }
552 | Error::HeartbeatDead { code, .. }
553 | Error::HeartbeatPingFailed { code, .. }
554 | Error::HeartbeatRequired { code, .. }
555 | Error::ValidationFingerprintMissing { code, .. }
556 | Error::ValidationComponentsMissing { code, .. }
557 | Error::ValidationProductMissing { code, .. }
558 | Error::NotFound { code, .. } => code.to_string(),
559 _ => "ERROR".to_string(),
560 }
561 }
562
563 fn detail(&self) -> String {
564 match self {
565 Error::Api(document) => document
566 .primary()
567 .and_then(ApiErrorObject::detail)
568 .map(str::to_owned)
569 .unwrap_or_else(|| document.to_string()),
570 Error::KeygenApiError { detail, .. }
571 | Error::TokenNotAllowed { detail, .. }
572 | Error::TokenFormatInvalid { detail, .. }
573 | Error::TokenInvalid { detail, .. }
574 | Error::TokenExpired { detail, .. }
575 | Error::LicenseSuspended { detail, .. }
576 | Error::LicenseExpired { detail, .. }
577 | Error::LicenseNotAllowed { detail, .. }
578 | Error::LicenseNotActivated { detail, .. }
579 | Error::LicenseKeyInvalid { detail, .. }
580 | Error::LicenseTokenInvalid { detail, .. }
581 | Error::LicenseTooManyMachines { detail, .. }
582 | Error::LicenseTooManyCores { detail, .. }
583 | Error::LicenseTooManyProcesses { detail, .. }
584 | Error::MachineAlreadyActivated { detail, .. }
585 | Error::MachineLimitExceeded { detail, .. }
586 | Error::ProcessLimitExceeded { detail, .. }
587 | Error::ComponentConflict { detail, .. }
588 | Error::ComponentAlreadyActivated { detail, .. }
589 | Error::ComponentNotActivated { detail, .. }
590 | Error::EnvironmentError { detail, .. }
591 | Error::HeartbeatDead { detail, .. }
592 | Error::HeartbeatPingFailed { detail, .. }
593 | Error::HeartbeatRequired { detail, .. }
594 | Error::ValidationFingerprintMissing { detail, .. }
595 | Error::ValidationComponentsMissing { detail, .. }
596 | Error::ValidationProductMissing { detail, .. }
597 | Error::NotFound { detail, .. } => detail.to_string(),
598 _ => self.to_string(),
599 }
600 }
601}
602
603#[cfg(test)]
604mod api_error_tests {
605 use super::*;
606 use reqwest::StatusCode;
607 use serde_json::json;
608
609 #[test]
610 fn classifies_v1_7_and_v1_8_credential_errors_without_losing_raw_codes() {
611 let v1_7 = ApiErrorDocument::from_response(
612 StatusCode::UNAUTHORIZED,
613 json!({
614 "errors": [{
615 "code": "CREDENTIALS_INVALID",
616 "detail": "email and password must be valid"
617 }]
618 }),
619 );
620 let v1_8 = ApiErrorDocument::from_response(
621 StatusCode::UNAUTHORIZED,
622 json!({
623 "errors": [{
624 "code": "PASSWORD_REQUIRED",
625 "detail": "password is required",
626 "meta": { "attempt": 1 },
627 "futureField": true
628 }]
629 }),
630 );
631
632 assert_eq!(v1_7.kind(), ApiErrorKind::InvalidCredentials);
633 assert_eq!(v1_8.kind(), ApiErrorKind::PasswordRequired);
634 assert_eq!(
635 v1_7.primary().and_then(|error| error.code()),
636 Some("CREDENTIALS_INVALID")
637 );
638 assert_eq!(
639 v1_8.primary().and_then(|error| error.code()),
640 Some("PASSWORD_REQUIRED")
641 );
642 assert_eq!(v1_8.errors()[0].extensions()["futureField"], true);
643 }
644
645 #[test]
646 fn preserves_multiple_and_unknown_api_errors() {
647 let document = ApiErrorDocument::from_response(
648 StatusCode::BAD_REQUEST,
649 json!({
650 "errors": [
651 { "code": "FUTURE_ERROR", "detail": "first" },
652 { "detail": "second" }
653 ],
654 "meta": { "requestId": "req-1" }
655 }),
656 );
657
658 assert_eq!(document.kind(), ApiErrorKind::Unknown);
659 assert_eq!(document.errors().len(), 2);
660 assert_eq!(document.body()["meta"]["requestId"], "req-1");
661 }
662
663 #[test]
664 fn classifies_license_errors_and_searches_all_error_objects() {
665 let document = ApiErrorDocument::from_response(
666 StatusCode::UNPROCESSABLE_ENTITY,
667 json!({
668 "errors": [
669 { "code": "FUTURE_ERROR", "detail": "first" },
670 { "code": "LICENSE_SUSPENDED", "detail": "second" }
671 ]
672 }),
673 );
674
675 assert_eq!(document.kind(), ApiErrorKind::Unknown);
676 assert!(document.contains_kind(ApiErrorKind::LicenseSuspended));
677 assert!(document.has_code("LICENSE_SUSPENDED"));
678 assert!(!document.has_code("LICENSE_EXPIRED"));
679 }
680
681 #[test]
682 fn legacy_and_json_api_license_errors_share_semantic_kinds() {
683 let legacy = Error::LicenseExpired {
684 code: "LICENSE_EXPIRED".into(),
685 detail: "expired".into(),
686 };
687 let document = ApiErrorDocument::from_response(
688 StatusCode::UNPROCESSABLE_ENTITY,
689 json!({
690 "errors": [{
691 "code": "LICENSE_EXPIRED",
692 "detail": "expired"
693 }]
694 }),
695 );
696
697 assert_eq!(legacy.api_error_kind(), ApiErrorKind::LicenseExpired);
698 assert_eq!(
699 Error::Api(document).api_error_kind(),
700 ApiErrorKind::LicenseExpired
701 );
702 }
703}