#![allow(clippy::doc_markdown)]
use std::collections::BTreeMap;
#[cfg(feature = "openapi")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default)]
pub struct ApiDoc {
pub method: &'static str,
pub path: &'static str,
pub operation_id: &'static str,
pub summary: Option<&'static str>,
pub description: Option<&'static str>,
pub tags: &'static [&'static str],
pub path_params: &'static [&'static str],
pub request_body: Option<SchemaEntry>,
pub response: Option<SchemaEntry>,
pub success_status: u16,
pub hidden: bool,
pub register_schemas: Option<fn(&mut SchemaRegistry)>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct SchemaEntry {
pub name: &'static str,
pub kind: SchemaKind,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SchemaKind {
Ref,
Primitive(&'static str),
Array(&'static SchemaEntry),
Nullable(&'static SchemaEntry),
}
#[cfg(feature = "openapi")]
#[derive(Clone)]
pub struct OpenApiConfig {
pub title: String,
pub version: String,
pub description: Option<String>,
pub openapi_json_path: String,
pub swagger_ui_path: Option<String>,
pub additional_schemas: BTreeMap<String, serde_json::Value>,
}
#[cfg(feature = "openapi")]
impl OpenApiConfig {
#[must_use]
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
Self {
title: title.into(),
version: version.into(),
description: None,
openapi_json_path: "/v3/api-docs".to_owned(),
swagger_ui_path: Some("/swagger-ui".to_owned()),
additional_schemas: BTreeMap::new(),
}
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
self.openapi_json_path = path.into();
self
}
#[must_use]
pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
self.swagger_ui_path = path;
self
}
#[must_use]
pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
self.additional_schemas.insert(name.into(), schema);
self
}
}
#[cfg(feature = "openapi")]
pub trait OpenApiSchema {
fn schema_name() -> &'static str;
fn schema() -> serde_json::Value;
}
#[cfg(feature = "openapi")]
macro_rules! impl_primitive_schema {
($ty:ty, $name:literal, $json:literal) => {
impl OpenApiSchema for $ty {
fn schema_name() -> &'static str {
$name
}
fn schema() -> serde_json::Value {
serde_json::json!({ "type": $json })
}
}
};
}
#[cfg(feature = "openapi")]
impl_primitive_schema!(bool, "boolean", "boolean");
#[cfg(feature = "openapi")]
impl_primitive_schema!(String, "string", "string");
#[cfg(feature = "openapi")]
impl_primitive_schema!(&'static str, "string", "string");
#[cfg(feature = "openapi")]
impl_primitive_schema!(i8, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(i16, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(i32, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(i64, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(u8, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(u16, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(u32, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(u64, "integer", "integer");
#[cfg(feature = "openapi")]
impl_primitive_schema!(f32, "number", "number");
#[cfg(feature = "openapi")]
impl_primitive_schema!(f64, "number", "number");
#[cfg(feature = "openapi")]
impl_primitive_schema!(serde_json::Value, "object", "object");
#[derive(Default)]
pub struct SchemaRegistry {
schemas: BTreeMap<String, serde_json::Value>,
}
impl SchemaRegistry {
#[cfg(feature = "openapi")]
pub fn register<T: OpenApiSchema>(&mut self) {
let name = T::schema_name().to_owned();
self.schemas.entry(name).or_insert_with(T::schema);
}
pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
self.schemas.insert(name.into(), schema);
}
#[must_use]
pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
self.schemas
}
#[must_use]
pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
&self.schemas
}
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct OpenApiSpec {
pub openapi: String,
pub info: Info,
pub paths: BTreeMap<String, PathItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub components: Option<Components>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Info {
pub title: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[cfg(feature = "openapi")]
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct PathItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub get: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub put: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patch: Option<Operation>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Operation {
#[serde(rename = "operationId")]
pub operation_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub parameters: Vec<Parameter>,
#[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
pub request_body: Option<RequestBody>,
pub responses: BTreeMap<String, Response>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Parameter {
pub name: String,
#[serde(rename = "in")]
pub location: String,
pub required: bool,
pub schema: serde_json::Value,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct RequestBody {
pub required: bool,
pub content: BTreeMap<String, MediaType>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Response {
pub description: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub content: BTreeMap<String, MediaType>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct MediaType {
pub schema: serde_json::Value,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Components {
pub schemas: BTreeMap<String, serde_json::Value>,
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
let mut registry = SchemaRegistry::default();
for (name, schema) in &config.additional_schemas {
registry.insert(name.clone(), schema.clone());
}
let mut referenced_names: std::collections::BTreeSet<&'static str> =
std::collections::BTreeSet::new();
for api_doc in routes {
if api_doc.hidden {
continue;
}
if let Some(register) = api_doc.register_schemas {
(register)(&mut registry);
}
if let Some(entry) = &api_doc.request_body {
collect_ref_names(entry, &mut referenced_names);
}
if let Some(entry) = &api_doc.response {
collect_ref_names(entry, &mut referenced_names);
}
let operation = operation_for(api_doc);
let entry = paths.entry(api_doc.path.to_owned()).or_default();
match api_doc.method {
"GET" => entry.get = Some(operation),
"POST" => entry.post = Some(operation),
"PUT" => entry.put = Some(operation),
"DELETE" => entry.delete = Some(operation),
"PATCH" => entry.patch = Some(operation),
_ => {}
}
}
for name in referenced_names {
if !registry.schemas().contains_key(name) {
registry.insert(
name,
serde_json::json!({
"type": "object",
"title": name,
}),
);
}
}
let components_map = registry.into_map();
let components = if components_map.is_empty() {
None
} else {
Some(Components {
schemas: components_map,
})
};
OpenApiSpec {
openapi: "3.0.3".to_owned(),
info: Info {
title: config.title.clone(),
version: config.version.clone(),
description: config.description.clone(),
},
paths,
components,
}
}
#[cfg(feature = "openapi")]
fn operation_for(api_doc: &ApiDoc) -> Operation {
let tags = if api_doc.tags.is_empty() {
default_tag(api_doc.path)
.map(|t| vec![t.to_owned()])
.unwrap_or_default()
} else {
api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
};
let parameters = api_doc
.path_params
.iter()
.map(|name| Parameter {
name: (*name).to_owned(),
location: "path".to_owned(),
required: true,
schema: serde_json::json!({ "type": "string" }),
})
.collect();
let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
required: true,
content: std::iter::once((
"application/json".to_owned(),
MediaType {
schema: schema_value_for(entry),
},
))
.collect(),
});
let mut responses: BTreeMap<String, Response> = BTreeMap::new();
let status = if api_doc.success_status == 0 {
200
} else {
api_doc.success_status
};
let response_content = api_doc
.response
.as_ref()
.map(|entry| {
let mut content = BTreeMap::new();
content.insert(
"application/json".to_owned(),
MediaType {
schema: schema_value_for(entry),
},
);
content
})
.unwrap_or_default();
responses.insert(
status.to_string(),
Response {
description: status_description(status).to_owned(),
content: response_content,
},
);
Operation {
operation_id: api_doc.operation_id.to_owned(),
summary: api_doc.summary.map(str::to_owned),
description: api_doc.description.map(str::to_owned),
tags,
parameters,
request_body,
responses,
}
}
#[cfg(feature = "openapi")]
fn schema_value_for(entry: &SchemaEntry) -> serde_json::Value {
match entry.kind {
SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
SchemaKind::Ref => {
serde_json::json!({ "$ref": format!("#/components/schemas/{}", entry.name) })
}
SchemaKind::Array(items) => serde_json::json!({
"type": "array",
"items": schema_value_for(items),
}),
SchemaKind::Nullable(inner) => {
if inner.kind == SchemaKind::Ref {
serde_json::json!({
"nullable": true,
"allOf": [schema_value_for(inner)],
})
} else {
let mut v = schema_value_for(inner);
if let Some(obj) = v.as_object_mut() {
obj.insert("nullable".to_owned(), serde_json::Value::Bool(true));
}
v
}
}
}
}
#[cfg(feature = "openapi")]
fn collect_ref_names(entry: &SchemaEntry, out: &mut std::collections::BTreeSet<&'static str>) {
match entry.kind {
SchemaKind::Ref => {
out.insert(entry.name);
}
SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => collect_ref_names(inner, out),
SchemaKind::Primitive(_) => {}
}
}
#[cfg(feature = "openapi")]
fn default_tag(path: &str) -> Option<&str> {
path.trim_start_matches('/')
.split('/')
.find(|seg| !seg.is_empty() && !seg.starts_with('{'))
}
#[cfg(feature = "openapi")]
const fn status_description(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
422 => "Unprocessable Entity",
500 => "Internal Server Error",
_ => "Response",
}
}
#[cfg(feature = "openapi")]
pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
#[cfg(feature = "openapi")]
pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
#[cfg(feature = "openapi")]
pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
#[cfg(feature = "openapi")]
const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
#[cfg(feature = "openapi")]
const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
#[cfg(feature = "openapi")]
const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
#[cfg(feature = "openapi")]
#[must_use]
pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
[
swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
]
}
#[cfg(feature = "openapi")]
#[must_use]
fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
let base = swagger_path.trim_end_matches('/');
if base.is_empty() || base == "/" {
format!("/{asset_file}")
} else {
format!("{base}/{asset_file}")
}
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn swagger_ui_html(
title: &str,
css_url: &str,
bundle_url: &str,
initializer_url: &str,
) -> String {
let title = html_escape(title);
let css_url = html_escape(css_url);
let bundle_url = html_escape(bundle_url);
let initializer_url = html_escape(initializer_url);
let mut out = String::with_capacity(1024);
out.push_str("<!DOCTYPE html>\n");
out.push_str("<html lang=\"en\">\n");
out.push_str(" <head>\n");
out.push_str(" <meta charset=\"utf-8\" />\n");
out.push_str(" <title>");
out.push_str(&title);
out.push_str("</title>\n");
out.push_str(" <link rel=\"stylesheet\" href=\"");
out.push_str(&css_url);
out.push_str("\" />\n");
out.push_str(" </head>\n");
out.push_str(" <body>\n");
out.push_str(" <div id=\"swagger-ui\"></div>\n");
out.push_str(" <script src=\"");
out.push_str(&bundle_url);
out.push_str("\" charset=\"UTF-8\"></script>\n");
out.push_str(" <script src=\"");
out.push_str(&initializer_url);
out.push_str("\" charset=\"UTF-8\"></script>\n");
out.push_str(" </body>\n");
out.push_str("</html>\n");
out
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
let spec_url = serde_json::to_string(spec_url)
.unwrap_or_else(|e| format!("\"/v3/api-docs?serialization_error={e}\""));
let mut out = String::with_capacity(256);
out.push_str("window.onload = function() {\n");
out.push_str(" window.ui = SwaggerUIBundle({\n");
out.push_str(" url: ");
out.push_str(&spec_url);
out.push_str(",\n");
out.push_str(" dom_id: \"#swagger-ui\",\n");
out.push_str(" deepLinking: true\n");
out.push_str(" });\n");
out.push_str("};\n");
out
}
#[cfg(feature = "openapi")]
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(all(test, feature = "openapi"))]
mod tests {
use super::*;
fn make_doc() -> ApiDoc {
ApiDoc {
method: "GET",
path: "/users/{id}",
operation_id: "get_user",
summary: Some("Fetch a user"),
description: None,
tags: &[],
path_params: &["id"],
request_body: None,
response: None,
success_status: 200,
hidden: false,
register_schemas: None,
}
}
#[test]
fn generate_spec_builds_path_with_parameters() {
let doc = make_doc();
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
assert_eq!(spec.openapi, "3.0.3");
assert_eq!(spec.info.title, "Demo");
assert!(spec.paths.contains_key("/users/{id}"));
let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
assert_eq!(op.operation_id, "get_user");
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].name, "id");
assert_eq!(op.parameters[0].location, "path");
assert_eq!(op.tags, vec!["users".to_owned()]);
}
#[test]
fn generate_spec_skips_hidden_routes() {
let mut doc = make_doc();
doc.hidden = true;
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
assert!(spec.paths.is_empty());
}
#[test]
fn generate_spec_writes_request_body_ref() {
let mut doc = make_doc();
doc.method = "POST";
doc.path = "/users";
doc.operation_id = "create_user";
doc.path_params = &[];
doc.request_body = Some(SchemaEntry {
name: "CreateUser",
kind: SchemaKind::Ref,
});
doc.success_status = 201;
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/users"].post.as_ref().unwrap();
let body = op.request_body.as_ref().unwrap();
assert!(body.required);
let media = body.content.get("application/json").unwrap();
assert_eq!(
media.schema,
serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
);
assert!(op.responses.contains_key("201"));
}
#[test]
fn generate_spec_inlines_primitive_response() {
let mut doc = make_doc();
doc.response = Some(SchemaEntry {
name: "string",
kind: SchemaKind::Primitive("string"),
});
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
let media = op.responses["200"].content.get("application/json").unwrap();
assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
}
#[test]
fn swagger_ui_html_uses_same_origin_assets() {
let html = swagger_ui_html(
"Demo",
"/swagger-ui/swagger-ui.css",
"/swagger-ui/swagger-ui-bundle.js",
"/swagger-ui/swagger-initializer.js",
);
assert!(html.contains("/swagger-ui/swagger-ui.css"));
assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
assert!(html.contains("/swagger-ui/swagger-initializer.js"));
assert!(!html.contains("unpkg.com"));
assert!(!html.contains("window.onload = function()"));
}
#[test]
fn swagger_ui_initializer_js_references_spec_url() {
let js = swagger_ui_initializer_js("/v3/api-docs");
assert!(js.contains("SwaggerUIBundle"));
assert!(js.contains(r#""/v3/api-docs""#));
}
#[test]
fn nullable_ref_uses_openapi_3_0_shape() {
static INNER: SchemaEntry = SchemaEntry {
name: "User",
kind: SchemaKind::Ref,
};
let entry = SchemaEntry {
name: "nullable",
kind: SchemaKind::Nullable(&INNER),
};
let value = schema_value_for(&entry);
assert_eq!(value["nullable"], true);
assert_eq!(
value["allOf"][0]["$ref"], "#/components/schemas/User",
"nullable refs must wrap in allOf so `$ref` can carry siblings"
);
assert!(value.get("anyOf").is_none(), "must not emit anyOf/null");
assert!(
value.get("type").is_none()
|| value.get("type").unwrap() != &serde_json::Value::String("null".into()),
"must not emit type: null"
);
}
#[test]
fn nullable_primitive_inlines_nullable_flag() {
static INNER: SchemaEntry = SchemaEntry {
name: "integer",
kind: SchemaKind::Primitive("integer"),
};
let entry = SchemaEntry {
name: "nullable",
kind: SchemaKind::Nullable(&INNER),
};
let value = schema_value_for(&entry);
assert_eq!(value["type"], "integer");
assert_eq!(value["nullable"], true);
}
#[test]
fn generate_spec_includes_additional_schemas() {
let doc = make_doc();
let config = OpenApiConfig::new("Demo", "1.0.0")
.register_schema("Foo", serde_json::json!({ "type": "object" }));
let spec = generate_spec(&config, &[&doc]);
let components = spec.components.unwrap();
assert!(components.schemas.contains_key("Foo"));
}
#[test]
fn generate_spec_back_fills_unregistered_ref_schemas() {
let mut doc = make_doc();
doc.method = "POST";
doc.path = "/users";
doc.path_params = &[];
doc.request_body = Some(SchemaEntry {
name: "CreateUser",
kind: SchemaKind::Ref,
});
doc.response = Some(SchemaEntry {
name: "User",
kind: SchemaKind::Ref,
});
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let components = spec.components.expect("components must be emitted");
let create = components
.schemas
.get("CreateUser")
.expect("CreateUser should be back-filled");
let user = components
.schemas
.get("User")
.expect("User should be back-filled");
assert_eq!(create["type"], "object");
assert_eq!(create["title"], "CreateUser");
assert_eq!(user["type"], "object");
assert_eq!(user["title"], "User");
}
#[test]
fn generate_spec_preserves_user_registered_schemas_over_backfill() {
let mut doc = make_doc();
doc.response = Some(SchemaEntry {
name: "User",
kind: SchemaKind::Ref,
});
let user_schema = serde_json::json!({
"type": "object",
"properties": {"id": {"type": "integer"}},
});
let config =
OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
let spec = generate_spec(&config, &[&doc]);
let components = spec.components.unwrap();
let stored = components.schemas.get("User").unwrap();
assert_eq!(stored, &user_schema, "user schema must not be overwritten");
}
#[test]
fn default_tag_picks_first_static_segment() {
assert_eq!(default_tag("/users/{id}"), Some("users"));
assert_eq!(default_tag("/api/v1/users"), Some("api"));
assert_eq!(default_tag("/"), None);
assert_eq!(default_tag("/{id}"), None);
}
#[test]
fn schema_registry_deduplicates() {
struct Foo;
impl OpenApiSchema for Foo {
fn schema_name() -> &'static str {
"Foo"
}
fn schema() -> serde_json::Value {
serde_json::json!({ "type": "object", "title": "Foo" })
}
}
let mut registry = SchemaRegistry::default();
registry.register::<Foo>();
registry.register::<Foo>();
assert_eq!(registry.schemas().len(), 1);
}
#[test]
fn primitive_impls_cover_common_types() {
assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
}
#[test]
fn swagger_ui_html_embeds_spec_url() {
let html = swagger_ui_html(
"My API",
"/swagger-ui/swagger-ui.css",
"/swagger-ui/swagger-ui-bundle.js",
"/swagger-ui/swagger-initializer.js",
);
assert!(html.contains("/swagger-ui/swagger-ui.css"));
assert!(html.contains("My API"));
}
#[test]
fn swagger_ui_html_escapes_attributes() {
let html = swagger_ui_html(
"A \"cool\" & fun API",
"/swagger-ui/swagger-ui.css?x=<y>",
"/swagger-ui/swagger-ui-bundle.js",
"/swagger-ui/swagger-initializer.js",
);
assert!(html.contains("/swagger-ui/swagger-ui.css?x=<y>"));
assert!(html.contains("A "cool" & fun API"));
}
}