Skip to main content

keygen_rs/
errors.rs

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}