stano-axum 0.2.0

Axum HTTP extractors, unified ApiError type, error-logging middleware, declarative request-authorization, and route auto-registration for the Stano platform
Documentation
# stano-axum

Axum HTTP layer: custom extractors with detailed error logging, a unified `ApiError` type that maps service errors to HTTP responses, and a standard error response format.

## Install

```toml
[dependencies]
stano-axum = { path = "../stano-axum" }
stano-common = { path = "../stano-common" }
```

## API

### Error Types

- **`ApiError`** — HTTP response error type (implements `IntoResponse`).
  - `JsonExtraction { status, body_text, rejection_type }` — JSON deserialization failed.
  - `PathExtraction { status, body_text, rejection_type }` — path parameter extraction failed.
  - `QueryExtraction { status, body_text, rejection_type }` — query parameter extraction failed.
  - `Service(ServiceError)` — service layer error (mapped to HTTP via `ServiceError` status code).
  - `Internal(String)` — unhandled internal error (returns 500; details hidden from clients).

  **Behavior when converted to HTTP response:**
  - `ServiceError::NotFound` → 404 (no error logged).
  - `ServiceError::InvalidInput` → 400 (logged).
  - `ServiceError::Conflict` → 409 (logged).
  - `ServiceError::Unauthorized` → 401 (no error logged).
  - `ServiceError::Forbidden` → 403 (no error logged).
  - `ServiceError::Internal` or `ApiError::Internal` → 500 (logged, details hidden from response).
  - Extraction errors (`JsonExtraction`, etc.) → client error status with `code: "INVALID_JSON"` / `"INVALID_PATH"` / `"INVALID_QUERY"` (logged).

- **`ErrorResponse`** — standard JSON error shape: `{ status, code, message, details?, request_id? }`. Derives `utoipa::ToSchema` so it can be referenced directly in `#[utoipa::path(responses(...))]` blocks (see Usage Example).
  - `new(status: u16, code: impl Into<...>, message: impl Into<String>) -> Self`
  - `with_details(mut self, details: impl Into<String>) -> Self`
  - `with_request_id(mut self, request_id: impl Into<String>) -> Self`

### Extractors

- **`AppJson<T>`** — JSON extractor (like `axum::Json`, but with detailed error logging).
  - `impl FromRequest<S> for AppJson<T> where T: DeserializeOwned`
  - `impl IntoResponse for AppJson<T> where T: Serialize` — also usable as a response type.

- **`AppPath<T>`** — path parameter extractor (like `axum::Path`).
  - `impl FromRequestParts<S> for AppPath<T> where T: DeserializeOwned + Send`

- **`AppQuery<T>`** — query parameter extractor (like `axum::Query`).
  - `impl FromRequestParts<S> for AppQuery<T> where T: DeserializeOwned + Send`

### Middleware

- **`error_logging_middleware`** — logs `ApiError` with request context (method, URI, request ID).
  - Reads `x-request-id` from the request and logs any `ApiError` stored in response extensions.

## Usage Example

```rust
use stano_axum::{AppJson, AppPath, ApiError, ErrorResponse};
use stano_common::{ServiceError, id_type};
use stano_launcher::{get, post};
use axum::extract::State;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use utoipa::ToSchema;

id_type!(UserId, uuid_v7);

#[derive(Serialize, Deserialize, ToSchema)]
pub struct UserRequest {
    email: String,
}

#[derive(Serialize, ToSchema)]
pub struct UserResponse {
    id: String,
    email: String,
}

// Simulate a service.
async fn create_user(req: UserRequest) -> Result<UserResponse, ServiceError> {
    if req.email.is_empty() {
        return Err(ServiceError::InvalidInput("email required".into()));
    }
    Ok(UserResponse {
        id: UserId::new().to_string(),
        email: req.email,
    })
}

// HTTP handler using custom extractors, annotated for OpenAPI generation.
// stano-launcher's #[post(...)]/#[get(...)]/etc. (see stano-launcher's README) replace
// #[utoipa::path(...)], inferring request_body/the 200 response/params(...) from the
// handler's signature — write #[post(...)] instead of #[utoipa::path], and the spec stays
// in sync automatically, with no separate `axum::Router::route` call.
#[post(
    path = "/api/users",
    responses((status = 400, body = ErrorResponse)),
)]
async fn create_user_handler(
    AppJson(req): AppJson<UserRequest>,
) -> Result<AppJson<UserResponse>, ApiError> {
    let user = create_user(req).await?; // ServiceError -> ApiError via impl From
    Ok(AppJson(user))
}

// Another handler using path extraction, with a bearer-auth requirement declared explicitly.
// `params(("user_id" = UserId, Path))` is inferred from `AppPath<UserId>` + the single
// `{user_id}` placeholder.
#[get(
    path = "/api/users/{user_id}",
    responses((status = 404, body = ErrorResponse)),
    security(("bearerAuth" = [])),
)]
async fn get_user(
    AppPath(user_id): AppPath<UserId>,
) -> Result<AppJson<UserResponse>, ApiError> {
    // Fetch user...
    Err(ServiceError::NotFound.into()) // -> 404 response
}
```

## Notes

- **Error details privacy** — 4xx responses include `details` field; 5xx responses do not (security: don't leak internals).
- **Request ID integration** — the `error_logging_middleware` reads `x-request-id` from incoming requests. For the ID to be set on the request itself, the middleware stack must include `SetRequestIdLayer` (typically applied in `stano-launcher`). The middleware still works if the ID is missing (just logs `request_id: None`).
- **No feature flags** — all APIs available.