# ApiGate
Типизированный API-шлюз (reverse proxy) для микросервисов на Rust.
Макросы генерируют маршруты с валидацией `Path / Query / Json / Form / Multipart`, хуками `before`, преобразованием `map` и runtime-политиками (балансировка, routing, таймауты). Внутри — `axum`, снаружи — только API `apigate`.
---
## Быстрый старт
```rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cfg = AppConfig::from_env();
let app = apigate::App::builder()
.backend("sales", cfg.sales_backends)
.backend("files", cfg.files_backends)
.state(cfg.db_pool.clone())
.state(cfg.auth_config.clone())
.request_timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(3))
.pool_idle_timeout(std::time::Duration::from_secs(60))
.policy(
"sales_default",
apigate::Policy::new()
.router(apigate::routing::HeaderSticky::new("x-user-id"))
.balancer(apigate::balancing::ConsistentHash::new()),
)
.mount(sales::routes!())
.mount(files::routes!())
.build()?;
apigate::run(cfg.listen, app).await
}
```
---
## Сервис
```rust
#[apigate::service(name = "sales", prefix = "/sales", policy = "sales_default")]
mod sales {
use super::*;
#[apigate::get("/ping")]
async fn ping() {}
#[apigate::get("/{id}", path = SaleIdPath, before = [auth])]
async fn get_by_id() {}
#[apigate::get("/public", to = "/internal")]
async fn public_alias() {}
#[apigate::post("/buy", json = BuyInput, before = [auth], map = remap_buy)]
async fn buy() {}
#[apigate::post("/upload", multipart, before = [auth])]
async fn upload() {}
}
```
| `name` | Имя сервиса = ключ `.backend(...)` в main |
| `prefix` | Внешний URL-префикс |
| `policy` | Политика по умолчанию для всех маршрутов сервиса |
---
## Атрибуты маршрута
```rust
#[apigate::get("/path", to = "/rewrite", path = T, query = T, json = T, form = T,
multipart, before = [hook1, hook2], map = map_fn, policy = "name")]
```
| `"/path"` | Внешний путь. Поддерживает `/{param}` |
| `to` | Путь в upstream. Без `to` — проксирует как есть (`StripPrefix`). Поддерживает `/{param}` |
| `path = T` | Десериализует и валидирует path-параметры (`T: Deserialize + Clone`). 400 при ошибке |
| `query = T` | Валидирует query string |
| `json = T` | Валидирует JSON body |
| `form = T` | Валидирует `application/x-www-form-urlencoded` body |
| `multipart` | Passthrough для `multipart/form-data` |
| `before = [...]` | Хуки, выполняемые до проксирования |
| `map = fn` | Преобразование `query/json/form` перед отправкой в upstream |
| `policy = "name"` | Переопределяет политику сервиса для этого маршрута |
> `json`, `form`, `multipart` — взаимоисключающие (один body-режим на маршрут).
---
## Входные данные
### Path
```rust
#[derive(Clone, serde::Deserialize)]
struct SaleIdPath { sale_id: uuid::Uuid }
#[apigate::get("/{sale_id}", path = SaleIdPath)]
async fn get_sale() {}
```
Извлекается **до** хуков, попадает в `RequestScope`. Доступен в хуках как `path: SaleIdPath` (owned) или `path: &SaleIdPath`.
### Query / Json / Form
```rust
#[apigate::get("/search", query = SearchQuery)] // валидация query
#[apigate::post("/buy", json = BuyInput)] // валидация JSON
#[apigate::post("/legacy", form = LegacyForm)] // валидация form
```
Без `map` — валидация + passthrough оригинального тела. С `map` — преобразование перед отправкой.
### Multipart
```rust
#[apigate::post("/upload", multipart)]
async fn upload() {}
```
Passthrough без чтения тела. `map` не поддерживается.
---
## Хуки (`before`)
Выполняются до проксирования. Работают с заголовками, URI, extensions.
```rust
#[apigate::hook]
async fn auth(ctx: &mut apigate::PartsCtx<'_>) -> apigate::HookResult {
let token = ctx.header("authorization")
.ok_or_else(|| apigate::ApigateError::unauthorized("missing token"))?;
ctx.set_header("x-user-id", "...")?;
Ok(())
}
#[apigate::get("/protected", before = [auth])]
async fn protected() {}
```
---
## Преобразование (`map`)
Типизированное преобразование `query/json/form` перед отправкой в upstream.
```rust
#[apigate::map]
async fn remap_buy(input: PublicBuy) -> apigate::MapResult<ServiceBuy> {
Ok(ServiceBuy {
ids: input.sale_ids,
source: "apigate",
})
}
#[apigate::post("/buy", json = PublicBuy, before = [auth], map = remap_buy)]
async fn buy() {}
```
Работает аналогично для `query = T, map = ...` и `form = T, map = ...`:
- **query**: map переписывает query string в URI
- **json**: map сериализует результат в новое тело
- **form**: map сериализует результат в URL-encoded тело
---
## Инъекция параметров в `hook` / `map`
Макрос анализирует типы параметров и генерирует код извлечения:
| `&mut PartsCtx<'_>` | Контекст запроса | `ctx: &mut PartsCtx<'_>` |
| `&mut RequestScope` | Прямой доступ к scope | `scope: &mut RequestScope` |
| `&T` | `scope.get::<T>()` — shared state / per-request данные | `config: &AuthConfig` |
| `&mut T` | `scope.get_mut::<T>()` — только из local | `state: &mut Counter` |
| `T` (owned в hook) | `scope.take::<T>()` — local, fallback clone из shared | `path: SaleIdPath` |
| `T` (первый owned в map) | Входные данные (json/query/form) | `input: PublicBuy` |
Все параметры опциональны.
**Ограничения:** `&mut PartsCtx` / `&mut RequestScope` — макс. по одному; `&mut T` — макс. один и нельзя совмещать с `&T`; `&mut RequestScope` нельзя совмещать с `&T` / `&mut T`.
---
## Таймауты
| `.request_timeout(Duration)` | 30s | Полное время upstream-запроса. 504 при превышении |
| `.connect_timeout(Duration)` | 5s | TCP handshake к backend'у |
| `.pool_idle_timeout(Duration)` | 90s | Время жизни idle-соединений в connection pool |
---
## Политики
Политика = routing (какие backend'ы) + balancing (какой конкретно). Дефолт: `NoRouteKey` + `RoundRobin`.
```rust
.policy("sticky_sales", apigate::Policy::new()
.router(apigate::routing::HeaderSticky::new("x-user-id"))
.balancer(apigate::balancing::ConsistentHash::new()))
```
Приоритет: атрибут маршрута > политика сервиса > дефолтная.
---
## Маршрутизация (routing)
Определяет набор кандидатов и опциональный affinity key для sticky sessions.
| `NoRouteKey` | Все backend'ы, без аффинности. **Дефолт** |
| `HeaderSticky::new("header")` | Affinity key из заголовка |
### Кастомная стратегия
```rust
use apigate::routing::{RouteStrategy, RouteCtx, RoutingDecision, AffinityKey, CandidateSet};
struct CookieSticky(&'static str);
impl RouteStrategy for CookieSticky {
fn route<'a>(&self, ctx: &'_ RouteCtx, _pool: &'a BackendPool) -> RoutingDecision<'a> {
let affinity = ctx.headers.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|c| c.split(';').map(str::trim)
.find(|s| s.starts_with(self.0))
.and_then(|s| s.split('=').nth(1)))
.map(AffinityKey::borrowed);
RoutingDecision { affinity, candidates: CandidateSet::All }
}
}
```
**`RouteCtx`**: `service`, `route_path`, `method`, `uri`, `headers`.
---
## Балансировка (balancing)
Выбирает конкретный backend из кандидатов.
| `RoundRobin::new()` | Циклический перебор. **Дефолт** |
| `ConsistentHash::new()` | Jump consistent hash по affinity key (xxh3). Без ключа — round-robin |
| `LeastRequest::new()` | Наименьшее число in-flight запросов |
| `LeastTime::new()` | Наименьшая EWMA-латентность |
Все балансировщики lock-free (атомарные операции).
### Кастомный балансировщик
```rust
use apigate::balancing::{Balancer, BalanceCtx, StartEvent, ResultEvent};
struct MyBalancer;
impl Balancer for MyBalancer {
fn pick<'a>(&self, ctx: &'a BalanceCtx<'a>) -> Option<usize> {
// ctx.candidate_len(), ctx.candidate_index(nth), ctx.affinity
Some(ctx.candidate_index(0)?)
}
fn on_start(&self, _event: &StartEvent<'_>) {} // опционально
fn on_result(&self, _event: &ResultEvent<'_>) {} // опционально
}
```
**`BalanceCtx`**: `service`, `affinity`, `pool`, `candidates`, `candidate_len()`, `candidate_index(nth)`, `candidate_backend(nth)`, `is_candidate(idx)`.
**`ResultEvent`**: `service`, `backend_index`, `status: Option<StatusCode>`, `error: Option<ProxyErrorKind>`, `head_latency: Duration`.
---
## Custom State
```rust
let app = apigate::App::builder()
.state(DbPool(pool.clone()))
.state(AuthConfig { jwt_secret: "...".into() })
// ...
```
Доступ в хуках через `&T`:
```rust
#[apigate::hook]
async fn auth(ctx: &mut apigate::PartsCtx<'_>, config: &AuthConfig) -> apigate::HookResult {
// config из shared state, zero-copy
Ok(())
}
```
State хранится в `Arc<Extensions>` — **не клонируется** на каждый запрос. `scope.get::<T>()` читает shared-ссылку. `scope.insert()` / `scope.take()` работают с per-request хранилищем.
---
## Производительность
* Без `json/query/form` — body проксируется без чтения (streaming)
* `json = T` без `map` — валидация + passthrough оригинального тела
* State: `Arc<Extensions>`, 0 heap-аллокаций для read-only доступа
* Pipeline: path + hooks + body в одном `Box::pin`
* HTTP-клиент: `TCP_NODELAY`, connection pooling, keep-alive
* `request_timeout` → `504 Gateway Timeout`
* Балансировщики lock-free (atomic counters)