diff --git a/Cargo.toml b/Cargo.toml index 7182202f..a979fa8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "utoipa" description = "Compile time generated OpenAPI documentation for Rust" -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT OR Apache-2.0" readme = "README.md" @@ -10,6 +10,10 @@ keywords = ["rest-api", "openapi", "auto-generate", "documentation", "compile-ti # homepage = "" repository = "https://github.com/juhaku/utoipa" categories = ["web-programming"] +authors = [ + "Juha Kukkonen " +] + exclude = [ ".git*", ".github" @@ -24,7 +28,7 @@ json = ["serde_json", "utoipa-gen/json"] [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", optional = true } -utoipa-gen = { version = "0.1.0", path = "./utoipa-gen" } +utoipa-gen = { version = "0.1.1", path = "./utoipa-gen" } [dev-dependencies] actix-web = { version = "4" } diff --git a/src/openapi/schema.rs b/src/openapi/schema.rs index 7ee92ead..c2274188 100644 --- a/src/openapi/schema.rs +++ b/src/openapi/schema.rs @@ -64,10 +64,10 @@ impl Components { ..Default::default() } } - /// Add [`SecuritySchema`] to [`Components`] + /// Add [`SecurityScheme`] to [`Components`] /// - /// Accepts two arguments where first is the name of the [`SecuritySchema`]. This is later when - /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecuritySchema`]. + /// Accepts two arguments where first is the name of the [`SecurityScheme`]. This is later when + /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecurityScheme`]. /// /// [requirement]: ../security/struct.SecurityRequirement.html pub fn add_security_scheme, S: Into>( @@ -79,10 +79,10 @@ impl Components { .insert(name.into(), security_schema.into()); } - /// Add iterator of [`SecuritySchema`]s to [`Components`]. + /// Add iterator of [`SecurityScheme`]s to [`Components`]. /// - /// Accepts two arguments where first is the name of the [`SecuritySchema`]. This is later when - /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecuritySchema`]. + /// Accepts two arguments where first is the name of the [`SecurityScheme`]. This is later when + /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecurityScheme`]. /// /// [requirement]: ../security/struct.SecurityRequirement.html pub fn add_security_schemes_from_iter< @@ -111,10 +111,10 @@ impl ComponentsBuilder { self } - /// Add [`SecuritySchema`] to [`Components`]. + /// Add [`SecurityScheme`] to [`Components`]. /// - /// Accepts two arguments where first is the name of the [`SecuritySchema`]. This is later when - /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecuritySchema`]. + /// Accepts two arguments where first is the name of the [`SecurityScheme`]. This is later when + /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecurityScheme`]. /// /// [requirement]: ../security/struct.SecurityRequirement.html pub fn security_schema, S: Into>( diff --git a/src/openapi/security.rs b/src/openapi/security.rs index 32ca6406..d5de1416 100644 --- a/src/openapi/security.rs +++ b/src/openapi/security.rs @@ -1,6 +1,6 @@ //! Implements [OpenAPI Security Schema][security] types. //! -//! Refer to [`SecuritySchema`] for usage and more details. +//! Refer to [`SecurityScheme`] for usage and more details. //! //! [security]: https://spec.openapis.org/oas/latest.html#security-scheme-object use std::{collections::HashMap, iter}; @@ -11,7 +11,7 @@ use super::{build_fn, builder, from, new}; /// OpenAPI [security requirment][security] object. /// -/// Security requirement holds list of required [`SecuritySchema`] *names* and possible *scopes* required +/// Security requirement holds list of required [`SecurityScheme`] *names* and possible *scopes* required /// to execute the operation. They can be defined in [`#[utoipa::path(...)]`][path] or in `#[openapi(...)]` /// of [`OpenApi`][openapi]. /// @@ -33,9 +33,9 @@ pub struct SecurityRequirement { impl SecurityRequirement { /// Construct a new [`SecurityRequirement`] /// - /// Accepts name for the security requirement which must match to the name of available [`SecuritySchema`]. + /// Accepts name for the security requirement which must match to the name of available [`SecurityScheme`]. /// Second parameter is [`IntoIterator`] of [`Into`] scopes needed by the [`SecurityRequirement`]. - /// Scopes must match to the ones defined in [`SecuritySchema`]. + /// Scopes must match to the ones defined in [`SecurityScheme`]. /// /// # Examples /// @@ -120,7 +120,7 @@ pub enum SecurityScheme { }, } -/// Api key authentication [`SecuritySchema`]. +/// Api key authentication [`SecurityScheme`]. #[derive(Serialize, Deserialize, Clone)] #[serde(tag = "in", rename_all = "lowercase")] #[cfg_attr(feature = "debug", derive(Debug))] @@ -141,7 +141,7 @@ pub struct ApiKeyValue { /// Name of the [`ApiKey`] parameter. pub name: String, - /// Description of the the [`ApiKey`] [`SecuritySchema`]. Supports markdown syntax. + /// Description of the the [`ApiKey`] [`SecurityScheme`]. Supports markdown syntax. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -183,7 +183,7 @@ impl ApiKeyValue { builder! { HttpBuilder; - /// Http authentication [`SecuritySchema`] builder. + /// Http authentication [`SecurityScheme`] builder. /// /// Methods can be chained to configure _bearer_format_ or to add _description_. #[non_exhaustive] @@ -198,7 +198,7 @@ builder! { #[serde(skip_serializing_if = "Option::is_none")] pub bearer_format: Option, - /// Optional description of [`Http`] [`SecuritySchema`] supporting markdown syntax. + /// Optional description of [`Http`] [`SecurityScheme`] supporting markdown syntax. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -230,7 +230,7 @@ impl HttpBuilder { /// /// # Examples /// - /// Create new [`Http`] [`SecuritySchema`] via [`HttpBuilder`]. + /// Create new [`Http`] [`SecurityScheme`] via [`HttpBuilder`]. /// ```rust /// # use utoipa::openapi::security::{HttpBuilder, HttpAuthScheme}; /// let http = HttpBuilder::new().scheme(HttpAuthScheme::Basic).build(); @@ -294,7 +294,7 @@ impl Default for HttpAuthScheme { } } -/// Open id connect [`SecuritySchema`] +/// Open id connect [`SecurityScheme`] #[non_exhaustive] #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] @@ -303,7 +303,7 @@ pub struct OpenIdConnect { /// Url of the [`OpenIdConnect`] to discover OAuth2 connect values. pub open_id_connect_url: String, - /// Description of [`OpenIdConnect`] [`SecuritySchema`] supporting markdown syntax. + /// Description of [`OpenIdConnect`] [`SecurityScheme`] supporting markdown syntax. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -324,7 +324,7 @@ impl OpenIdConnect { } } - /// Construct a new [`OpenIdConnect`] [`SecuritySchema`] with optional description + /// Construct a new [`OpenIdConnect`] [`SecurityScheme`] with optional description /// supporting markdown syntax. /// /// # Examples @@ -341,7 +341,7 @@ impl OpenIdConnect { } } -/// OAuth2 [`Flow`] configuration for [`SecuritySchema`]. +/// OAuth2 [`Flow`] configuration for [`SecurityScheme`]. #[non_exhaustive] #[derive(Serialize, Deserialize, Clone)] #[cfg_attr(feature = "debug", derive(Debug))] @@ -349,7 +349,7 @@ pub struct OAuth2 { /// Map of supported OAuth2 flows. pub flows: HashMap, - /// Optional description for the [`OAuth2`] [`Flow`] [`SecuritySchema`]. + /// Optional description for the [`OAuth2`] [`Flow`] [`SecurityScheme`]. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } diff --git a/utoipa-gen/Cargo.toml b/utoipa-gen/Cargo.toml index 73194541..728e8912 100644 --- a/utoipa-gen/Cargo.toml +++ b/utoipa-gen/Cargo.toml @@ -1,12 +1,15 @@ [package] name = "utoipa-gen" description = "Code generation implementation for utoipa" -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT OR Apache-2.0" readme = "README.md" keywords = ["openapi", "codegen", "proc-macro", "documentation", "compile-time"] repository = "https://github.com/juhaku/utoipa" +authors = [ + "Juha Kukkonen " +] [lib] proc-macro = true diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 507ea227..6e6efd88 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -397,6 +397,7 @@ impl ToTokens for Path { tokens.extend(quote! { #[allow(non_camel_case_types)] + #[doc(hidden)] pub struct #path_struct; impl utoipa::Path for #path_struct { diff --git a/utoipa-swagger-ui/Cargo.toml b/utoipa-swagger-ui/Cargo.toml index d415b2bf..528d1b8c 100644 --- a/utoipa-swagger-ui/Cargo.toml +++ b/utoipa-swagger-ui/Cargo.toml @@ -1,13 +1,16 @@ [package] name = "utoipa-swagger-ui" description = "Swagger UI for utoipa" -version = "0.1.2" +version = "0.2.0" edition = "2021" license = "MIT OR Apache-2.0" readme = "README.md" keywords = ["swagger-ui", "openapi", "documentation"] repository = "https://github.com/juhaku/utoipa" categories = ["web-programming"] +authors = [ + "Juha Kukkonen " +] [features] debug = [] @@ -16,7 +19,7 @@ debug = [] rust-embed = { version = "6.3", features = ["interpolate-folder-path"] } mime_guess = { version = "2.0" } actix-web = { version = "4", optional = true } -utoipa = { version = "0.1.0", path = "..", default-features = false, features = [] } +utoipa = { version = "0.1.1", path = "..", default-features = false, features = [] } [package.metadata.docs.rs] features = ["actix-web"] diff --git a/utoipa-swagger-ui/README.md b/utoipa-swagger-ui/README.md index 4a895c22..bdda4002 100644 --- a/utoipa-swagger-ui/README.md +++ b/utoipa-swagger-ui/README.md @@ -47,7 +47,7 @@ HttpServer::new(move || { .url("/api-doc/openapi.json", ApiDoc::openapi()), ) }) - .bind(format!("{}:{}", Ipv4Addr::UNSPECIFIED, 8989)).unwrap() + .bind((Ipv4Addr::UNSPECIFIED, 8989)).unwrap() .run(); ``` **actix-web** feature need to be enabled. diff --git a/utoipa-swagger-ui/src/lib.rs b/utoipa-swagger-ui/src/lib.rs index da237618..1e9bba06 100644 --- a/utoipa-swagger-ui/src/lib.rs +++ b/utoipa-swagger-ui/src/lib.rs @@ -4,12 +4,13 @@ //! //! [utoipa]: //! -//! **Currently supported frameworks:** +//! **Currently implemented boiler plate for:** //! //! * **actix-web** //! -//! Serving Swagger UI is framework independant thus [`SwaggerUi`] and [`Url`] of this create -//! could be used similarly to serve the Swagger UI in other frameworks as well. +//! Serving Swagger UI is framework independant thus this crate also supports serving the Swagger UI in with +//! other frameworks as well. With other frameworks there is bit more manual implementation to be done. See +//! more details at [`serve`]. //! //! # Features //! @@ -21,13 +22,13 @@ //! Use only the raw types without any boiler plate implementation. //! ```text //! [dependencies] -//! utoipa-swagger-ui = "0.1.2" +//! utoipa-swagger-ui = "0.2.0" //! //! ``` //! Enable actix-web framework with Swagger UI you could define the dependency as follows. //! ```text //! [dependencies] -//! utoipa-swagger-ui = { version = "0.1.2", features = ["actix-web"] } +//! utoipa-swagger-ui = { version = "0.2.0", features = ["actix-web"] } //! ``` //! //! **Note!** Also remember that you already have defined `utoipa` dependency in your `Cargo.toml` @@ -50,11 +51,11 @@ //! .url("/api-doc/openapi.json", ApiDoc::openapi()), //! ) //! }) -//! .bind(format!("{}:{}", Ipv4Addr::UNSPECIFIED, 8989)).unwrap() +//! .bind((Ipv4Addr::UNSPECIFIED, 8989)).unwrap() //! .run(); //! ``` //! [^actix]: **actix-web** feature need to be enabled. -use std::borrow::Cow; +use std::{borrow::Cow, error::Error, sync::Arc}; #[cfg(feature = "actix-web")] use actix_web::{ @@ -62,22 +63,26 @@ use actix_web::{ }; use rust_embed::RustEmbed; +#[cfg(feature = "actix-web")] use utoipa::openapi::OpenApi; -#[doc(hidden)] #[derive(RustEmbed)] #[folder = "$UTOIPA_SWAGGER_DIR/$UTOIPA_SWAGGER_UI_VERSION/dist/"] -pub struct SwaggerUiDist; +struct SwaggerUiDist; /// Entry point for serving Swagger UI and api docs in application. It uses provides -/// builder style chainable configuration methods for configuring api doc urls. +/// builder style chainable configuration methods for configuring api doc urls. **In actix-web only** [^actix] +/// +/// [^actix]: **actix-web** feature need to be enabled. #[non_exhaustive] #[derive(Clone)] +#[cfg(feature = "actix-web")] pub struct SwaggerUi { path: Cow<'static, str>, urls: Vec<(Url<'static>, OpenApi)>, } +#[cfg(feature = "actix-web")] impl SwaggerUi { /// Create a new [`SwaggerUi`] for given path. /// @@ -183,7 +188,7 @@ impl HttpServiceFactory for SwaggerUi { let swagger_resource = Resource::new(self.path.as_ref()) .guard(Get()) - .app_data(Data::new(urls)) + .app_data(Data::new(Config::new(urls))) .to(serve_swagger_ui); HttpServiceFactory::register(swagger_resource, config); @@ -205,7 +210,7 @@ fn register_api_doc_url_resource(url: &str, api: OpenApi, config: &mut actix_web /// Rust type for Swagger UI url configuration object. #[non_exhaustive] -#[derive(Default, Clone)] +#[derive(Default, Clone, Debug)] pub struct Url<'a> { name: Cow<'a, str>, url: Cow<'a, str>, @@ -257,6 +262,18 @@ impl<'a> Url<'a> { primary, } } + + fn to_json_object_string(&self) -> String { + format!( + r#"{{name: "{}", url: "{}"}}"#, + if self.name.is_empty() { + &self.url + } else { + &self.name + }, + self.url + ) + } } impl<'a> From<&'a str> for Url<'a> { @@ -278,54 +295,258 @@ impl From for Url<'_> { } #[cfg(feature = "actix-web")] -async fn serve_swagger_ui(path: web::Path, data: web::Data>>) -> HttpResponse { - let mut part = path.into_inner(); - if part.is_empty() || part == "/" { - part = "index.html".to_string() +async fn serve_swagger_ui(path: web::Path, data: web::Data>) -> HttpResponse { + match serve(&*path.into_inner(), data.into_inner()) { + Ok(swagger_file) => swagger_file + .map(|file| { + HttpResponse::Ok() + .content_type(file.content_type) + .body(file.bytes.to_vec()) + }) + .unwrap_or_else(|| HttpResponse::NotFound().finish()), + Err(error) => HttpResponse::InternalServerError().body(error.to_string()), + } +} + +/// Object used to alter Swagger UI settings. +/// +/// # Examples +/// +/// Simple case is to create config directly from url that points to the api doc json. +/// ```rust +/// # use utoipa_swagger_ui::Config; +/// let config = Config::from("/api-doc.json"); +/// ``` +/// +/// If there is multiple api docs to serve config can be also directly created with [`Config::new`] +/// ```rust +/// # use utoipa_swagger_ui::Config; +/// let config = Config::new(["/api-doc/openapi1.json", "/api-doc/openapi2.json"]); +/// ``` +/// +/// Or same as above but more verbose syntax. +/// ```rust +/// # use utoipa_swagger_ui::{Config, Url}; +/// let config = Config::new([ +/// Url::new("api1", "/api-doc/openapi1.json"), +/// Url::new("api2", "/api-doc/openapi2.json") +/// ]); +/// ``` +#[non_exhaustive] +#[derive(Default, Clone)] +pub struct Config<'a> { + /// [`Url`]s the Swagger UI is serving. + urls: Vec>, +} + +impl<'a> Config<'a> { + /// Constructs a new [`Config`] from [`Iterator`] of [`Url`]s. + /// + /// # Examples + /// Create new config with 2 api doc urls. + /// ```rust + /// # use utoipa_swagger_ui::Config; + /// let config = Config::new(["/api-doc/openapi1.json", "/api-doc/openapi2.json"]); + /// ``` + pub fn new, U: Into>>(urls: I) -> Self { + Self { + urls: urls.into_iter().map(|url| url.into()).collect(), + } + } +} + +impl<'a> From<&'a str> for Config<'a> { + fn from(s: &'a str) -> Self { + Self { + urls: vec![Url::from(s)], + } + } +} + +impl From for Config<'_> { + fn from(s: String) -> Self { + Self { + urls: vec![Url::from(s)], + } + } +} + +/// Represents servealbe file of Swagger UI. This is used together with [`serve`] function +/// to serve Swagger UI files via web server. +#[non_exhaustive] +pub struct SwaggerFile<'a> { + /// Content of the file as [`Cow`] [`slice`] of bytes. + pub bytes: Cow<'a, [u8]>, + /// Content type of the file e.g `"text/xml"`. + pub content_type: String, +} + +/// User friendly way to serve Swagger UI and its content via web server. +/// +/// * **path** Should be the relative path to Swagger UI resource within the web server. +/// * **config** Swagger [`Config`] to use for the Swagger UI. Currently supported configuration +/// options are managing [`Url`]s. +/// +/// Typpically this function is implemented _**within**_ handler what handles _**GET**_ operations related to the +/// Swagger UI. Handler itself must match to user defined path that points to the root of the Swagger UI and +/// matches everything relatively from the root of the Swagger UI. The relative path from root of the Swagger UI +/// must be taken to `tail` path variable which is used to serve [`SwaggerFile`]s. If Swagger UI +/// is served from path `/swagger-ui/` then the `tail` is everything under the `/swagger-ui/` prefix. +/// +/// _There are also implementations in [examples of utoipa repoistory][examples]._ +/// +/// [examples]: https://github.com/juhaku/utoipa/tree/master/examples +/// +/// # Examples +/// +/// Reference implementation with `actix-web`. +/// ```rust +/// # use actix_web::HttpResponse; +/// # use std::sync::Arc; +/// # use utoipa_swagger_ui::Config; +/// // The config should be created in main function or in initialization before +/// // creation of the handler which will handle serving the Swagger UI. +/// let config = Arc::new(Config::from("/api-doc.json")); +/// // This "/" is for demostrative purposes only. The actual path should point to +/// // file within Swagger UI. In real implementation this is the `tail` path from root of the +/// // Swagger UI to the file served. +/// let path = "/"; +/// +/// match utoipa_swagger_ui::serve(path, config) { +/// Ok(swagger_file) => swagger_file +/// .map(|file| { +/// HttpResponse::Ok() +/// .content_type(file.content_type) +/// .body(file.bytes.to_vec()) +/// }) +/// .unwrap_or_else(|| HttpResponse::NotFound().finish()), +/// Err(error) => HttpResponse::InternalServerError().body(error.to_string()), +/// }; +/// ``` +pub fn serve<'a>( + path: &str, + config: Arc>, +) -> Result>, Box> { + let mut file_path = path; + + if file_path.is_empty() || file_path == "/" { + file_path = "index.html"; } - if let Some(file) = SwaggerUiDist::get(&part) { - let mut bytes = file.data.into_owned(); + if let Some(file) = SwaggerUiDist::get(file_path) { + let mut bytes = file.data; - if part == "swagger-initializer.js" { - let mut index = match String::from_utf8(bytes.to_vec()) { + if file_path == "swagger-initializer.js" { + let mut file = match String::from_utf8(bytes.to_vec()) { Ok(index) => index, - Err(error) => return HttpResponse::InternalServerError().body(error.to_string()), + Err(error) => return Err(Box::new(error)), }; + file = format_swagger_config_urls(&mut config.urls.iter(), file); - if data.len() > 1 { - let mut urls = String::from("urls: ["); - data.as_ref().iter().for_each(|url| { - urls.push_str(&format!( - "{{name: \"{}\", url: \"{}\"}},", - if url.name.is_empty() { - &url.url - } else { - &url.name - }, - url.url - )); - }); - urls.push(']'); - if let Some(primary) = data.as_ref().iter().find(|url| url.primary) { - urls.push_str(&format!(", \"urls.primaryName\": \"{}\"", primary.name)); - } - index = index.replace(r"{{urls}}", &urls); - } else if let Some(url) = data.first() { - index = index.replace(r"{{urls}}", &format!("url: \"{}\"", url.url)); - } - - bytes = index.as_bytes().to_vec(); + bytes = Cow::Owned(file.as_bytes().to_vec()) }; - HttpResponse::Ok() - .content_type( - mime_guess::from_path(&part) - .first_or_octet_stream() - .to_string(), - ) - .body(bytes) + Ok(Some(SwaggerFile { + bytes, + content_type: mime_guess::from_path(&file_path) + .first_or_octet_stream() + .to_string(), + })) } else { - HttpResponse::NotFound().finish() + Ok(None) + } +} + +#[inline] +fn format_swagger_config_urls<'a, U: ExactSizeIterator>>( + urls: &mut U, + file: String, +) -> String { + if urls.len() > 1 { + let mut primary = None::>; + let mut urls_string = format!( + "urls: [{}],", + &urls + .inspect(|url| if url.primary { + primary = Some(Cow::Borrowed(url.name.as_ref())) + }) + .map(Url::to_json_object_string) + .collect::>() + .join(",") + ); + + if let Some(primary) = primary { + urls_string.push_str(&format!(r#""urls.primaryName": "{}","#, primary)); + } + file.replace(r"{{urls}},", &urls_string) + } else if let Some(url) = urls.next() { + file.replace(r"{{urls}}", &format!(r#"url: "{}""#, url.url)) + } else { + file + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_CONTENT: &str = r###""window.ui = SwaggerUIBundle({ + {{urls}}, + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout" + });""###; + + #[test] + fn format_swagger_config_urls_with_one_url() { + let config = Config::from("/api-doc.json"); + let file = + super::format_swagger_config_urls(&mut config.urls.iter(), TEST_CONTENT.to_string()); + + assert!( + file.contains(r#"url: "/api-doc.json","#), + "expected file to contain {}", + r#"url: "/api-doc.json","# + ) + } + + #[test] + fn format_swagger_config_urls_multiple() { + let config = Config::new(["/api-doc.json", "/api-doc2.json"]); + let file = + super::format_swagger_config_urls(&mut config.urls.iter(), TEST_CONTENT.to_string()); + + assert!( + file.contains(r#"urls: [{name: "/api-doc.json", url: "/api-doc.json"},{name: "/api-doc2.json", url: "/api-doc2.json"}],"#), + "expected file to contain {}", + r#"urls: [{name: "/api-doc.json", url: "/api-doc.json"}, {name: "/api-doc2.json", url: "/api-doc2.json"}],"# + ) + } + #[test] + fn format_swagger_config_urls_with_primary() { + let config = Config::new([ + Url::new("api1", "/api-doc.json"), + Url::with_primary("api2", "/api-doc2.json", true), + ]); + let file = + super::format_swagger_config_urls(&mut config.urls.iter(), TEST_CONTENT.to_string()); + + assert!( + file.contains(r#"urls: [{name: "api1", url: "/api-doc.json"},{name: "api2", url: "/api-doc2.json"}],"#), + "expected file to contain {}", + r#"urls: [{name: "api1", url: "/api-doc.json"}, {name: "api2", url: "/api-doc2.json"}],"# + ); + assert!( + file.contains(r#""urls.primaryName": "api2","#), + "expected file to contain {}", + r#""urls.primaryName": "api2","# + ) } }