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
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
8ed9348
inti
NexVeridian Nov 12, 2024
6deca2b
hook initial_openapi_spec
NexVeridian Nov 13, 2024
def3dd5
config.yaml and merge hosted doc with router
NexVeridian Nov 13, 2024
8db4443
update existing tests
NexVeridian Nov 13, 2024
34d10e6
test: OpenAPI config from file
NexVeridian Nov 14, 2024
c887042
snapshot tests
NexVeridian Nov 14, 2024
4ed2668
fix snapshot path
NexVeridian Nov 14, 2024
2aa3d66
match title, upload snapshots
NexVeridian Nov 14, 2024
0a304c1
OpenAPI json snapshot test
NexVeridian Nov 14, 2024
8c0fd96
Another snapshot
NexVeridian Nov 16, 2024
5f63b08
LocoMethodRouter
NexVeridian Nov 16, 2024
1c67cc6
fix AppContext
NexVeridian Nov 19, 2024
c1e953e
missing cfg for tests
NexVeridian Nov 19, 2024
74f78cc
clippy
NexVeridian Nov 19, 2024
ac0f1ce
openapi.josn and openapi.yaml endpoints for all types
NexVeridian Nov 20, 2024
2082e12
SecurityAddon
NexVeridian Nov 21, 2024
ea48919
fix cfg flag for import
NexVeridian Nov 23, 2024
c849c73
Merge branch 'upstream' into OpenAPI-integration
NexVeridian Nov 27, 2024
022909d
fix: snapshots and imports
NexVeridian Nov 27, 2024
165d162
Merge branch 'master' into OpenAPI-integration
kaplanelad Nov 27, 2024
00abce3
split feature openapi into feature swagger-ui redoc scalar, extract s…
NexVeridian Nov 28, 2024
2e7e750
move OpenAPIType.url
NexVeridian Dec 2, 2024
e27be7f
drop test for JWT_LOCATION.get_or_init, not possible with cargo test
NexVeridian Dec 2, 2024
b848348
rstest feature flagged cases
NexVeridian Dec 2, 2024
7a76f3a
some docs
NexVeridian Dec 3, 2024
3781c13
Merge branch 'master' into OpenAPI-integration
NexVeridian Dec 3, 2024
162ae44
clippy
NexVeridian Dec 3, 2024
bef0eb2
Merge branch 'master' into OpenAPI-integration
NexVeridian Dec 13, 2024
e1a64a6
docs: docs-site OpenAPI
NexVeridian Dec 13, 2024
8872a10
docs: SecurityAddon
NexVeridian Dec 14, 2024
e352e88
feat: format::yaml
NexVeridian Dec 16, 2024
dc97291
tests: add headers content-type to snapshots
NexVeridian Dec 16, 2024
e691e29
Merge branch 'master' into OpenAPI-integration
NexVeridian Jan 8, 2025
93c1123
mark layer as sync
NexVeridian Jan 8, 2025
edccb0c
update snapshots
NexVeridian Jan 8, 2025
a23dd55
block doc test for openapi_spec_yaml
NexVeridian Jan 8, 2025
00e72b9
cargo loco generate controller --openapi
NexVeridian Jan 8, 2025
42e9789
update features
NexVeridian Jan 15, 2025
152de4b
prelude and docs update
NexVeridian Jan 15, 2025
d7d8774
Merge branch 'master' into OpenAPI-integration
NexVeridian Jan 15, 2025
4958d36
more prelude changes
NexVeridian Jan 15, 2025
f833e6b
make unused openapi optional in config file
NexVeridian Jan 15, 2025
2569837
snapshots
NexVeridian Jan 15, 2025
b492d68
fix panic for set_jwt_location_ctx
NexVeridian Jan 15, 2025
970e6c6
remove demo app test
NexVeridian Jan 16, 2025
e0c1f89
bump version of utoipa
NexVeridian Jan 16, 2025
d72c460
Merge branch 'master' into OpenAPI-integration
kaplanelad Jan 18, 2025
ba43fcf
swap assert_debug_snapshot to json or yaml snapshot
NexVeridian Jan 21, 2025
facb0ae
cargo format
NexVeridian Jan 21, 2025
06da6e9
hide loco version from snapshot
NexVeridian Jan 21, 2025
819b45e
allow and defualt to None jwt_location
NexVeridian Jan 21, 2025
6c3d1aa
Merge branch 'master' into OpenAPI-integration
NexVeridian Jan 28, 2025
05d90af
clippy
NexVeridian Jan 28, 2025
9107d3e
drop demo test
NexVeridian Jan 28, 2025
0a7df8c
drop generator command for now
NexVeridian Jan 28, 2025
9f6bac2
clippy
NexVeridian Jan 28, 2025
8a0c996
clippy fix from main branch
NexVeridian Jan 28, 2025
0a09a5c
fmt from main branch
NexVeridian Jan 28, 2025
902f65a
fix doc tests
NexVeridian Jan 29, 2025
fb9e237
some derive(ToSchema) and derive(IntoParams)
NexVeridian Jan 30, 2025
9a74966
more ToSchema
NexVeridian Jan 30, 2025
5f4c63f
Revert "more ToSchema" and "some derive(ToSchema) and derive(IntoPara…
NexVeridian Jan 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ auth_jwt = ["dep:jsonwebtoken"]
cli = ["dep:clap"]
testing = ["dep:axum-test", "dep:scraper"]
with-db = ["dep:sea-orm", "dep:sea-orm-migration", "loco-gen/with-db"]
# OpenAPI features
all_openapi = ["openapi_swagger", "openapi_redoc", "openapi_scalar"]
openapi_swagger = ["dep:utoipa", "dep:utoipa-axum", "dep:utoipa-swagger-ui"]
openapi_redoc = ["dep:utoipa", "dep:utoipa-axum", "dep:utoipa-redoc"]
openapi_scalar = ["dep:utoipa", "dep:utoipa-axum", "dep:utoipa-scalar"]
# Storage features
all_storage = ["storage_aws_s3", "storage_azure", "storage_gcp"]
storage_aws_s3 = ["opendal/services-s3"]
Expand Down Expand Up @@ -130,6 +135,13 @@ cfg-if = "1"

uuid = { version = "1.10.0", features = ["v4", "fast-rng"] }

# OpenAPI
utoipa = { workspace = true, optional = true }
utoipa-axum = { workspace = true, optional = true }
utoipa-swagger-ui = { workspace = true, optional = true }
utoipa-redoc = { workspace = true, optional = true }
utoipa-scalar = { workspace = true, optional = true }

# File Upload
opendal = { version = "0.50.2", default-features = false, features = [
"services-memory",
Expand Down Expand Up @@ -183,6 +195,13 @@ tower-http = { version = "0.6.1", features = [
] }
heck = "0.4.0"

# OpenAPI
utoipa = { version = "5.0.0", features = ["yaml"] }
utoipa-axum = { version = "0.2.0" }
utoipa-swagger-ui = { version = "9.0", features = ["axum", "vendored"] }
utoipa-redoc = { version = "6.0.0", features = ["axum"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"] }

[dependencies.sea-orm-migration]
optional = true
version = "1.0.0"
Expand All @@ -201,7 +220,12 @@ features = ["testing"]
[dev-dependencies]
loco-rs = { path = ".", features = ["testing"] }
rstest = "0.21.0"
insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] }
insta = { version = "1.34.0", features = [
"redactions",
"yaml",
"json",
"filters",
] }
tree-fs = { version = "0.2.1" }
reqwest = { version = "0.12.7" }
serial_test = "3.1.1"
Expand Down
140 changes: 134 additions & 6 deletions docs-site/content/docs/the-app/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Note that by-design _sharing state between controllers and workers have no meani

### Shared state in tasks

Tasks don't really have a value for shared state, as they have a similar life as any exec'd binary. The process fires up, boots, creates all resources needed (connects to db, etc.), performs the task logic, and then the
Tasks don't really have a value for shared state, as they have a similar life as any exec'd binary. The process fires up, boots, creates all resources needed (connects to db, etc.), performs the task logic, and then the

## Routes in Controllers

Expand Down Expand Up @@ -416,7 +416,7 @@ By default, Loco uses Bearer authentication for JWT. However, you can customize
auth:
# JWT authentication
jwt:
location:
location:
from: Cookie
name: token
...
Expand All @@ -427,7 +427,7 @@ By default, Loco uses Bearer authentication for JWT. However, you can customize
auth:
# JWT authentication
jwt:
location:
location:
from: Query
name: token
...
Expand Down Expand Up @@ -700,7 +700,7 @@ middlewares:
```

## CORS
This middleware enables Cross-Origin Resource Sharing (CORS) by allowing configurable origins, methods, and headers in HTTP requests.
This middleware enables Cross-Origin Resource Sharing (CORS) by allowing configurable origins, methods, and headers in HTTP requests.
It can be tailored to fit various application requirements, supporting permissive CORS or specific rules as defined in the middleware configuration.

```yaml
Expand Down Expand Up @@ -764,6 +764,134 @@ impl Hooks for App {
}
```

# OpenAPI Integration Setup
The Loco OpenAPI integration is generated using [`Utoipa`](https://github.com/juhaku/utoipa)

## `Cargo.toml` features flages
Edit your `Cargo.toml` file and add one or multiple of the following features flages:
- `openapi_swagger`
- `openapi_redoc`
- `openapi_scalar`
- `all_openapi`

```toml
[workspace.dependencies]
loco-rs = { version = "0.14", features = [
"openapi_scalar",
] }
```

## Configuration
Add the corresponding OpenAPI visualizer to the config file
```yaml
#...
server:
...
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_json_url is required for swagger-ui
# spec_yaml_url: /api-docs/openapi.yaml
```
## Inital OpenAPI Spec
Modifies the OpenAPI spec before the routes are added, allowing you to edit [`openapi::info`](https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html)

```rust
// src/app.rs
use loco_rs::auth::openapi::{set_jwt_location_ctx, SecurityAddon};

impl Hooks for App {
#...
fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi {
#[derive(OpenApi)]
#[openapi(
modifiers(&SecurityAddon),
info(
title = "Loco Demo",
description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project."
)
)]
struct ApiDoc;
set_jwt_location_ctx(ctx);

ApiDoc::openapi()
}
```

## Generating the OpenAPI spec for a route
Only routes that are annotated with [`utoipa::path`](https://docs.rs/utoipa/latest/utoipa/attr.path.html) will be included in the OpenAPI spec.

```rust
#[utoipa::path(
get,
path = "/album",
responses(
(status = 200, description = "Album found", body = Album),
),
)]
async fn get_action_openapi() -> Result<Response> {
format::json(Album {
title: "VH II".to_string(),
rating: 10,
})
}
```

Make sure to add `#[derive(ToSchema)]` on any struct that included in [`utoipa::path`](https://docs.rs/utoipa/latest/utoipa/attr.path.html).
```rust
#[derive(Serialize, Debug, ToSchema)]
pub struct Album {
title: String,
rating: u32,
}
```

If `modifiers(&SecurityAddon)` is set in `inital_openapi_spec`, you can document the per route security in `utoipa::path`:
- `security(("jwt_token" = []))`
- `security(("api_key" = []))`
- or leave blank to remove security from the route `security(())`

Example:
```rust
#[utoipa::path(
get,
path = "/album",
security(("jwt_token" = [])),
responses(
(status = 200, description = "Album found", body = Album),
),
)]
```

## Adding routes to the OpenAPI spec visualizer
Swap the `axum::routing::MethodFilter` to `routes!`
### Before
```rust
Routes::new()
.add("/album", get(get_action_openapi)),
```
### After
```rust
Routes::new()
.add("/album", routes!(get_action_openapi)),
```
### Note: do not add multiple routes inside the `routes!` macro
```rust
Routes::new()
.add("/album", routes!(get_action_1_do_not_do_this, get_action_2_do_not_do_this)),
```

# Request Validation
`JsonValidate` extractor simplifies input [validation](https://github.com/Keats/validator) by integrating with the validator crate. Here's an example of how to validate incoming request data:

Expand Down Expand Up @@ -826,7 +954,7 @@ If you'd like to return validation errors in a structured JSON format, use `Json
]
}
}
```
```

# Pagination

Expand Down Expand Up @@ -925,7 +1053,7 @@ impl PaginationResponse {
```


# Testing
# Testing
When testing controllers, the goal is to call the router's controller endpoint and verify the HTTP response, including the status code, response content, headers, and more.

To initialize a test request, use `use loco_rs::testing::prelude::*;`, which prepares your app routers, providing the request instance and the application context.
Expand Down
39 changes: 39 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,45 @@ pub trait Hooks: Send {
/// This function allows users to perform any necessary cleanup or final
/// actions before the application stops completely.
async fn on_shutdown(_ctx: &AppContext) {}

/// Modifies the OpenAPI spec before the routes are added, allowing you to edit [openapi::info](https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html)
/// # Examples
/// ```rust ignore
/// fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi {
/// #[derive(OpenApi)]
/// #[openapi(info(
/// title = "Loco Demo",
/// description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project."
/// ))]
/// struct ApiDoc;
/// ApiDoc::openapi()
/// }
/// ```
///
/// With SecurityAddon
/// ```rust ignore
/// fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi {
/// #[derive(OpenApi)]
/// #[openapi(
/// modifiers(&SecurityAddon),
/// info(
/// title = "Loco Demo",
/// description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project."
/// )
/// )]
/// struct ApiDoc;
/// set_jwt_location_ctx(ctx);
///
/// ApiDoc::openapi()
/// }
/// ```
#[cfg(any(
feature = "openapi_swagger",
feature = "openapi_redoc",
feature = "openapi_scalar"
))]
#[must_use]
fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi;
}

/// An initializer.
Expand Down
6 changes: 6 additions & 0 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
#[cfg(feature = "auth_jwt")]
pub mod jwt;
#[cfg(any(
feature = "openapi_swagger",
feature = "openapi_redoc",
feature = "openapi_scalar"
))]
pub mod openapi;
68 changes: 68 additions & 0 deletions src/auth/openapi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use std::sync::OnceLock;

use utoipa::{
openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme},
Modify,
};

use crate::{app::AppContext, config::JWTLocation};

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()
}
Comment on lines +10 to +33
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


pub struct SecurityAddon;

/// Adds security to the `OpenAPI` doc, using the JWT location in the config
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(jwt_location) = get_jwt_location() {
if let Some(components) = openapi.components.as_mut() {
components.add_security_schemes_from_iter([
(
"jwt_token",
match jwt_location {
JWTLocation::Bearer => SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
JWTLocation::Query { name } => {
SecurityScheme::ApiKey(ApiKey::Query(ApiKeyValue::new(name)))
}
JWTLocation::Cookie { name } => {
SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(name)))
}
},
),
(
"api_key",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("apikey"))),
),
]);
}
}
}
}
4 changes: 2 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ enum ComponentArg {
/// Generates a new model file for defining the data structure of your
/// application, and test file logic.
#[command(after_help = format!(
"{}
"{}
- Generate empty model:
$ cargo loco g model posts

Expand Down Expand Up @@ -272,7 +272,7 @@ After running the migration, follow these steps to complete the process:
},
/// Generate a new controller with the given controller name, and test file.
#[command(after_help = format!(
"{}
"{}
- Generate an empty controller:
$ cargo loco generate controller posts --api

Expand Down
Loading
Loading