Repository: juhaku/utoipa (rust)

Issue:
[utopia-swagger-ui] missing trailing / causes thread to panic in rocket environment
Hello, 
I'm currently running into a problem where a thread is panicing, when accessing the swagger-ui path

```
> curl http://localhost:6913/documentation/

<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Swagger UI</title>
    <link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
    <link rel="stylesheet" type="text/css" href="index.css" />
    <link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
    <link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
  </head>

  <body>
    <div id="swagger-ui"></div>
    <script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
    <script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
    <script src="./swagger-initializer.js" charset="UTF-8"> </script>
  </body>
</html>
```  
works, while

```
> curl http://localhost:6913/documentation
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>500 Internal Server Error</title>
</head>
<body align="center">
    <div role="main" align="center">
        <h1>500: Internal Server Error</h1>
        <p>The server encountered an internal error while processing this request.</p>
        <hr />
    </div>
    <div role="contentinfo" align="center">
        <small>Rocket</small>
    </div>
</body>
</html>
```
crashes with 
```
GET /documentation:
   >> Matched: GET /documentation/<_..>
thread 'rocket-worker-thread' panicked at 'byte index 15 is out of bounds of `/documentation`', /home/rustdev/.cargo/registry/src/github.com-1ecc6299db9ec823/utoipa-swagger-ui-3.1.3/src/rocket.rs:87:29
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
   >> A handler panicked.
   >> This is an application bug.
   >> A panic in Rust must be treated as an exceptional event.
   >> Panicking is not a suitable error handling mechanism.
   >> Unwinding, the result of a panic, is an expensive operation.
   >> Panics will degrade application performance.
   >> Instead of panicking, return `Option` and/or `Result`.
   >> Values of either type can be returned directly from handlers.
   >> A panic is treated as an internal server error.
   >> Outcome: Failure
   >> No 500 catcher registered. Using Rocket default.
   >> Response succeeded.
```

I'm currently using following version of:
rocket = { version = "0.5.0-rc.3", features = ["secrets", "json"]}
utoipa = { version = "3", features = ["debug", "rocket_extras", "yaml"]}
utoipa-swagger-ui = { version = "3.1.3", features = ["rocket"]}

Do you have any what might be causing this behaviour ?
And if so, what could be a possible workaround ?

Thanks.



Pull request description:
Fix: panic on missing trailing / in rocket environment (#645)
The ServeSwagger handler extracts the base path from the route by taking the beginning of the route until it finds the opening '<' and uses the extracted &str as base path to resolve incoming requests.

Thus, it removes this base path from the request URI and proceeds serving with the corresponding response.

Rocket also routes '/url' (note the missing trailing slash) to the handler which then panics as the base URL is shorter than request URI.

This patch adds a test for the expected base path prefix and response with a redirection if missing resulting in a successful page load.

fixes #645

Patch:
diff --git a/utoipa-swagger-ui/src/rocket.rs b/utoipa-swagger-ui/src/rocket.rs
index 0b8224b..b122216 100644
--- a/utoipa-swagger-ui/src/rocket.rs
+++ b/utoipa-swagger-ui/src/rocket.rs
@@ -4,10 +4,7 @@ use std::{borrow::Cow, io::Cursor, sync::Arc};
 
 use rocket::{
     http::{Header, Status},
-    response::{
-        status::{self, NotFound},
-        Responder as RocketResponder,
-    },
+    response::{status::NotFound, Responder as RocketResponder},
     route::{Handler, Outcome},
     serde::json::Json,
     Data as RocketData, Request, Response, Route,
@@ -79,18 +76,23 @@ struct ServeSwagger(Cow<'static, str>, Arc<Config<'static>>);
 #[rocket::async_trait]
 impl Handler for ServeSwagger {
     async fn handle<'r>(&self, request: &'r Request<'_>, _: RocketData<'r>) -> Outcome<'r> {
-        let mut path = self.0.as_ref();
+        let mut base_path = self.0.as_ref();
         if let Some(index) = self.0.find('<') {
-            path = &path[..index];
+            base_path = &base_path[..index];
         }
 
-        match super::serve(&request.uri().path().as_str()[path.len()..], self.1.clone()) {
+        let request_path = request.uri().path().as_str();
+        let request_path = match request_path.strip_prefix(base_path) {
+            Some(stripped) => stripped,
+            None => return Outcome::from(request, RedirectResponder(base_path.into())),
+        };
+        match super::serve(request_path, self.1.clone()) {
             Ok(swagger_file) => swagger_file
                 .map(|file| Outcome::from(request, file))
                 .unwrap_or_else(|| Outcome::from(request, NotFound("Swagger UI file not found"))),
             Err(error) => Outcome::from(
                 request,
-                status::Custom(Status::InternalServerError, error.to_string()),
+                rocket::response::status::Custom(Status::InternalServerError, error.to_string()),
             ),
         }
     }
@@ -98,12 +100,20 @@ impl Handler for ServeSwagger {
 
 impl<'r, 'o: 'r> RocketResponder<'r, 'o> for SwaggerFile<'o> {
     fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'o> {
-        rocket::response::Result::Ok(
-            Response::build()
-                .header(Header::new("Content-Type", self.content_type))
-                .sized_body(self.bytes.len(), Cursor::new(self.bytes.to_vec()))
-                .status(Status::Ok)
-                .finalize(),
-        )
+        Ok(Response::build()
+            .header(Header::new("Content-Type", self.content_type))
+            .sized_body(self.bytes.len(), Cursor::new(self.bytes.to_vec()))
+            .status(Status::Ok)
+            .finalize())
+    }
+}
+
+struct RedirectResponder(String);
+impl<'r, 'a: 'r> RocketResponder<'r, 'a> for RedirectResponder {
+    fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'a> {
+        Response::build()
+            .status(Status::Found)
+            .raw_header("Location", self.0)
+            .ok()
     }
 }
diff --git a/utoipa/src/openapi/schema.rs b/utoipa/src/openapi/schema.rs
index 573a24c..53d3c76 100644
--- a/utoipa/src/openapi/schema.rs
+++ b/utoipa/src/openapi/schema.rs
@@ -284,6 +284,12 @@ pub struct Discriminator {
     /// Defines a discriminator property name which must be found within all composite
     /// objects.
     pub property_name: String,
+
+    /// An object to hold mappings between payload values and schema names or references.
+    /// This field can only be populated manually. There is no macro support and no
+    /// validation.
+    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
+    pub mapping: BTreeMap<String, String>,
 }
 
 impl Discriminator {
@@ -299,6 +305,7 @@ impl Discriminator {
     pub fn new<I: Into<String>>(property_name: I) -> Self {
         Self {
             property_name: property_name.into(),
+            mapping: BTreeMap::new(),
         }
     }
 }
@@ -1031,6 +1038,12 @@ impl From<ObjectBuilder> for AdditionalProperties<Schema> {
     }
 }
 
+impl From<ArrayBuilder> for AdditionalProperties<Schema> {
+    fn from(value: ArrayBuilder) -> Self {
+        Self::RefOr(RefOr::T(Schema::Array(value.build())))
+    }
+}
+
 impl From<Ref> for AdditionalProperties<Schema> {
     fn from(value: Ref) -> Self {
         Self::RefOr(RefOr::Ref(value))
@@ -1615,6 +1628,24 @@ mod tests {
             })
         );
 
+        let json_value = ObjectBuilder::new()
+            .additional_properties(Some(
+                ArrayBuilder::new().items(ObjectBuilder::new().schema_type(SchemaType::Number)),
+            ))
+            .build();
+        assert_json_eq!(
+            json_value,
+            json!({
+                "type": "object",
+                "additionalProperties": {
+                    "items": {
+                        "type": "number",
+                    },
+                    "type": "array",
+                }
+            })
+        );
+
         let json_value = ObjectBuilder::new()
             .additional_properties(Some(Ref::from_schema_name("ComplexModel")))
             .build();
@@ -2013,4 +2044,34 @@ mod tests {
 
         assert_eq!(json_str, json_de_str);
     }
+
+    #[test]
+    fn serialize_discriminator_with_mapping() {
+        let mut discriminator = Discriminator::new("type");
+        discriminator.mapping = [("int".to_string(), "#/components/schemas/MyInt".to_string())]
+            .into_iter()
+            .collect::<BTreeMap<_, _>>();
+        let one_of = OneOfBuilder::new()
+            .item(Ref::from_schema_name("MyInt"))
+            .discriminator(Some(discriminator))
+            .build();
+        let json_value = serde_json::to_value(one_of).unwrap();
+
+        assert_json_eq!(
+            json_value,
+            json!({
+                "oneOf": [
+                    {
+                        "$ref": "#/components/schemas/MyInt"
+                    }
+                ],
+                "discriminator": {
+                    "propertyName": "type",
+                    "mapping": {
+                        "int": "#/components/schemas/MyInt"
+                    }
+                }
+            })
+        );
+    }
 }


Test patch:
diff --git a/scripts/test.sh b/scripts/test.sh
index f51048d..eb5ffb8 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -2,28 +2,32 @@
 
 set -e
 
-crate="$1"
-
-echo "Testing crate: $crate..."
-
-if [[ "$crate" == "utoipa" ]]; then
-    cargo test -p utoipa --features openapi_extensions,preserve_order,preserve_path_order,debug
-elif [[ "$crate" == "utoipa-gen" ]]; then
-    cargo test -p utoipa-gen --features utoipa/actix_extras,chrono,decimal,utoipa/uuid,uuid,utoipa/ulid,ulid,utoipa/url,url,utoipa/time,time,utoipa/repr,utoipa/smallvec,smallvec,rc_schema,utoipa/rc_schema
-
-    cargo test -p utoipa-gen --test schema_derive_test --features decimal_float
-    cargo test -p utoipa-gen --test path_derive_auto_into_responses --features auto_into_responses,utoipa/uuid,uuid
-    cargo test -p utoipa-gen --test path_derive_actix --test path_parameter_derive_actix --features actix_extras,utoipa/uuid,uuid
-    cargo test -p utoipa-gen --test path_derive_auto_into_responses_actix --features actix_extras,utoipa/auto_into_responses,utoipa/uuid,uuid
-
-    cargo test -p utoipa-gen --test path_derive_rocket --features rocket_extras
-
-    cargo test -p utoipa-gen --test path_derive_axum_test --features axum_extras
-    cargo test -p utoipa-gen --test path_derive_auto_into_responses_axum --features axum_extras,utoipa/auto_into_responses
-elif [[ "$crate" == "utoipa-swagger-ui" ]]; then
-    cargo test -p utoipa-swagger-ui --features actix-web,rocket,axum
-elif [[ "$crate" == "utoipa-redoc" ]]; then
-    cargo test -p utoipa-redoc --features actix-web,rocket,axum
-elif [[ "$crate" == "utoipa-rapidoc" ]]; then
-    cargo test -p utoipa-rapidoc --features actix-web,rocket,axum
-fi
+: "${CARGO:=cargo}"
+
+crates="${1:-utoipa utoipa-gen utoipa-swagger-ui utoipa-redoc utoipa-rapidoc}"
+
+for crate in $crates; do
+    echo "Testing crate: $crate..."
+
+    if [[ "$crate" == "utoipa" ]]; then
+        $CARGO test -p utoipa --features openapi_extensions,preserve_order,preserve_path_order,debug
+    elif [[ "$crate" == "utoipa-gen" ]]; then
+        $CARGO test -p utoipa-gen --features utoipa/actix_extras,chrono,decimal,utoipa/uuid,uuid,utoipa/ulid,ulid,utoipa/url,url,utoipa/time,time,utoipa/repr,utoipa/smallvec,smallvec,rc_schema,utoipa/rc_schema
+        $CARGO test -p utoipa-gen --test schema_derive_test --features decimal_float
+
+        $CARGO test -p utoipa-gen --test path_derive_auto_into_responses --features auto_into_responses,utoipa/uuid,uuid
+        $CARGO test -p utoipa-gen --test path_derive_actix --test path_parameter_derive_actix --features actix_extras,utoipa/uuid,uuid,utoipa/chrono,chrono,utoipa/time,time
+        $CARGO test -p utoipa-gen --test path_derive_auto_into_responses_actix --features actix_extras,utoipa/auto_into_responses,utoipa/uuid,uuid
+
+        $CARGO test -p utoipa-gen --test path_derive_rocket --features rocket_extras
+
+        $CARGO test -p utoipa-gen --test path_derive_axum_test --features axum_extras
+        $CARGO test -p utoipa-gen --test path_derive_auto_into_responses_axum --features axum_extras,utoipa/auto_into_responses
+    elif [[ "$crate" == "utoipa-swagger-ui" ]]; then
+        $CARGO test -p utoipa-swagger-ui --features actix-web,rocket,axum
+    elif [[ "$crate" == "utoipa-redoc" ]]; then
+        $CARGO test -p utoipa-redoc --features actix-web,rocket,axum
+    elif [[ "$crate" == "utoipa-rapidoc" ]]; then
+        $CARGO test -p utoipa-rapidoc --features actix-web,rocket,axum
+    fi
+done
diff --git a/utoipa-gen/tests/path_derive.rs b/utoipa-gen/tests/path_derive.rs
index 4f1aa8e..f88ba7b 100644
--- a/utoipa-gen/tests/path_derive.rs
+++ b/utoipa-gen/tests/path_derive.rs
@@ -280,6 +280,138 @@ fn derive_path_with_security_requirements() {
     }
 }
 
+#[test]
+fn derive_path_with_datetime_format_query_parameter() {
+    #[derive(serde::Deserialize, utoipa::ToSchema)]
+    struct Since {
+        /// Some date
+        #[allow(dead_code)]
+        date: String,
+        /// Some time
+        #[allow(dead_code)]
+        time: String,
+    }
+
+    /// This is test operation
+    ///
+    /// This is long description for test operation
+    #[utoipa::path(
+        get,
+        path = "/foo/{id}/{start}",
+        responses(
+            (status = 200, description = "success response")
+        ),
+        params(
+            ("id" = i64, Path, description = "Foo database id"),
+            ("start" = String, Path, description = "Datetime since foo is updated", format = DateTime)
+        )
+    )]
+    #[allow(unused)]
+    async fn get_foos_by_id_date() -> String {
+        "".to_string()
+    }
+
+    let operation: Value = test_api_fn_doc! {
+        get_foos_by_id_date,
+        operation: get,
+        path: "/foo/{id}/{start}"
+    };
+
+    let parameters: &Value = operation.get("parameters").unwrap();
+
+    assert_json_eq!(
+        parameters,
+        json!([
+            {
+                "description": "Foo database id",
+                "in": "path",
+                "name": "id",
+                "required": true,
+                "schema": {
+                    "format": "int64",
+                    "type": "integer",
+                }
+            },
+            {
+                "description": "Datetime since foo is updated",
+                "in": "path",
+                "name": "start",
+                "required": true,
+                "schema": {
+                    "format": "date-time",
+                    "type": "string",
+                }
+            }
+        ])
+    );
+}
+
+#[test]
+fn derive_path_with_datetime_format_path_parameter() {
+    #[derive(serde::Deserialize, utoipa::ToSchema)]
+    struct Since {
+        /// Some date
+        #[allow(dead_code)]
+        date: String,
+        /// Some time
+        #[allow(dead_code)]
+        time: String,
+    }
+
+    /// This is test operation
+    ///
+    /// This is long description for test operation
+    #[utoipa::path(
+        get,
+        path = "/foo/{id}",
+        responses(
+            (status = 200, description = "success response")
+        ),
+        params(
+            ("id" = i64, description = "Foo database id"),
+            ("start" = String, Query, description = "Datetime since foo is updated", format = DateTime)
+        )
+    )]
+    #[allow(unused)]
+    async fn get_foos_by_id_date() -> String {
+        "".to_string()
+    }
+
+    let operation: Value = test_api_fn_doc! {
+        get_foos_by_id_date,
+        operation: get,
+        path: "/foo/{id}"
+    };
+
+    let parameters: &Value = operation.get("parameters").unwrap();
+
+    assert_json_eq!(
+        parameters,
+        json!([
+            {
+                "description": "Foo database id",
+                "in": "path",
+                "name": "id",
+                "required": true,
+                "schema": {
+                    "format": "int64",
+                    "type": "integer",
+                }
+            },
+            {
+                "description": "Datetime since foo is updated",
+                "in": "query",
+                "name": "start",
+                "required": true,
+                "schema": {
+                    "format": "date-time",
+                    "type": "string",
+                }
+            }
+        ])
+    );
+}
+
 #[test]
 fn derive_path_with_parameter_schema() {
     #[derive(serde::Deserialize, utoipa::ToSchema)]
diff --git a/utoipa-gen/tests/path_parameter_derive_actix.rs b/utoipa-gen/tests/path_parameter_derive_actix.rs
index 3240842..732ddc2 100644
--- a/utoipa-gen/tests/path_parameter_derive_actix.rs
+++ b/utoipa-gen/tests/path_parameter_derive_actix.rs
@@ -164,3 +164,112 @@ fn derive_params_from_method_args_actix_success() {
         "[1].schema.format" = r#"null"#, "Parameter schema format"
     };
 }
+
+#[test]
+fn derive_path_with_date_params_implicit() {
+    mod mod_derive_path_with_date_params {
+        use actix_web::{get, web, HttpResponse, Responder};
+        use chrono::{DateTime, Utc};
+        use serde_json::json;
+        use time::Date;
+
+        #[utoipa::path(
+            responses(
+                (status = 200, description = "success response")
+            ),
+            params(
+                ("start_date", description = "Start date filter"),
+                ("end_date", description = "End date filter"),
+            )
+        )]
+        #[get("/visitors/v1/{start_date}/{end_date}")]
+        #[allow(unused)]
+        async fn get_foo_by_date(path: web::Path<(Date, DateTime<Utc>)>) -> impl Responder {
+            let (start_date, end_date) = path.into_inner();
+            HttpResponse::Ok()
+                .json(json!({ "params": &format!("{:?} {:?}", start_date, end_date) }))
+        }
+    }
+
+    #[derive(OpenApi, Default)]
+    #[openapi(paths(mod_derive_path_with_date_params::get_foo_by_date))]
+    struct ApiDoc;
+
+    let doc = serde_json::to_value(ApiDoc::openapi()).unwrap();
+    let parameters = doc
+        .pointer("/paths/~1visitors~1v1~1{start_date}~1{end_date}/get/parameters")
+        .unwrap();
+
+    common::assert_json_array_len(parameters, 2);
+    assert_value! {parameters=>
+        "[0].in" = r#""path""#, "Parameter in"
+        "[0].name" = r#""start_date""#, "Parameter name"
+        "[0].description" = r#""Start date filter""#, "Parameter description"
+        "[0].required" = r#"true"#, "Parameter required"
+        "[0].deprecated" = r#"null"#, "Parameter deprecated"
+        "[0].schema.type" = r#""string""#, "Parameter schema type"
+        "[0].schema.format" = r#""date""#, "Parameter schema format"
+
+        "[1].in" = r#""path""#, "Parameter in"
+        "[1].name" = r#""end_date""#, "Parameter name"
+        "[1].description" = r#""End date filter""#, "Parameter description"
+        "[1].required" = r#"true"#, "Parameter required"
+        "[1].deprecated" = r#"null"#, "Parameter deprecated"
+        "[1].schema.type" = r#""string""#, "Parameter schema type"
+        "[1].schema.format" = r#""date-time""#, "Parameter schema format"
+    };
+}
+
+#[test]
+fn derive_path_with_date_params_explicit_ignored() {
+    mod mod_derive_path_with_date_params {
+        use actix_web::{get, web, HttpResponse, Responder};
+        use serde_json::json;
+        use time::Date;
+
+        #[utoipa::path(
+            responses(
+                (status = 200, description = "success response")
+            ),
+            params(
+                ("start_date", description = "Start date filter", format = Date),
+                ("end_date", description = "End date filter", format = DateTime),
+            )
+        )]
+        #[get("/visitors/v1/{start_date}/{end_date}")]
+        #[allow(unused)]
+        async fn get_foo_by_date(path: web::Path<(Date, String)>) -> impl Responder {
+            let (start_date, end_date) = path.into_inner();
+            HttpResponse::Ok()
+                .json(json!({ "params": &format!("{:?} {:?}", start_date, end_date) }))
+        }
+    }
+
+    #[derive(OpenApi, Default)]
+    #[openapi(paths(mod_derive_path_with_date_params::get_foo_by_date))]
+    struct ApiDoc;
+
+    let doc = serde_json::to_value(ApiDoc::openapi()).unwrap();
+    let parameters = doc
+        .pointer("/paths/~1visitors~1v1~1{start_date}~1{end_date}/get/parameters")
+        .unwrap();
+
+    common::assert_json_array_len(parameters, 2);
+    assert_value! {parameters=>
+        "[0].in" = r#""path""#, "Parameter in"
+        "[0].name" = r#""start_date""#, "Parameter name"
+        "[0].description" = r#""Start date filter""#, "Parameter description"
+        "[0].required" = r#"true"#, "Parameter required"
+        "[0].deprecated" = r#"null"#, "Parameter deprecated"
+        "[0].schema.type" = r#""string""#, "Parameter schema type"
+        "[0].schema.format" = r#""date""#, "Parameter schema format"
+
+        "[1].in" = r#""path""#, "Parameter in"
+        "[1].name" = r#""end_date""#, "Parameter name"
+        "[1].description" = r#""End date filter""#, "Parameter description"
+        "[1].required" = r#"true"#, "Parameter required"
+        "[1].deprecated" = r#"null"#, "Parameter deprecated"
+        "[1].schema.type" = r#""string""#, "Parameter schema type"
+        "[1].schema.format" = r#"null"#, "Parameter schema format"
+    };
+}