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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ User-visible changes are recorded here.

## Unreleased

### Added

- Embed the data-only HTTP API in an existing host with `http::DataService`,
using a mountable Axum router or a direct request handler. Reuse SQLRest's
parsing, deadlines and errors without binding standalone listeners.

## 0.0.1 - 2026-09-15

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Read [interfaces](docs/interfaces.md), [execution](docs/execution.md),
[registry](docs/registry.md), [migrations](docs/migrations.md), and
[HTTP](docs/http.md) for details. Embedded users call `Registry` from Tokio;
standalone callers use the same core through HTTP.
Hosts with an existing HTTP server can mount `http::DataService::router()` with
Axum's `nest_service` or
call `DataService::handle()` without binding SQLRest listeners; see
[HTTP embedding](docs/http.md#embedding-and-shutdown).

Important boundaries:

Expand Down
45 changes: 45 additions & 0 deletions docs/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,51 @@ shutdown cancels incomplete uploads. Other management routes ignore bodies.

## Embedding and shutdown

To mount data HTTP access inside an existing Axum host without opening either
SQLRest listener, use `DataService`:

```rust
use axum::Router;
use sqlrest::{http::DataService, registry::Registry};

fn mount(registry: Registry) -> (Router, DataService) {
let data = DataService::new(registry);
let app = Router::new().nest_service("/api/sql", data.router());
(app, data)
}
```

Requests use `/api/sql/db/{name}/...`; Axum strips `/api/sql` before dispatch.
The host must apply its login and per-database authorization before forwarding
requests. No management endpoints are installed. Continue using Registry methods
for publication and status in the host's trusted code.

Use `nest_service`, not `nest`: SQLRest's router uses a fallback handler, and
Axum's `route_layer` does not cover nested fallbacks. With `nest_service`, apply
the host's authorization `route_layer` **after** mounting the service so it
protects every method under the mount, including unknown paths. The mount alone
does not authenticate requests. If forwarding through a custom fallback instead,
use middleware that also covers fallbacks (such as `layer`), not `route_layer`.

For a custom adapter, `data.handle(request).await` accepts an
`axum::extract::Request` and returns an `axum::response::Response`. Supply the
URI as `/db/{name}/...`, preserving its encoded path and query; strip only the
outer mount prefix, without decoding/re-encoding parameters. Both entrypoints
reuse the standalone server's request parsing, deadlines, error serialization,
HEAD handling and execution. Host middleware may intentionally impose additional
limits; use a fallback or all-method forwarding route to preserve SQLRest's
explicit HEAD/OPTIONS and JSON 404/405 behavior.

Keep a `DataService` clone for lifecycle management. During host shutdown,
call `data.shutdown().await` while request tasks and Tokio are still running,
alongside draining the host's own HTTP server. It closes the entire shared
Registry, cancels incomplete uploads on this service and its clones, and waits
for core cleanup. It does not stop host listeners. Dropping a clone/router does
not shut down the Registry. Independently constructed services have independent
upload-cancellation tokens; prefer clones when mounting the same service more
than once. OpenAPI remains available through the Registry; use the external
server URL `/api/sql/db/{name}` when generating it.

Open with `Registry::open(workspace).await` in a live Tokio runtime, or pass it to
`http::Server::bind` / `from_listeners`. Await `serve(shutdown_future)` to serve
both listeners. `Registry::shutdown().await` closes global admission and drains
Expand Down
71 changes: 67 additions & 4 deletions src/http.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Independent data and management listeners over the shared Registry.
//! Embeddable data HTTP service and independent data/management listeners.
use crate::{
SqlrestError,
params::Input,
Expand All @@ -16,6 +16,68 @@ use std::{future::Future, net::SocketAddr};
use tokio::{net::TcpListener, task::JoinSet};
use tokio_util::sync::CancellationToken;

/// Data-only HTTP access to a shared Registry, without binding any listeners.
///
/// Requests use `/db/{name}/...`. The host owns authentication and authorization
/// for each database and strips any outer mount prefix before calling `handle`.
/// Clones share shutdown state; dropping a clone does not shut down the service.
#[derive(Clone)]
pub struct DataService {
state: ServiceState,
}

impl DataService {
pub fn new(registry: Registry) -> Self {
Self {
state: ServiceState {
registry,
stop: CancellationToken::new(),
},
}
}

/// Handle an HTTP request using the standalone server's complete data contract.
pub async fn handle(&self, request: Request) -> Response {
data_handler(State(self.state.clone()), request).await
}

/// Mountable router with no management routes or implicit HEAD/OPTIONS.
///
/// Mount with `Router::nest_service`, which strips the mount prefix and lets
/// the host apply `route_layer` authorization to the mounted service.
/// Do not use `Router::nest` with host `route_layer`: this router uses a
/// fallback handler, and `route_layer` does not protect nested fallbacks.
///
/// ```
/// use axum::Router;
/// use sqlrest::{http::DataService, registry::Registry};
///
/// fn mount(registry: Registry) -> (Router, DataService) {
/// let data = DataService::new(registry);
/// let app = Router::new().nest_service("/api/sql", data.router());
/// // The host must add its authentication/authorization middleware.
/// // Requests now use /api/sql/db/{name}/...
/// (app, data)
/// }
/// ```
pub fn router(&self) -> Router {
Router::new()
.fallback(data_handler)
.with_state(self.state.clone())
}

/// Close admission, cancel incomplete uploads, and await core cleanup.
///
/// This shuts down the entire shared Registry, including other services
/// using it. Keep Tokio alive until this future completes. The host remains
/// responsible for stopping its own listeners.
pub async fn shutdown(&self) -> Result<(), SqlrestError> {
self.state.registry.start_shutdown()?;
self.state.stop.cancel();
self.state.registry.shutdown().await
}
}

pub struct Server {
registry: Registry,
data: TcpListener,
Expand Down Expand Up @@ -77,9 +139,10 @@ impl Server {
};
// A fallback handler keeps explicit HEAD/OPTIONS and our JSON errors;
// it does not install implicit GET-to-HEAD or CORS behavior.
let data = Router::new()
.fallback(data_handler)
.with_state(state.clone());
let data = DataService {
state: state.clone(),
}
.router();
let management = Router::new().fallback(management_handler).with_state(state);
let mut servers = JoinSet::new();
let data_stop = stop.clone();
Expand Down
Loading
Loading