solti-api
Dual-transport API layer exposing task operations over gRPC and HTTP.
Both transports delegate to an ApiHandler trait, decoupling wire format from business logic. Both speak the same proto contract defined in proto/solti/task/v1/.
Architecture
Control Plane / Client
│
├──► gRPC (feature = "grpc")
│ └──► TaskApiService<H>
│ │
├──► HTTP (feature = "http")
│ └──► HttpApi<H> (axum Router)
│ │
▼ ▼
ApiHandler trait (transport-agnostic)
│
▼
SupervisorApiAdapter
│
▼
solti_core::SupervisorApi
Versioning
solti-api exports API_VERSION: u32 - the current protocol version.
Binary passes it to solti_discover::DiscoverConfig::builder(... , API_VERSION), which reports it to the control-plane via SyncRequest. One binary = one API version.
use API_VERSION;
use ;
use AgentId;
let config = builder
.build?;
Bump rules:
- New field in existing message - no bump (proto3 backwards compatible)
- New RPC - no bump (control-plane does not call unsupported RPCs)
- Removed/renamed field, changed semantics - bump
- New proto package (
solti.v2) - bump
Internal crate compatibility is handled by cargo semver.
Per-version API surface is documented in separate files: api_v1.md.
Key types
| Type | Role |
|---|---|
ApiHandler |
Transport-agnostic trait with 6 operations (CRUD + log stream) |
OutputEventStream |
Pin<Box<dyn Stream<Item = OutputEvent> + Send>> returned by stream_task_logs |
SupervisorApiAdapter |
Default adapter bridging to SupervisorApi |
ApiError |
Unified error mapped to gRPC Status / HTTP JSON |
TaskApiService<H> |
gRPC server impl (feature grpc) |
HttpApi<H> |
axum router builder (feature http) |
BearerAuth |
gRPC interceptor verifying the inbound bearer token (feature grpc) |
API_VERSION |
Protocol version constant reported via discover |
Error model
| Variant | gRPC Status | HTTP Status | error label (HTTP body) |
|---|---|---|---|
InvalidRequest |
INVALID_ARGUMENT |
400 Bad Request |
"InvalidRequest" |
Unauthenticated |
UNAUTHENTICATED |
401 Unauthorized |
"Unauthenticated" |
TaskNotFound |
NOT_FOUND |
404 Not Found |
"TaskNotFound" |
PayloadTooLarge |
RESOURCE_EXHAUSTED |
413 Payload Too Large |
"PayloadTooLarge" |
Internal |
INTERNAL |
500 Internal Server Error |
"Internal" |
Core |
derived from inner CoreError |
derived from inner | derived from inner |
Core is split by the wrapped [solti_core::CoreError]:
InvalidSpec→INVALID_ARGUMENT/400 Bad Request/"InvalidRequest"AlreadyExists→ALREADY_EXISTS/409 Conflict/"AlreadyExists"NotFound→NOT_FOUND/404 Not Found/"TaskNotFound"- everything else (
Supervisor,Mapping,Runner) →INTERNAL/500 Internal Server Error/"Internal"
HTTP error body:
HTTP requests return 413 Payload Too Large with a JSON envelope ({"error": "PayloadTooLarge", "message": "…"}) when the body exceeds MAX_REQUEST_BYTES (4 MiB).
gRPC calls return RESOURCE_EXHAUSTED for oversize messages.
Script bodies are separately capped in the model at [solti_model::MAX_SCRIPT_BODY_BYTES] (2 MiB after base64 decode): oversize bodies are rejected as InvalidRequest.
Feature flags
| Flag | Enables | Dependencies |
|---|---|---|
grpc |
TaskApiService, TaskServiceServer, proto codegen |
tonic, tonic-prost, prost |
http |
HttpApi, axum router, proto-JSON serde |
axum, serde_json, prost, pbjson |
tls |
to_tonic_server_tls(&ServerTlsConfig) adapter (under grpc); pulls solti-tls |
solti-tls; activates tonic/tls-ring |
No feature is enabled by default. tls implies grpc — the adapter targets tonic, so enabling tls alone would pull solti-tls in without compiling anything. HTTP TLS is terminated by the binary via axum-server (see below), not by this feature.
Enabling TLS
For gRPC:
use ;
use ServerTlsConfig;
let server_tls = builder
.cert_pem_file
.key_pem_file
.require_client_ca_pem_file // optional
.build?;
let tls_cfg = to_tonic_server_tls?;
builder
.tls_config?
.add_service
.serve
.await?;
For HTTP, terminate TLS in your binary via axum-server using the rustls::ServerConfig produced by solti_tls::ServerTlsConfig::into_rustls_config().
See the solti-tls README for the full pattern.
Enabling token auth
Require a bearer token on every inbound call.
The token is the same shared secret the agent presents to the control plane in discovery (solti_model::Token), one config value gates both directions.
Orthogonal to TLS; comparison is constant-time; a missing/invalid token is rejected with 401 (HTTP) / Unauthenticated (gRPC) before reaching any handler.
HTTP:
use HttpApi;
use Token;
let router = new
.with_auth
.router;
gRPC:
use build_grpc_server_with_auth;
use Token;
let svc = build_grpc_server_with_auth;
builder
.add_service
.serve
.await?;
When no token is configured (plain HttpApi::new(...).router() / build_grpc_server(...)), no auth is enforced.
Build
build.rs walks proto/ recursively, collecting every *.proto file (emitting rerun-if-changed for each). Two codegen passes:
tonic_prost_build::configure(): message types always, tonic server/client only undergrpc.pbjson_buildunderhttp: attaches canonical proto-JSONSerialize/Deserializeto the same message types, with.emit_fields()enabled so REST clients see0/false/""/[]/{}for default scalar/repeated/map values (optionalmessagefields still omit onNone).
The proto package selector is derived in build.rs as format!(".solti.task.v{API_MAJOR}").
If the package declaration in a .proto changes, keep it in lockstep with API_MAJOR - otherwise pbjson generates nothing and HTTP compile fails.
Adding new .proto files anywhere under proto/ requires no changes to build.rs.
Notes
ApiHandlerusesasync_traitfor object safety (Send + Sync + 'static).- Both transports feed input through the same
convert_create_specvalidator. - Re-exports:
solti_api::tonic,solti_api::axumfor version pinning. - Proto contract in
proto/solti/task/v1/(api.proto,types.proto).