Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .changepacks/changepack_log_dN7zCG1siUU55ptEo9GMn.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"Implement export app","date":"2026-01-25T13:55:16.361701200Z"}
37 changes: 34 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,27 @@ let app = vespera!(
servers = [ // OpenAPI servers
{ url = "https://api.example.com", description = "Production" },
{ url = "http://localhost:3000", description = "Development" }
]
],
merge = [crate1::App1, crate2::App2] // Merge child vespera apps
);
```

## `export_app!` Macro Reference

Export a vespera app for merging into other apps:

```rust
// Basic usage (scans "routes" folder by default)
vespera::export_app!(MyApp);

// Custom directory
vespera::export_app!(MyApp, dir = "api");
```

Generates a struct with:
- `MyApp::OPENAPI_SPEC: &'static str` - The OpenAPI JSON spec
- `MyApp::router() -> Router` - Function returning the Axum router

### Environment Variable Fallbacks

All parameters support environment variable fallbacks:
Expand Down Expand Up @@ -251,6 +268,40 @@ let app = vespera!("api");
let app = vespera!(dir = "api");
```

### Merging Multiple Vespera Apps

Combine routes and OpenAPI specs from multiple vespera apps at compile time:

**Child app (e.g., `third` crate):**
```rust
// src/lib.rs
mod routes;

// Export app for merging (dir defaults to "routes")
vespera::export_app!(ThirdApp);

// Or with custom directory
// vespera::export_app!(ThirdApp, dir = "api");
```

**Parent app:**
```rust
// src/main.rs
use vespera::vespera;

let app = vespera!(
openapi = "openapi.json",
docs_url = "/docs",
merge = [third::ThirdApp] // Merges router AND OpenAPI spec
)
.with_state(app_state);
```

This automatically:
- Merges all routes from child apps into the parent router
- Combines OpenAPI specs (paths, schemas, tags) into a single spec
- Makes Swagger UI show all routes from all apps

---

## Type Mapping
Expand Down
58 changes: 58 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,61 @@ npx @apidevtools/swagger-cli validate openapi.json
| `VESPERA_DOCS_URL` | Swagger UI path | none |
| `VESPERA_REDOC_URL` | ReDoc path | none |
| `VESPERA_SERVER_URL` | Server URL | `http://localhost:3000` |

---

## Merging Multiple Vespera Apps

Combine routes and OpenAPI specs from multiple apps at compile time.

### export_app! Macro

Export an app for merging:

```rust
// Child crate (e.g., third/src/lib.rs)
mod routes;

// Basic - scans "routes" folder by default
vespera::export_app!(ThirdApp);

// Custom directory
vespera::export_app!(ThirdApp, dir = "api");
```

Generates:
- `ThirdApp::OPENAPI_SPEC: &'static str` - OpenAPI JSON
- `ThirdApp::router() -> Router` - Axum router

### merge Parameter

Merge child apps in parent:

```rust
let app = vespera!(
openapi = "openapi.json",
docs_url = "/docs",
merge = [third::ThirdApp, other::OtherApp]
)
.with_state(state);
```

**What happens:**
1. Child routers merged into parent router
2. OpenAPI specs merged (paths, schemas, tags)
3. Swagger UI shows all routes

### How It Works (Compile-Time)

```
Child compilation (export_app!):
1. Scan routes/ folder
2. Generate OpenAPI spec
3. Write to target/vespera/{Name}.openapi.json

Parent compilation (vespera! with merge):
1. Generate parent OpenAPI spec
2. Read child specs from target/vespera/
3. Merge all specs together
4. Write merged openapi.json
```
3 changes: 3 additions & 0 deletions crates/vespera/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ vespera_core = { workspace = true }
vespera_macro = { workspace = true }
axum = "0.8"
axum-extra = { version = "0.12", optional = true }
serde_json = "1"
tower-layer = "0.3"
tower-service = "0.3"
76 changes: 75 additions & 1 deletion crates/vespera/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ pub mod openapi {
pub use vespera_core::openapi::*;
}

// Re-export OpenApi directly for convenience (used by merge feature)
pub use vespera_core::openapi::OpenApi;

// Re-export macros from vespera_macro
pub use vespera_macro::{Schema, route, vespera};
pub use vespera_macro::{Schema, export_app, route, vespera};

// Re-export serde_json for merge feature (runtime spec merging)
pub use serde_json;

// Re-export axum for convenience
pub mod axum {
Expand All @@ -27,3 +33,71 @@ pub mod axum {
pub mod axum_extra {
pub use axum_extra::*;
}

/// A router wrapper that defers merging until `with_state()` is called.
///
/// This is necessary because in Axum, routers can only be merged when they have
/// the same state type. By deferring the merge, we ensure that:
/// 1. The base router's `.with_state()` is called first, converting it to `Router<()>`
/// 2. Then the child routers (also `Router<()>`) are merged
///
/// This wrapper is returned by `vespera!()` when the `merge` parameter is used.
pub struct VesperaRouter<S>
where
S: Clone + Send + Sync + 'static,
{
base: axum::Router<S>,
/// Routers to merge after `with_state()` is called
merge_fns: Vec<fn() -> axum::Router<()>>,
}

impl<S> VesperaRouter<S>
where
S: Clone + Send + Sync + 'static,
{
/// Create a new VesperaRouter with a base router and routers to merge
pub fn new(base: axum::Router<S>, merge_fns: Vec<fn() -> axum::Router<()>>) -> Self {
Self { base, merge_fns }
}

/// Provide the state for the router and merge all child routers.
///
/// This is equivalent to calling `Router::with_state()` and then merging
/// all the child routers.
///
/// After calling `with_state()`, the router's state type becomes `()` because
/// the state has been provided. Child routers (also `Router<()>`) can then be merged.
pub fn with_state(self, state: S) -> axum::Router<()> {
// First, apply the state to convert Router<S> to Router<()>
let mut router: axum::Router<()> = self.base.with_state(state);

// Then merge all child routers (they are Router<()> which can be merged
// into Router<()> without issues)
for merge_fn in self.merge_fns {
router = router.merge(merge_fn());
}

router
}

/// Add a layer to the router.
pub fn layer<L>(self, layer: L) -> Self
where
L: tower_layer::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
L::Service: tower_service::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
<L::Service as tower_service::Service<axum::extract::Request>>::Response:
axum::response::IntoResponse + 'static,
<L::Service as tower_service::Service<axum::extract::Request>>::Error:
Into<std::convert::Infallible> + 'static,
<L::Service as tower_service::Service<axum::extract::Request>>::Future: Send + 'static,
{
Self {
base: self.base.layer(layer),
merge_fns: self.merge_fns,
}
}
}

// Re-export tower_layer and tower_service for the layer method
pub use tower_layer;
pub use tower_service;
Loading