Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IntoParams on axum: very strange and confusing behavior i.e. duplicated parameters, single parameter **must** be a tuple, missing examples, etc. #1314

Open
tgrushka opened this issue Feb 13, 2025 · 0 comments

Comments

@tgrushka
Copy link

Thank you for this library! poem-openapi is very elegant, but poem has some limitations that sent me back to axum, and I found this.

I'm having difficulty figuring out how to use this properly though, because there are so many options, and I can't tell what things are auto-discovered / auto-derived, and what needs manual configuration. That's what I really liked about poem-openapi -- it was just automatic. My biggest pet peeve is duplicated code, because that means I have to remember to change it twice or thrice instead of once.

I made this custom type because I want the input to be either an integer or a Roman Numeral, and axum parses either correctly:

use std::str::FromStr;

use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};

/// Integer or Roman Numeral, e.g. `19` or `XIX`
#[derive(Debug, IntoParams, ToSchema, Eq, PartialEq, Hash)]
#[schema(
    examples(19, "XIX"),
    value_type = String,
    format = Regex,
    pattern = r#"^\d{0,3}$|^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$"#,
)]
#[into_params(
    names("number"),
    style = Simple,
    parameter_in = Path,
)]
// Why do I need an example here when there's also one in the schema?
// If I don't include the following example, Swagger UI never shows an example (see below).
pub struct RomanNumber(#[param(example = "XIX")] pub u32);

impl<'de> Deserialize<'de> for RomanNumber {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        s.parse().map_err(serde::de::Error::custom)
    }
}

impl FromStr for RomanNumber {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.chars().all(|c| c.is_ascii_digit()) {
            return match s.parse::<u32>() {
                Ok(num) => Ok(RomanNumber(num)),
                Err(_) => Err("Number too large"),
            };
        }
        writings::roman::from(s)
            .map(RomanNumber)
            .ok_or("Invalid number or Roman Numeral")
    }
}

Please see my comments in the code about what happens when I include or exclude the params attribute in the utoipa::path annotation:

  • duplicated parameters in the spec when I declare them explicitly;
  • incorrect parameter types in the spec when I declare them explicitly
  • no parameters at all if I don't use a tuple for a single parameter;
  • examples not showing up when I don't declare them explicitly;
  • response body examples are showing up just fine.

(Note: this is from a module that is mounted at /gleanings.)

use axum::{Json, extract::Path};
use utoipa::OpenApi as DeriveOpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
use writings::{EmbedAllTrait as _, GleaningParagraph};

use crate::{ApiError, ApiResult, roman_number::RomanNumber};

#[derive(DeriveOpenApi)]
#[openapi(components(schemas(GleaningParagraph, RomanNumber)))]
pub struct GleaningsApiDoc;

pub fn router() -> OpenApiRouter {
    OpenApiRouter::with_openapi(GleaningsApiDoc::openapi())
        .routes(routes!(get_all_gleanings))
        .routes(routes!(get_gleanings_number))
        .routes(routes!(get_gleanings_number_paragraph))
}

#[utoipa::path(
    get,
    path = "/",
    responses(
        (status = OK, body = Vec<GleaningParagraph>, description = "Gleanings Paragraphs"),
    )
)]
pub async fn get_all_gleanings() -> ApiResult<Json<Vec<GleaningParagraph>>> {
    Ok(Json(GleaningParagraph::all().to_vec()))
}

#[utoipa::path(
    get,
    path = "/{number}",
    // When the following `params` attribute is included:
    //   - the spec has a duplicate `number` param;
    //   - both `number` params have the "XIX" example pre-filled;
    //   - both `number` params show as "integer($int32)", which is wrong;
    //   - the first `number` param only accepts an integer, ignoring the regex.
    // When I make the param **not** a tuple, **and** the following `params` attribute is included:
    //   - the type is incorrect: "integer($int32)";
    //   - the example "XIX" shows;
    //   - the regex does **not** work, and Swagger will **only** accept an integer.
    // When the following `params` attribute is excluded:
    //   - the spec has one param (correct);
    //   - the `number` type shows as "string($regex)" (correct);
    //   - and the regex validation works in Swagger UI;
    //   - but there is **no** example in Swagger UI.
    // params(RomanNumber),
    responses(
        (status = OK, body = Vec<GleaningParagraph>, description = "Gleanings Paragraphs"),
        (status = BAD_REQUEST, description = "bad request / invalid parameters")
    )
)]
pub async fn get_gleanings_number(
    // If this is not a tuple, the spec has no parameters, unless I provide the `params` argument in `utoipa::path` above!!
    Path((number,)): Path<(RomanNumber,)>,
) -> ApiResult<Json<Vec<GleaningParagraph>>> {
    Ok(Json(
        GleaningParagraph::all()
            .iter()
            .filter(|p| p.number == number.0)
            .cloned()
            .collect(),
    ))
}

#[utoipa::path(
    get,
    path = "/{number}/{paragraph}",
    // When the following `params` attribute is included:
    //   - same duplication as above, but in order: `number`, `paragraph`, `number`;
    //   - as above, both `number` params have the "XIX" example and incorrect "integer($int32)" types;
    //   - as above, the first `number` param only accepts an integer, ignoring the regex.
    // When the following `params` attribute is excluded:
    //   - the spec has two params (correct);
    //   - as above, the `number` type shows as "string($regex)" (correct);
    //   - as above, the regex validation works in Swagger UI;
    //   - as above, there is **no** example in Swagger UI.
    // params(RomanNumber, ("paragraph" = u32, Path)),
    responses(
        (status = OK, body = GleaningParagraph, description = "Gleanings Paragraph"),
        (status = BAD_REQUEST, description = "bad request / invalid parameters")
    )
)]
pub async fn get_gleanings_number_paragraph(
    Path((number, paragraph)): Path<(RomanNumber, u32)>,
) -> ApiResult<Json<GleaningParagraph>> {
    Ok(Json(
        GleaningParagraph::all()
            .iter()
            .find(|p| p.number == number.0 && p.paragraph == paragraph)
            .cloned()
            .ok_or(ApiError::NotFound)?,
    ))
}

In conclusion, it seems to me:

  • utoipa is partially inferring the parameters, but only when they are provided in tuples;
  • overriding the params attribute on the utoipa::path annotation does not remove duplicate inferred types on which IntoParams is derived;
  • I'm having difficulty understanding how to use this crate properly. Maybe it's a problem due to the "custom" RomanNumber type I created? But wouldn't it be the same for any "custom" type, or am I defining it incorrectly?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant