Skip to main content

progenitor_middleware_impl/
lib.rs

1// Copyright 2025 Oxide Computer Company
2
3//! Core implementation for the progenitor OpenAPI client generator.
4
5#![deny(missing_docs)]
6
7use std::collections::{BTreeMap, HashMap, HashSet};
8
9use openapiv3::OpenAPI;
10use proc_macro2::TokenStream;
11use quote::quote;
12use serde::Deserialize;
13use thiserror::Error;
14use typify::{TypeSpace, TypeSpaceSettings};
15
16use crate::to_schema::ToSchema;
17
18pub use typify::CrateVers;
19pub use typify::TypeSpaceImpl as TypeImpl;
20pub use typify::TypeSpacePatch as TypePatch;
21pub use typify::UnknownPolicy;
22
23mod cli;
24mod httpmock;
25mod method;
26mod template;
27mod to_schema;
28mod util;
29
30#[allow(missing_docs)]
31#[derive(Error, Debug)]
32pub enum Error {
33    #[error("unexpected value type {0}: {1}")]
34    BadValue(String, serde_json::Value),
35    #[error("type error {0}")]
36    TypeError(#[from] typify::Error),
37    #[error("unexpected or unhandled format in the OpenAPI document {0}")]
38    UnexpectedFormat(String),
39    #[error("invalid operation path {0}")]
40    InvalidPath(String),
41    #[error("invalid dropshot extension use: {0}")]
42    InvalidExtension(String),
43    #[error("internal error {0}")]
44    InternalError(String),
45}
46
47#[allow(missing_docs)]
48pub type Result<T> = std::result::Result<T, Error>;
49
50/// OpenAPI generator.
51pub struct Generator {
52    type_space: TypeSpace,
53    settings: GenerationSettings,
54    uses_futures: bool,
55    uses_websockets: bool,
56}
57
58/// Settings for [Generator].
59#[derive(Default, Clone)]
60pub struct GenerationSettings {
61    interface: InterfaceStyle,
62    tag: TagStyle,
63    inner_type: Option<TokenStream>,
64    pre_hook: Option<TokenStream>,
65    pre_hook_async: Option<TokenStream>,
66    post_hook: Option<TokenStream>,
67    post_hook_async: Option<TokenStream>,
68    extra_derives: Vec<String>,
69
70    map_type: Option<String>,
71    unknown_crates: UnknownPolicy,
72    crates: BTreeMap<String, CrateSpec>,
73
74    patch: HashMap<String, TypePatch>,
75    replace: HashMap<String, (String, Vec<TypeImpl>)>,
76    convert: Vec<(schemars::schema::SchemaObject, String, Vec<TypeImpl>)>,
77}
78
79#[derive(Debug, Clone)]
80struct CrateSpec {
81    version: CrateVers,
82    rename: Option<String>,
83}
84
85/// Style of generated client.
86#[derive(Clone, Deserialize, PartialEq, Eq)]
87pub enum InterfaceStyle {
88    /// Use positional style.
89    Positional,
90    /// Use builder style.
91    Builder,
92}
93
94impl Default for InterfaceStyle {
95    fn default() -> Self {
96        Self::Positional
97    }
98}
99
100/// Style for using the OpenAPI tags when generating names in the client.
101#[derive(Clone, Deserialize)]
102pub enum TagStyle {
103    /// Merge tags to create names in the generated client.
104    Merged,
105    /// Use each tag name to create separate names in the generated client.
106    Separate,
107}
108
109impl Default for TagStyle {
110    fn default() -> Self {
111        Self::Merged
112    }
113}
114
115impl GenerationSettings {
116    /// Create new generator settings with default values.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Set the [InterfaceStyle].
122    pub fn with_interface(&mut self, interface: InterfaceStyle) -> &mut Self {
123        self.interface = interface;
124        self
125    }
126
127    /// Set the [TagStyle].
128    pub fn with_tag(&mut self, tag: TagStyle) -> &mut Self {
129        self.tag = tag;
130        self
131    }
132
133    /// Client inner type available to pre and post hooks.
134    pub fn with_inner_type(&mut self, inner_type: TokenStream) -> &mut Self {
135        self.inner_type = Some(inner_type);
136        self
137    }
138
139    /// Hook invoked before issuing the HTTP request.
140    pub fn with_pre_hook(&mut self, pre_hook: TokenStream) -> &mut Self {
141        self.pre_hook = Some(pre_hook);
142        self
143    }
144
145    /// Hook invoked before issuing the HTTP request.
146    pub fn with_pre_hook_async(&mut self, pre_hook: TokenStream) -> &mut Self {
147        self.pre_hook_async = Some(pre_hook);
148        self
149    }
150
151    /// Hook invoked prior to receiving the HTTP response.
152    pub fn with_post_hook(&mut self, post_hook: TokenStream) -> &mut Self {
153        self.post_hook = Some(post_hook);
154        self
155    }
156
157    /// Hook invoked prior to receiving the HTTP response.
158    pub fn with_post_hook_async(&mut self, post_hook: TokenStream) -> &mut Self {
159        self.post_hook_async = Some(post_hook);
160        self
161    }
162
163    /// Additional derive macros applied to generated types.
164    pub fn with_derive(&mut self, derive: impl ToString) -> &mut Self {
165        self.extra_derives.push(derive.to_string());
166        self
167    }
168
169    /// Modify a type with the given name.
170    /// See [typify::TypeSpaceSettings::with_patch].
171    pub fn with_patch<S: AsRef<str>>(&mut self, type_name: S, patch: &TypePatch) -> &mut Self {
172        self.patch
173            .insert(type_name.as_ref().to_string(), patch.clone());
174        self
175    }
176
177    /// Replace a referenced type with a named type.
178    /// See [typify::TypeSpaceSettings::with_replacement].
179    pub fn with_replacement<TS: ToString, RS: ToString, I: Iterator<Item = TypeImpl>>(
180        &mut self,
181        type_name: TS,
182        replace_name: RS,
183        impls: I,
184    ) -> &mut Self {
185        self.replace.insert(
186            type_name.to_string(),
187            (replace_name.to_string(), impls.collect()),
188        );
189        self
190    }
191
192    /// Replace a given schema with a named type.
193    /// See [typify::TypeSpaceSettings::with_conversion].
194    pub fn with_conversion<S: ToString, I: Iterator<Item = TypeImpl>>(
195        &mut self,
196        schema: schemars::schema::SchemaObject,
197        type_name: S,
198        impls: I,
199    ) -> &mut Self {
200        self.convert
201            .push((schema, type_name.to_string(), impls.collect()));
202        self
203    }
204
205    /// Policy regarding crates referenced by the schema extension
206    /// `x-rust-type` not explicitly specified via [Self::with_crate].
207    /// See [typify::TypeSpaceSettings::with_unknown_crates].
208    pub fn with_unknown_crates(&mut self, policy: UnknownPolicy) -> &mut Self {
209        self.unknown_crates = policy;
210        self
211    }
212
213    /// Explicitly named crates whose types may be used during generation
214    /// rather than generating new types based on their schemas (base on the
215    /// presence of the x-rust-type extension).
216    /// See [typify::TypeSpaceSettings::with_crate].
217    pub fn with_crate<S1: ToString>(
218        &mut self,
219        crate_name: S1,
220        version: CrateVers,
221        rename: Option<&String>,
222    ) -> &mut Self {
223        self.crates.insert(
224            crate_name.to_string(),
225            CrateSpec {
226                version,
227                rename: rename.cloned(),
228            },
229        );
230        self
231    }
232
233    /// Set the type used for key-value maps. Common examples:
234    /// - [`std::collections::HashMap`] - **Default**
235    /// - [`std::collections::BTreeMap`]
236    /// - [`indexmap::IndexMap`]
237    ///
238    /// The requiremnets for a map type can be found in the
239    /// [typify::TypeSpaceSettings::with_map_type] documentation.
240    pub fn with_map_type<MT: ToString>(&mut self, map_type: MT) -> &mut Self {
241        self.map_type = Some(map_type.to_string());
242        self
243    }
244}
245
246impl Default for Generator {
247    fn default() -> Self {
248        Self {
249            type_space: TypeSpace::new(TypeSpaceSettings::default().with_type_mod("types")),
250            settings: Default::default(),
251            uses_futures: Default::default(),
252            uses_websockets: Default::default(),
253        }
254    }
255}
256
257impl Generator {
258    /// Create a new generator with default values.
259    pub fn new(settings: &GenerationSettings) -> Self {
260        let mut type_settings = TypeSpaceSettings::default();
261        type_settings
262            .with_type_mod("types")
263            .with_struct_builder(settings.interface == InterfaceStyle::Builder);
264        settings.extra_derives.iter().for_each(|derive| {
265            let _ = type_settings.with_derive(derive.clone());
266        });
267
268        // Control use of crates found in x-rust-type extension
269        type_settings.with_unknown_crates(settings.unknown_crates);
270        settings
271            .crates
272            .iter()
273            .for_each(|(crate_name, CrateSpec { version, rename })| {
274                type_settings.with_crate(crate_name, version.clone(), rename.as_ref());
275            });
276
277        // Adjust generation by type, name, or schema.
278        settings.patch.iter().for_each(|(type_name, patch)| {
279            type_settings.with_patch(type_name, patch);
280        });
281        settings
282            .replace
283            .iter()
284            .for_each(|(type_name, (replace_name, impls))| {
285                type_settings.with_replacement(type_name, replace_name, impls.iter().cloned());
286            });
287        settings
288            .convert
289            .iter()
290            .for_each(|(schema, type_name, impls)| {
291                type_settings.with_conversion(schema.clone(), type_name, impls.iter().cloned());
292            });
293
294        // Set the map type if specified.
295        if let Some(map_type) = &settings.map_type {
296            type_settings.with_map_type(map_type.clone());
297        }
298
299        Self {
300            type_space: TypeSpace::new(&type_settings),
301            settings: settings.clone(),
302            uses_futures: false,
303            uses_websockets: false,
304        }
305    }
306
307    /// Emit a [TokenStream] containing the generated client code.
308    pub fn generate_tokens(&mut self, spec: &OpenAPI) -> Result<TokenStream> {
309        validate_openapi(spec)?;
310
311        // Convert our components dictionary to schemars
312        let schemas = spec.components.iter().flat_map(|components| {
313            components
314                .schemas
315                .iter()
316                .map(|(name, ref_or_schema)| (name.clone(), ref_or_schema.to_schema()))
317        });
318
319        self.type_space.add_ref_types(schemas)?;
320
321        let raw_methods = spec
322            .paths
323            .iter()
324            .flat_map(|(path, ref_or_item)| {
325                // Exclude externally defined path items.
326                let item = ref_or_item.as_item().unwrap();
327                item.iter().map(move |(method, operation)| {
328                    (path.as_str(), method, operation, &item.parameters)
329                })
330            })
331            .map(|(path, method, operation, path_parameters)| {
332                self.process_operation(operation, &spec.components, path, method, path_parameters)
333            })
334            .collect::<Result<Vec<_>>>()?;
335
336        let operation_code = match (&self.settings.interface, &self.settings.tag) {
337            (InterfaceStyle::Positional, TagStyle::Merged) => self
338                .generate_tokens_positional_merged(
339                    &raw_methods,
340                    self.settings.inner_type.is_some(),
341                ),
342            (InterfaceStyle::Positional, TagStyle::Separate) => {
343                unimplemented!("positional arguments with separate tags are currently unsupported")
344            }
345            (InterfaceStyle::Builder, TagStyle::Merged) => self
346                .generate_tokens_builder_merged(&raw_methods, self.settings.inner_type.is_some()),
347            (InterfaceStyle::Builder, TagStyle::Separate) => {
348                let tag_info = spec
349                    .tags
350                    .iter()
351                    .map(|tag| (&tag.name, tag))
352                    .collect::<BTreeMap<_, _>>();
353                self.generate_tokens_builder_separate(
354                    &raw_methods,
355                    tag_info,
356                    self.settings.inner_type.is_some(),
357                )
358            }
359        }?;
360
361        // Generate error enums for operations with multiple error types
362        let error_enums = self.generate_error_enums(&raw_methods);
363
364        let types = self.type_space.to_stream();
365
366        let (inner_type, inner_fn_value) = match self.settings.inner_type.as_ref() {
367            Some(inner_type) => (inner_type.clone(), quote! { &self.inner }),
368            None => (quote! { () }, quote! { &() }),
369        };
370
371        let inner_property = self.settings.inner_type.as_ref().map(|inner| {
372            quote! {
373                pub (crate) inner: #inner,
374            }
375        });
376        let inner_parameter = self.settings.inner_type.as_ref().map(|inner| {
377            quote! {
378                inner: #inner,
379            }
380        });
381        let inner_value = self.settings.inner_type.as_ref().map(|_| {
382            quote! {
383                inner
384            }
385        });
386
387        let client_docstring = {
388            let mut s = format!("Client for {}", spec.info.title);
389
390            if let Some(ss) = &spec.info.description {
391                s.push_str("\n\n");
392                s.push_str(ss);
393            }
394            if let Some(ss) = &spec.info.terms_of_service {
395                s.push_str("\n\n");
396                s.push_str(ss);
397            }
398
399            s.push_str(&format!("\n\nVersion: {}", &spec.info.version));
400
401            s
402        };
403
404        let version_str = &spec.info.version;
405
406        // The allow(unused_imports) on the `pub use` is necessary with Rust
407        // 1.76+, in case the generated file is not at the top level of the
408        // crate.
409
410        let file = quote! {
411            // Re-export types that are used by the public interface of Client.
412            #[allow(unused_imports)]
413            pub use progenitor_middleware_client::{
414                ByteStream,
415                ClientInfo,
416                Error,
417                ResponseValue,
418            };
419            #[allow(unused_imports)]
420            use progenitor_middleware_client::{
421                encode_path,
422                ClientHooks,
423                OperationInfo,
424                RequestBuilderExt,
425            };
426
427            /// Types used as operation parameters and responses.
428            #[allow(clippy::all)]
429            pub mod types {
430                #[allow(unused_imports)]
431                use super::{ByteStream, ResponseValue};
432
433                #types
434
435                // Error enums for operations with multiple error response types
436                #(#error_enums)*
437            }
438
439            #[derive(Clone, Debug)]
440            #[doc = #client_docstring]
441            pub struct Client {
442                pub(crate) baseurl: String,
443                pub(crate) client: reqwest_middleware::ClientWithMiddleware,
444                #inner_property
445            }
446
447            impl Client {
448                /// Create a new client.
449                ///
450                /// `baseurl` is the base URL provided to the internal
451                /// `reqwest::Client`, and should include a scheme and hostname,
452                /// as well as port and a path stem if applicable.
453                pub fn new(
454                    baseurl: &str,
455                    #inner_parameter
456                ) -> Self {
457                    #[cfg(not(target_arch = "wasm32"))]
458                    let client = {
459                        let dur = std::time::Duration::from_secs(15);
460
461                        let reqwest_client = reqwest::ClientBuilder::new()
462                            .connect_timeout(dur)
463                            .timeout(dur)
464                            .build()
465                            .unwrap();
466
467                        reqwest_middleware::ClientBuilder::new(reqwest_client)
468                            .build()
469                    };
470                    #[cfg(target_arch = "wasm32")]
471                    let client = {
472                        let reqwest_client = reqwest::ClientBuilder::new()
473                            .build()
474                            .unwrap();
475
476                        reqwest_middleware::ClientBuilder::new(reqwest_client)
477                            .build()
478                    };
479
480                    Self::new_with_client(baseurl, client, #inner_value)
481                }
482
483                /// Construct a new client with an existing `reqwest_middleware::ClientWithMiddleware`,
484                /// allowing more control over its configuration.
485                ///
486                /// `baseurl` is the base URL provided to the internal
487                /// `reqwest_middleware::ClientWithMiddleware`, and should include a scheme and hostname,
488                /// as well as port and a path stem if applicable.
489                pub fn new_with_client(
490                    baseurl: &str,
491                    client: reqwest_middleware::ClientWithMiddleware,
492                    #inner_parameter
493                ) -> Self {
494                    Self {
495                        baseurl: baseurl.to_string(),
496                        client,
497                        #inner_value
498                    }
499                }
500            }
501
502            impl ClientInfo<#inner_type> for Client {
503                fn api_version() -> &'static str {
504                    #version_str
505                }
506
507                fn baseurl(&self) -> &str {
508                    self.baseurl.as_str()
509                }
510
511                fn client(&self) -> &reqwest_middleware::ClientWithMiddleware {
512                    &self.client
513                }
514
515                fn inner(&self) -> &#inner_type {
516                    #inner_fn_value
517                }
518            }
519
520            impl ClientHooks<#inner_type> for &Client {}
521
522            #operation_code
523        };
524
525        Ok(file)
526    }
527
528    fn generate_tokens_positional_merged(
529        &mut self,
530        input_methods: &[method::OperationMethod],
531        has_inner: bool,
532    ) -> Result<TokenStream> {
533        let methods = input_methods
534            .iter()
535            .map(|method| self.positional_method(method, has_inner))
536            .collect::<Result<Vec<_>>>()?;
537
538        // The allow(unused_imports) on the `pub use` is necessary with Rust
539        // 1.76+, in case the generated file is not at the top level of the
540        // crate.
541
542        let out = quote! {
543            #[allow(clippy::all)]
544            impl Client {
545                #(#methods)*
546            }
547
548            /// Items consumers will typically use such as the Client.
549            pub mod prelude {
550                #[allow(unused_imports)]
551                pub use super::Client;
552            }
553        };
554        Ok(out)
555    }
556
557    /// Generate error enum types for operations with multiple error response types.
558    fn generate_error_enums(&self, methods: &[method::OperationMethod]) -> Vec<TokenStream> {
559        methods
560            .iter()
561            .filter_map(|method| {
562                // Extract error responses to determine if we need an enum
563                let (_, error_response_type) = self.extract_responses(
564                    method,
565                    method::OperationResponseStatus::is_error_or_default,
566                );
567
568                match error_response_type {
569                    method::ErrorResponseType::Multiple {
570                        enum_name,
571                        variants,
572                    } => Some(self.generate_error_enum(&enum_name, &variants)),
573                    method::ErrorResponseType::Single(_) => None,
574                }
575            })
576            .collect()
577    }
578
579    fn generate_tokens_builder_merged(
580        &mut self,
581        input_methods: &[method::OperationMethod],
582        has_inner: bool,
583    ) -> Result<TokenStream> {
584        let builder_struct = input_methods
585            .iter()
586            .map(|method| self.builder_struct(method, TagStyle::Merged, has_inner))
587            .collect::<Result<Vec<_>>>()?;
588
589        let builder_methods = input_methods
590            .iter()
591            .map(|method| self.builder_impl(method))
592            .collect::<Vec<_>>();
593
594        let out = quote! {
595            impl Client {
596                #(#builder_methods)*
597            }
598
599            /// Types for composing operation parameters.
600            #[allow(clippy::all)]
601            pub mod builder {
602                use super::types;
603                #[allow(unused_imports)]
604                use super::{
605                    encode_path,
606                    ByteStream,
607                    ClientInfo,
608                    ClientHooks,
609                    Error,
610                    OperationInfo,
611                    RequestBuilderExt,
612                    ResponseValue,
613                };
614
615                #(#builder_struct)*
616            }
617
618            /// Items consumers will typically use such as the Client.
619            pub mod prelude {
620                pub use self::super::Client;
621            }
622        };
623
624        Ok(out)
625    }
626
627    fn generate_tokens_builder_separate(
628        &mut self,
629        input_methods: &[method::OperationMethod],
630        tag_info: BTreeMap<&String, &openapiv3::Tag>,
631        has_inner: bool,
632    ) -> Result<TokenStream> {
633        let builder_struct = input_methods
634            .iter()
635            .map(|method| self.builder_struct(method, TagStyle::Separate, has_inner))
636            .collect::<Result<Vec<_>>>()?;
637
638        let (traits_and_impls, trait_preludes) = self.builder_tags(input_methods, &tag_info);
639
640        // The allow(unused_imports) on the `pub use` is necessary with Rust
641        // 1.76+, in case the generated file is not at the top level of the
642        // crate.
643
644        let out = quote! {
645            #traits_and_impls
646
647            /// Types for composing operation parameters.
648            #[allow(clippy::all)]
649            pub mod builder {
650                use super::types;
651                #[allow(unused_imports)]
652                use super::{
653                    encode_path,
654                    ByteStream,
655                    ClientInfo,
656                    ClientHooks,
657                    Error,
658                    OperationInfo,
659                    RequestBuilderExt,
660                    ResponseValue,
661                };
662
663                #(#builder_struct)*
664            }
665
666            /// Items consumers will typically use such as the Client and
667            /// extension traits.
668            pub mod prelude {
669                #[allow(unused_imports)]
670                pub use super::Client;
671                #trait_preludes
672            }
673        };
674
675        Ok(out)
676    }
677
678    /// Get the [TypeSpace] for schemas present in the OpenAPI specification.
679    pub fn get_type_space(&self) -> &TypeSpace {
680        &self.type_space
681    }
682
683    /// Whether the generated client needs to use additional crates to support
684    /// futures.
685    pub fn uses_futures(&self) -> bool {
686        self.uses_futures
687    }
688
689    /// Whether the generated client needs to use additional crates to support
690    /// websockets.
691    pub fn uses_websockets(&self) -> bool {
692        self.uses_websockets
693    }
694}
695
696/// Add newlines after end-braces at <= two levels of indentation.
697pub fn space_out_items(content: String) -> Result<String> {
698    Ok(if cfg!(not(windows)) {
699        let regex = regex::Regex::new(r#"(\n\s*})(\n\s{0,8}[^} ])"#).unwrap();
700        regex.replace_all(&content, "$1\n$2").to_string()
701    } else {
702        let regex = regex::Regex::new(r#"(\n\s*})(\r\n\s{0,8}[^} ])"#).unwrap();
703        regex.replace_all(&content, "$1\r\n$2").to_string()
704    })
705}
706
707fn validate_openapi_spec_version(spec_version: &str) -> Result<()> {
708    // progenitor currenlty only support OAS 3.0.x
709    if spec_version.trim().starts_with("3.") {
710        Ok(())
711    } else {
712        Err(Error::UnexpectedFormat(format!(
713            "invalid version: {}",
714            spec_version
715        )))
716    }
717}
718
719/// Do some very basic checks of the OpenAPI documents.
720pub fn validate_openapi(spec: &OpenAPI) -> Result<()> {
721    validate_openapi_spec_version(spec.openapi.as_str())?;
722
723    let mut opids = HashSet::new();
724    spec.paths.paths.iter().try_for_each(|p| {
725        match p.1 {
726            openapiv3::ReferenceOr::Reference { reference: _ } => Err(Error::UnexpectedFormat(
727                format!("path {} uses reference, unsupported", p.0,),
728            )),
729            openapiv3::ReferenceOr::Item(item) => {
730                // Make sure every operation has an operation ID, and that each
731                // operation ID is only used once in the document.
732                item.iter().try_for_each(|(_, o)| {
733                    if let Some(oid) = o.operation_id.as_ref() {
734                        if !opids.insert(oid.to_string()) {
735                            return Err(Error::UnexpectedFormat(format!(
736                                "duplicate operation ID: {}",
737                                oid,
738                            )));
739                        }
740                    } else {
741                        return Err(Error::UnexpectedFormat(format!(
742                            "path {} is missing operation ID",
743                            p.0,
744                        )));
745                    }
746                    Ok(())
747                })
748            }
749        }
750    })?;
751
752    Ok(())
753}
754
755#[cfg(test)]
756mod tests {
757    use serde_json::json;
758
759    use crate::{validate_openapi_spec_version, Error};
760
761    #[test]
762    fn test_bad_value() {
763        assert_eq!(
764            Error::BadValue("nope".to_string(), json! { "nope"},).to_string(),
765            "unexpected value type nope: \"nope\"",
766        );
767    }
768
769    #[test]
770    fn test_type_error() {
771        assert_eq!(
772            Error::UnexpectedFormat("nope".to_string()).to_string(),
773            "unexpected or unhandled format in the OpenAPI document nope",
774        );
775    }
776
777    #[test]
778    fn test_invalid_path() {
779        assert_eq!(
780            Error::InvalidPath("nope".to_string()).to_string(),
781            "invalid operation path nope",
782        );
783    }
784
785    #[test]
786    fn test_internal_error() {
787        assert_eq!(
788            Error::InternalError("nope".to_string()).to_string(),
789            "internal error nope",
790        );
791    }
792
793    #[test]
794    fn test_validate_openapi_spec_version() {
795        assert!(validate_openapi_spec_version("3.0.0").is_ok());
796        assert!(validate_openapi_spec_version("3.0.1").is_ok());
797        assert!(validate_openapi_spec_version("3.0.4").is_ok());
798        assert!(validate_openapi_spec_version("3.0.5-draft").is_ok());
799        assert_eq!(
800            validate_openapi_spec_version("3.1.0")
801                .unwrap_err()
802                .to_string(),
803            "unexpected or unhandled format in the OpenAPI document invalid version: 3.1.0"
804        );
805    }
806}