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

feat: OpenAPI integration #984

Open
wants to merge 62 commits into
base: master
Choose a base branch
from

Conversation

NexVeridian
Copy link
Contributor

@NexVeridian NexVeridian commented Nov 13, 2024

related #855

cargo check -F all_openapi
cargo nextest run --test-threads 1 -F all_openapi
docker run -d \
  -e POSTGRES_DB=postgres_test \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  --health-cmd="pg_isready" \
  --health-interval=10s \
  --health-timeout=5s \
  --health-retries=5 \
  postgres

export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres_test"

cargo nextest run --all-features --workspace --exclude loco-gen --exclude loco --test-threads 1

TODO:

  • config.yaml
    • maybe no enable: true since that's handled by the feature
      openapi:
        redoc:
          !Redoc
            url: /redoc
            spec_json_url: /redoc/openapi.json
            spec_yaml_url: /redoc/openapi.yaml
        scalar:
          !Scalar
            url: /scalar
            spec_json_url: /scalar/openapi.json
            spec_yaml_url: /scalar/openapi.yaml
        swagger:
          !Swagger
            url: /swagger-ui
            spec_json_url: /api-docs/openapi.json
            spec_yaml_url: /api-docs/openapi.yaml
    • finish .merge(Redoc::with_url("/redoc", api.clone()))
    • openapi.josn and openapi.yaml endpoints for all types
  • split feature openapi into feature all_openapi swagger-ui redoc scalar
  • rstest feature flagged cases
  • SecurityAddon
    • put impl Modify for SecurityAddon somewhere, maybe with config
    • set the jwt token location
  • tests
    • update src/tests_cfg/db.rs:86:1
    • src/tests_cfg/config.rs
    • config from file test_from_folder_openapi()
    • snapshots
  • docs
  • maybe auto fill / wrap utoipa::path if possible
  • check that get in get(get_action_openapi) is still grabbed with routes!(get_action_openapi)
  • fix AppContext - check that api_router.routes(method.with_state::<AppContext>(())) doesn't break the ctx with .layer
  • cargo test is broken with JWT_LOCATION.get_or_init, nextest works correctly
  • codegen
    • cargo loco generate controller --openapi
    • adding #[derive[utoipa::ToSchema)] if used in utoipa::path
    • routes! macro

cc @DenuxPlays

@DenuxPlays
Copy link
Contributor

Looks like a good start.

I think the yaml also should contain a few other things:

  • openapi and ui endpoints (I am not sure if this is want you mean in your description)
  • whether it serves json, yaml or both (Perhaps this can be controlled by using features to exclude unnecessary dependencies)

@NexVeridian NexVeridian force-pushed the OpenAPI-integration branch 4 times, most recently from 9055d99 to 3e106d0 Compare November 22, 2024 09:21
@DenuxPlays
Copy link
Contributor

Hey @NexVeridian

Can I help you finish this pr?

@NexVeridian
Copy link
Contributor Author

@DenuxPlays yeah of course

@DenuxPlays
Copy link
Contributor

How can I help?

Also just one Note to your pr description.
I wouldn't wrap the utoipa path macro.
Its working very well just how it is and wrapping it would make upgrading and maintaining very difficult

@NexVeridian
Copy link
Contributor Author

NexVeridian commented Nov 27, 2024

Also just one Note to your pr description. I wouldn't wrap the utoipa path macro. Its working very well just how it is and wrapping it would make upgrading and maintaining very difficult

thanks!

this would be nice if possible:

impl Routes {
/// .add_openapi(routes!(get_action, post_action))
pub fn add_openapi(mut self, method: UtoipaMethodRouter<AppContext>) -> Self {}
}

any of the unchecked ones in the pr description would be great:
maybe cargo loco generate controller --openapi and docs first

Comment on lines +10 to +33
static JWT_LOCATION: OnceLock<Option<JWTLocation>> = OnceLock::new();

#[must_use]
pub fn get_jwt_location_from_ctx(ctx: &AppContext) -> JWTLocation {
ctx.config
.auth
.as_ref()
.and_then(|auth| auth.jwt.as_ref())
.and_then(|jwt| jwt.location.as_ref())
.unwrap_or(&JWTLocation::Bearer)
.clone()
}

pub fn set_jwt_location_ctx(ctx: &AppContext) {
set_jwt_location(get_jwt_location_from_ctx(ctx));
}

pub fn set_jwt_location(jwt_location: JWTLocation) -> &'static Option<JWTLocation> {
JWT_LOCATION.get_or_init(|| Some(jwt_location))
}

fn get_jwt_location() -> Option<&'static JWTLocation> {
JWT_LOCATION.get().unwrap_or(&None).as_ref()
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this might be flaky in cargo test because the global state isn't reset between test runs
tests are good in cargo nextest and should be fine for users

@NexVeridian
Copy link
Contributor Author

Just one small thing I noticed. Structs like these: https://github.com/NexVeridian/loco/blob/d72c46032a93a676004ade03266f76e6268b26bc/src/controller/views/pagination.rs would need to implement/derive the ToSchema trait right?

if it's used in the utoipa::path then yeah, I'm not sure if they would be, I haven't used those before

@DenuxPlays
Copy link
Contributor

Just one small thing I noticed. Structs like these: https://github.com/NexVeridian/loco/blob/d72c46032a93a676004ade03266f76e6268b26bc/src/controller/views/pagination.rs would need to implement/derive the ToSchema trait right?

if it's used in the utoipa::path then yeah, I'm not sure if they would be, I haven't used those before

They would to provide Basic paginagtion metadata

@SorenEdwards
Copy link

SorenEdwards commented Jan 30, 2025

@DenuxPlays @NexVeridian

I had to do something similar recently and If I understand correctly you would need to generate something like this. I had a problem where the generated openapi schema didn't see that X struct was a query parameter.

#[derive(Debug, Deserialize, Serialize,ToSchema,IntoParams)]
#[into_params(parameter_in=Query)]
pub struct PagerMeta {
    #[serde(rename(serialize = "page"))]
    pub page: u64,
    #[serde(rename(serialize = "page_size"))]
    pub page_size: u64,
    #[serde(rename(serialize = "total_pages"))]
    pub total_pages: u64,
}

…ms)"

This reverts commit 9a74966.

Revert "some derive(ToSchema) and derive(IntoParams)"

This reverts commit fb9e237.
@NexVeridian
Copy link
Contributor Author

5f4c63f still has some errors that I don't know how to fix, one of you two should take a look maybe

@DenuxPlays
Copy link
Contributor

DenuxPlays commented Jan 30, 2025

I think the Solution would be something like cfg_of and we have to define the struct two times.

@SorenEdwards
Copy link

I think this would work, I tested it on your branch. The cfg_attr for the trait bounds is pretty ugly so I think @DenuxPlays has a point with using cfg_of and having two structs.

Pragnation.rs


#[cfg_attr(
    any(
        feature = "openapi_swagger",
        feature = "openapi_redoc",
        feature = "openapi_scalar"
    ),
    derive(utoipa::ToSchema)
)]
#[derive(Debug, Deserialize, Serialize)]
pub struct Pager<
#[cfg(any(
    feature = "openapi_swagger",
    feature = "openapi_redoc",
    feature = "openapi_scalar"
))] T: utoipa::ToSchema,
#[cfg(not(any(
    feature = "openapi_swagger",
    feature = "openapi_redoc",
    feature = "openapi_scalar"
),))] T,
> {
    #[serde(rename(serialize = "results"))]
    pub results: T,

    #[serde(rename(serialize = "pagination"))]
    pub info: PagerMeta,
}


#[cfg_attr(
    any(
        feature = "openapi_swagger",
        feature = "openapi_redoc",
        feature = "openapi_scalar"
    ),
    derive(utoipa::ToSchema)
)]
#[derive(Debug, Deserialize, Serialize)]
pub struct PagerMeta {
    #[serde(rename(serialize = "page"))]
    pub page: u64,
    #[serde(rename(serialize = "page_size"))]
    pub page_size: u64,
    #[serde(rename(serialize = "total_pages"))]
    pub total_pages: u64,
    #[serde(rename(serialize = "total_items"))]
    pub total_items: u64,
}

impl<
#[cfg(any(
    feature = "openapi_swagger",
    feature = "openapi_redoc",
    feature = "openapi_scalar"
))] T: utoipa::ToSchema,
#[cfg(not(any(
    feature = "openapi_swagger",
    feature = "openapi_redoc",
    feature = "openapi_scalar"
),))] T,
> Pager<T> {
    #[must_use]
    pub const fn new(results: T, meta: PagerMeta) -> Self {
        Self {
            results,
            info: meta,
        }
    }
}

pragnate/mod.rs

#[cfg_attr(
    any(
        feature = "openapi_swagger",
        feature = "openapi_redoc",
        feature = "openapi_scalar"
    ),
    derive(utoipa::IntoParams)
)]
#[derive(Debug, Deserialize, Serialize)]
pub struct PaginationQuery {
    #[serde(
        default = "default_page_size",
        rename = "page_size",
        deserialize_with = "deserialize_pagination_filter"
    )]
    pub page_size: u64,
    #[serde(
        default = "default_page",
        rename = "page",
        deserialize_with = "deserialize_pagination_filter"
    )]
    pub page: u64,
}

@DenuxPlays
Copy link
Contributor

I had something like the following in mind.
Also this isn't tested I do not have time for this atm.

pagination.rs

use cfg_if::cfg_if;

cfg_if! {
    if #[cfg(any(
        feature = "openapi_swagger",
        feature = "openapi_redoc",
        feature = "openapi_scalar"
    ))] {
        #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)]
        pub struct Pager<T: utoipa::ToSchema> {
            #[serde(rename(serialize = "results"))]
            pub results: T,

            #[serde(rename(serialize = "pagination"))]
            pub info: PagerMeta,
        }
    } else {
        #[derive(Debug, serde::Deserialize, serde::Serialize)]
        pub struct Pager<T> {
            #[serde(rename(serialize = "results"))]
            pub results: T,

            #[serde(rename(serialize = "pagination"))]
            pub info: PagerMeta,
        }
    }
}

cfg_if! {
    if #[cfg(any(
        feature = "openapi_swagger",
        feature = "openapi_redoc",
        feature = "openapi_scalar"
    ))] {
        #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)]
        pub struct PagerMeta {
            #[serde(rename(serialize = "page"))]
            pub page: u64,
            #[serde(rename(serialize = "page_size"))]
            pub page_size: u64,
            #[serde(rename(serialize = "total_pages"))]
            pub total_pages: u64,
            #[serde(rename(serialize = "total_items"))]
            pub total_items: u64,
        }
    } else {
        #[derive(Debug, serde::Deserialize, serde::Serialize)]
        pub struct PagerMeta {
            #[serde(rename(serialize = "page"))]
            pub page: u64,
            #[serde(rename(serialize = "page_size"))]
            pub page_size: u64,
            #[serde(rename(serialize = "total_pages"))]
            pub total_pages: u64,
            #[serde(rename(serialize = "total_items"))]
            pub total_items: u64,
        }
    }
}

cfg_if! {
    if #[cfg(any(
        feature = "openapi_swagger",
        feature = "openapi_redoc",
        feature = "openapi_scalar"
    ))] {
        impl<T: utoipa::ToSchema> Pager<T> {
            #[must_use]
            pub const fn new(results: T, meta: PagerMeta) -> Self {
                Self {
                    results,
                    info: meta,
                }
            }
        }
    } else {
        impl<T> Pager<T> {
            #[must_use]
            pub const fn new(results: T, meta: PagerMeta) -> Self {
                Self {
                    results,
                    info: meta,
                }
            }
        }
    }
}

I think that @SorenEdwards implementation for the query is good but we should also add ToSchema to it.
It may be useful for some use cases.
So this is what I would do:

#[cfg_attr(
    any(
        feature = "openapi_swagger",
        feature = "openapi_redoc",
        feature = "openapi_scalar"
    ),
    derive(utoipa::IntoParams, utoipa::ToSchema)
)]
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct PaginationQuery {
    #[serde(
        default = "default_page_size",
        rename = "page_size",
        deserialize_with = "deserialize_pagination_filter"
    )]
    pub page_size: u64,
    #[serde(
        default = "default_page",
        rename = "page",
        deserialize_with = "deserialize_pagination_filter"
    )]
    pub page: u64,
}

@kaplanelad
Copy link
Contributor

After reviewing this implementation again, I feel we should consider moving it into an initializer maintained in a separate repository. For example:

Could you outline the changes needed in Loco to allow managing OpenAPI as an initializer?

@DenuxPlays
Copy link
Contributor

Note:

I don't think thats possible.
we need loco to implement certain traits for this to work.
examples are the above mentioned Pager and PagerMeta types

@kaplanelad
Copy link
Contributor

Note:

I don't think thats possible. we need loco to implement certain traits for this to work. examples are the above mentioned Pager and PagerMeta types

Everything is possible. When initializing is public create, you need to load Loco (see the other implementation), which allows us to make the dependent struct public.
I want to count the changes that we need to make in the framework to make it happen

@SorenEdwards
Copy link

@kaplanelad @DenuxPlays @NexVeridian I am actually using it in the initializer right now on my project/work. If you are interested I can add a post about it.

I think the real upside of adding a feature in loco would be the code generation aspect as I do a lot of this by hand currently and it can get pretty tedious. If you have any suggestions I would love to help out making the initializer approach work.

That being said seaorms --extra-model-derive could fix ToSchema not being added to the generated db entities.

@DenuxPlays
Copy link
Contributor

Note:

I don't think thats possible. we need loco to implement certain traits for this to work. examples are the above mentioned Pager and PagerMeta types

Everything is possible. When initializing is public create, you need to load Loco (see the other implementation), which allows us to make the dependent struct public.

I want to count the changes that we need to make in the framework to make it happen

I am not sure what you mean.
AFAIK traits can only be implemented on internal, your own types or if you created the trait.

So unless you copy the structs it isnt possible right?

@DenuxPlays
Copy link
Contributor

@kaplanelad @DenuxPlays @NexVeridian I am actually using it in the initializer right now on my project/work. If you are interested I can add a post about it.

I think the real upside of adding a feature in loco would be the code generation aspect as I do a lot of this by hand currently and it can get pretty tedious. If you have any suggestions I would love to help out making the initializer approach work.

That being said seaorms --extra-model-derive could fix ToSchema not being added to the generated db entities.

Yeah I also use it as an initializer.
But I don't think that Code Generation is my Main aspect.
Currently I use utoipauto to collect all the paths, schemas etc. But this would actually use the utoipa-Axum Router which is far cleaner then using utoipauto.
Also you do not have to copy types like Pager.

It is just a Smoother experience.

Just a small note you should Never Serve entities over your api.
Just use DTOs/Views for this.
Ofcourse I do Not know your project and it may be not the best Solution for your I just wanted to mention it.

@kaplanelad
Copy link
Contributor

@kaplanelad @DenuxPlays @NexVeridian I am actually using it in the initializer right now on my project/work. If you are interested I can add a post about it.

I think the real upside of adding a feature in loco would be the code generation aspect as I do a lot of this by hand currently and it can get pretty tedious. If you have any suggestions I would love to help out making the initializer approach work.

That being said seaorms --extra-model-derive could fix ToSchema not being added to the generated db entities.

Yes, it can be great. can you also share how you implement it with an example repo?

@DenuxPlays
Copy link
Contributor

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

Successfully merging this pull request may close these issues.

4 participants