use flatbuffers::FlatBufferBuilder;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
use std::ops::Deref;
#[cfg(feature = "telemetry")]
use std::sync::Arc;
use std::sync::LazyLock;
#[cfg(feature = "nats")]
pub mod nats;
#[cfg(feature = "nats")]
pub use nats::{run_stream_worker, HasJetStream, NatsResult, StreamWorker};
#[cfg(feature = "nats")]
pub mod kv;
#[cfg(feature = "nats")]
pub use kv::{run_kv_worker, KvWorker};
#[cfg(feature = "nats")]
#[doc(hidden)]
pub use async_nats;
#[cfg(any(feature = "nats", feature = "k8s"))]
#[doc(hidden)]
pub use futures;
#[cfg(feature = "k8s")]
pub mod k8s;
#[cfg(feature = "k8s")]
pub use k8s::{
run_kube_native_reconciler, run_kube_watcher, wait_for_follower, wait_for_follower_loss,
wait_for_leadership, wait_for_leadership_loss, HasKubeClient, HasLeaderElection,
KubeNativeReconciler, KubeWatcher, ReconcileError,
};
#[cfg(all(feature = "nats", feature = "k8s"))]
pub use k8s::{run_kube_reconciler, KubeReconciler};
#[cfg(any(feature = "nats", feature = "k8s"))]
mod context;
#[cfg(any(feature = "nats", feature = "k8s"))]
pub use context::FlatbedContext;
#[cfg(feature = "k8s")]
#[doc(hidden)]
pub use kube;
pub mod hyper;
#[cfg(feature = "telemetry")]
pub mod telemetry;
#[cfg(feature = "telemetry")]
pub use telemetry::{TelemetryConfig, TelemetryError, TelemetryService};
pub use flatbuffers;
pub use serde;
pub use serde_json;
#[cfg(feature = "openapi")]
pub use utoipa;
#[doc(hidden)]
pub use tokio;
pub type BoxFuture<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
pub use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
#[doc(hidden)]
pub use uuid;
#[derive(Clone)]
pub struct FlatbedConfig {
pub title: &'static str,
pub description: Option<&'static str>,
pub external_url: Option<String>,
pub host: String,
pub port: u16,
pub splash: Option<String>,
#[cfg(feature = "telemetry")]
pub telemetry: Option<Arc<dyn TelemetryService>>,
}
impl Default for FlatbedConfig {
fn default() -> Self {
Self {
title: "API",
description: None,
external_url: None,
host: "0.0.0.0".to_string(),
port: 8080,
splash: None,
#[cfg(feature = "telemetry")]
telemetry: None,
}
}
}
impl fmt::Debug for FlatbedConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("FlatbedConfig");
debug
.field("title", &self.title)
.field("description", &self.description)
.field("external_url", &self.external_url)
.field("host", &self.host)
.field("port", &self.port)
.field("splash", &self.splash.as_ref().map(|_| "<splash>"));
#[cfg(feature = "telemetry")]
debug.field(
"telemetry",
&self.telemetry.as_ref().map(|_| "<TelemetryService>"),
);
debug.finish()
}
}
impl FlatbedConfig {
pub fn new(title: &'static str) -> Self {
Self {
title,
description: None,
external_url: None,
host: "0.0.0.0".to_string(),
port: 8080,
splash: None,
#[cfg(feature = "telemetry")]
telemetry: None,
}
}
pub fn host(mut self, host: &str) -> Self {
self.host = host.to_string();
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn description(mut self, desc: &'static str) -> Self {
self.description = Some(desc);
self
}
pub fn splash(mut self, splash: impl Into<String>) -> Self {
self.splash = Some(splash.into());
self
}
pub fn external_url(mut self, url: impl Into<String>) -> Self {
self.external_url = Some(url.into());
self
}
#[cfg(feature = "telemetry")]
pub fn with_telemetry(mut self, telemetry: Arc<dyn TelemetryService>) -> Self {
self.telemetry = Some(telemetry);
self
}
pub fn bind_address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
pub struct Flatbed;
impl Flatbed {
pub async fn run<C, F, Fut>(config: FlatbedConfig, boot: F) -> Result<(), Error>
where
C: Clone + Send + Sync + 'static,
F: FnOnce(FlatbedConfig) -> Fut + Send,
Fut: std::future::Future<Output = Result<C, Box<dyn std::error::Error + Send + Sync>>>
+ Send,
{
use std::sync::Arc;
use tokio::sync::{watch, RwLock};
crate::validate_routes().map_err(|e| Error::Custom(format!("Route conflict: {}", e)))?;
crate::validate_type_registry()
.map_err(|e| Error::Custom(format!("Type registry conflict: {}", e)))?;
let router = Arc::new(hyper::build_router());
let addr: std::net::SocketAddr = config
.bind_address()
.parse()
.map_err(|e| Error::Custom(format!("Invalid bind address: {}", e)))?;
let (healthz_tx, healthz_rx) = watch::channel(false);
let (ready_tx, ready_rx) = watch::channel(false);
let context: Arc<RwLock<Option<Arc<C>>>> = Arc::new(RwLock::new(None));
let boot_config = config.clone();
let service_ctx = hyper::ServiceContext {
router,
healthz_rx,
ready_rx,
context: Arc::clone(&context),
config,
static_routes: Arc::new(crate::get_static_routes()),
};
let server = hyper::AutoServer::new(addr, service_ctx, healthz_tx.clone());
let server_handle = tokio::spawn(async move { server.serve().await });
let _ = healthz_tx.send(true);
let ctx = boot(boot_config)
.await
.map_err(|e| Error::Custom(format!("Boot error: {}", e)))?;
{
let mut guard = context.write().await;
*guard = Some(Arc::new(ctx));
}
let _ = ready_tx.send(true);
server_handle
.await
.map_err(|e| Error::Custom(format!("Server task error: {}", e)))?
.map_err(|e| Error::Custom(format!("Server error: {}", e)))
}
}
#[derive(Clone, Copy, Debug)]
pub struct FieldInfo {
pub name: &'static str,
pub fbs_type: &'static str,
pub required: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct SchemaInfo {
pub name: &'static str,
pub fields: &'static [FieldInfo],
}
#[derive(Clone, Copy, Debug)]
pub struct TypeSchema {
pub name: &'static str,
pub namespace: &'static str,
pub fields: &'static [TypeFieldInfo],
}
#[derive(Clone, Copy, Debug)]
pub struct TypeFieldInfo {
pub name: &'static str,
pub fbs_type: &'static str,
pub field_id: u16,
pub required: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct EnumSchema {
pub name: &'static str,
pub namespace: &'static str,
pub variants: &'static [&'static str],
}
inventory::collect!(TypeSchema);
inventory::collect!(EnumSchema);
pub trait ToFlatBuffer: serde::de::DeserializeOwned + serde::Serialize {
const SCHEMA_FIELDS: &'static [FieldInfo];
const SCHEMA_NAME: &'static str;
fn to_flatbuffer(&self) -> Vec<u8>;
fn schema_fields() -> &'static [FieldInfo] {
Self::SCHEMA_FIELDS
}
fn schema_name() -> &'static str {
Self::SCHEMA_NAME
}
}
impl ToFlatBuffer for () {
const SCHEMA_FIELDS: &'static [FieldInfo] = &[];
const SCHEMA_NAME: &'static str = "Empty";
fn to_flatbuffer(&self) -> Vec<u8> {
Vec::new()
}
}
pub trait HasJsonCompanion {
type Json: ToFlatBuffer;
}
pub trait HasPlainStruct {
type Plain: ToFlatBuffer;
type FlatBuffer<'a>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct OpenApiRouteInfo {
pub version: Option<&'static str>, pub tag: Option<&'static str>,
pub summary: Option<&'static str>,
pub operation_id: Option<&'static str>,
pub deprecated: bool,
pub request_schema: Option<SchemaInfo>,
pub response_schema: Option<SchemaInfo>,
}
pub use flatbed_macros::route;
pub use flatbed_macros::static_route;
pub use flatbed_macros::main;
#[doc(hidden)]
pub use inventory;
#[derive(Debug)]
pub struct Request<T, C = ()> {
pub body: T,
pub ctx: C,
pub headers: HeaderMap,
pub method: Method,
pub path: String,
pub path_params: HashMap<String, String>,
pub query_params: HashMap<String, String>,
pub request_id: String,
}
impl<T, C> Request<T, C> {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|v| v.to_str().ok())
}
pub fn param(&self, name: &str) -> Option<&str> {
self.path_params.get(name).map(|s| s.as_str())
}
pub fn query(&self, name: &str) -> Option<&str> {
self.query_params.get(name).map(|s| s.as_str())
}
pub fn request_id(&self) -> &str {
&self.request_id
}
}
impl<T, C> Deref for Request<T, C> {
type Target = T;
fn deref(&self) -> &T {
&self.body
}
}
#[derive(Debug)]
pub struct Response<T> {
pub body: T,
pub status: StatusCode,
pub headers: HeaderMap,
raw: Option<(Vec<u8>, Cow<'static, str>)>,
}
impl<T> Response<T> {
pub fn ok(body: T) -> Self {
Self {
body,
status: StatusCode::OK,
headers: HeaderMap::new(),
raw: None,
}
}
pub fn created(body: T) -> Self {
Self {
body,
status: StatusCode::CREATED,
headers: HeaderMap::new(),
raw: None,
}
}
pub fn accepted(body: T) -> Self {
Self {
body,
status: StatusCode::ACCEPTED,
headers: HeaderMap::new(),
raw: None,
}
}
pub fn no_content(body: T) -> Self {
Self {
body,
status: StatusCode::NO_CONTENT,
headers: HeaderMap::new(),
raw: None,
}
}
pub fn with_status(body: T, status: StatusCode) -> Self {
Self {
body,
status,
headers: HeaderMap::new(),
raw: None,
}
}
pub fn status(mut self, status: StatusCode) -> Self {
self.status = status;
self
}
pub fn header(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
if let (Ok(name), Ok(val)) = (
HeaderName::try_from(key.as_ref()),
HeaderValue::try_from(value.as_ref()),
) {
self.headers.insert(name, val);
}
self
}
#[doc(hidden)]
pub fn take_raw(&mut self) -> Option<(Vec<u8>, Cow<'static, str>)> {
self.raw.take()
}
}
impl Response<()> {
pub fn raw(bytes: impl Into<Vec<u8>>, content_type: impl Into<Cow<'static, str>>) -> Self {
Self {
body: (),
status: StatusCode::OK,
headers: HeaderMap::new(),
raw: Some((bytes.into(), content_type.into())),
}
}
}
#[derive(Debug, Clone)]
pub struct FlatbedWorkerError {
pub code: String,
pub message: String,
}
impl FlatbedWorkerError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl fmt::Display for FlatbedWorkerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for FlatbedWorkerError {}
#[derive(Debug, Clone)]
pub struct FlatbedRouteError<D = ()> {
pub status: StatusCode,
pub code: String,
pub message: String,
pub headers: HeaderMap,
pub details: Option<D>,
}
#[deprecated(since = "0.2.0", note = "Use FlatbedRouteError instead")]
pub type FlatbedError<D = ()> = FlatbedRouteError<D>;
impl FlatbedRouteError<()> {
pub fn bad_request(message: impl Into<String>) -> Self {
Self::with_status(StatusCode::BAD_REQUEST, message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::with_status(StatusCode::UNAUTHORIZED, message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::with_status(StatusCode::FORBIDDEN, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::with_status(StatusCode::NOT_FOUND, message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::with_status(StatusCode::CONFLICT, message)
}
pub fn unprocessable(message: impl Into<String>) -> Self {
Self::with_status(StatusCode::UNPROCESSABLE_ENTITY, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::with_status(StatusCode::INTERNAL_SERVER_ERROR, message)
}
pub fn with_status(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
code: String::new(),
message: message.into(),
headers: HeaderMap::new(),
details: None,
}
}
}
impl<D> FlatbedRouteError<D> {
pub fn code(mut self, code: impl Into<String>) -> Self {
self.code = code.into();
self
}
pub fn header(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
if let (Ok(name), Ok(val)) = (
HeaderName::try_from(key.as_ref()),
HeaderValue::try_from(value.as_ref()),
) {
self.headers.insert(name, val);
}
self
}
pub fn with_details<D2>(self, details: D2) -> FlatbedRouteError<D2> {
FlatbedRouteError {
status: self.status,
code: self.code,
message: self.message,
headers: self.headers,
details: Some(details),
}
}
pub fn status_code(&self) -> StatusCode {
self.status
}
}
impl<D> fmt::Display for FlatbedRouteError<D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.code.is_empty() {
write!(f, "{}", self.message)
} else {
write!(f, "[{}] {}", self.code, self.message)
}
}
}
impl<D: fmt::Debug> std::error::Error for FlatbedRouteError<D> {}
#[derive(Debug, Clone)]
pub struct RequestParts {
pub headers: HeaderMap,
pub method: Method,
pub path: String,
pub path_params: HashMap<String, String>,
pub query_params: HashMap<String, String>,
pub request_id: String,
}
impl RequestParts {
pub fn new(method: Method, path: String) -> Self {
Self {
headers: HeaderMap::new(),
method,
path,
path_params: HashMap::new(),
query_params: HashMap::new(),
request_id: uuid::Uuid::new_v4().to_string(),
}
}
pub fn with_request_id_from_header(mut self) -> Self {
if let Some(id) = self
.headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
{
self.request_id = id.to_string();
}
self
}
}
#[derive(Debug)]
pub struct ResponseParts {
pub body: Vec<u8>,
pub status: StatusCode,
pub headers: HeaderMap,
pub content_type: Cow<'static, str>,
}
impl ResponseParts {
pub fn ok(body: Vec<u8>, content_type: impl Into<Cow<'static, str>>) -> Self {
Self {
body,
status: StatusCode::OK,
headers: HeaderMap::new(),
content_type: content_type.into(),
}
}
pub fn with_status(
body: Vec<u8>,
status: StatusCode,
content_type: impl Into<Cow<'static, str>>,
) -> Self {
Self {
body,
status,
headers: HeaderMap::new(),
content_type: content_type.into(),
}
}
pub fn with_request_id(mut self, request_id: &str) -> Self {
if let Ok(val) = HeaderValue::try_from(request_id) {
self.headers.insert("x-request-id", val);
}
self
}
}
#[derive(Debug, Clone)]
pub enum Error {
DeserializationError(String),
SerializationError(String),
HandlerError(String),
Custom(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::DeserializationError(msg) => write!(f, "Deserialization error: {}", msg),
Error::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
Error::HandlerError(msg) => write!(f, "Handler error: {}", msg),
Error::Custom(msg) => write!(f, "{}", msg),
}
}
}
impl std::error::Error for Error {}
impl From<flatbuffers::InvalidFlatbuffer> for Error {
fn from(err: flatbuffers::InvalidFlatbuffer) -> Self {
Error::DeserializationError(err.to_string())
}
}
impl From<String> for Error {
fn from(msg: String) -> Self {
Error::Custom(msg)
}
}
impl From<&str> for Error {
fn from(msg: &str) -> Self {
Error::Custom(msg.to_string())
}
}
#[deprecated(since = "0.2.0", note = "Use the new Response<T> struct instead")]
pub struct LegacyResponse<T> {
builder: FlatBufferBuilder<'static>,
status_code: u16,
headers: HashMap<String, String>,
_phantom: std::marker::PhantomData<T>,
}
#[allow(deprecated)]
impl<T> LegacyResponse<T> {
pub fn new(builder: FlatBufferBuilder<'static>) -> Self {
Self {
builder,
status_code: 200,
headers: HashMap::new(),
_phantom: std::marker::PhantomData,
}
}
pub fn with_body<F>(f: F) -> Self
where
F: FnOnce(&mut FlatBufferBuilder<'static>),
{
let mut builder = FlatBufferBuilder::new();
f(&mut builder);
Self::new(builder)
}
pub fn with_status(mut self, status: u16) -> Self {
self.status_code = status;
self
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into(), value.into());
self
}
pub fn status_code(&self) -> u16 {
self.status_code
}
pub fn headers(&self) -> &HashMap<String, String> {
&self.headers
}
pub fn into_bytes(self) -> Vec<u8> {
self.builder.finished_data().to_vec()
}
pub fn as_bytes(&self) -> &[u8] {
self.builder.finished_data()
}
pub fn into_parts(self) -> (Vec<u8>, u16, HashMap<String, String>) {
let bytes = self.builder.finished_data().to_vec();
(bytes, self.status_code, self.headers)
}
}
pub fn new_builder() -> FlatBufferBuilder<'static> {
FlatBufferBuilder::new()
}
pub fn new_builder_with_capacity(capacity: usize) -> FlatBufferBuilder<'static> {
FlatBufferBuilder::with_capacity(capacity)
}
pub fn verify_buffer<'a, T>(buf: &'a [u8]) -> bool
where
T: flatbuffers::Follow<'a> + flatbuffers::Verifiable + 'a,
{
flatbuffers::root::<T>(buf).is_ok()
}
pub fn get_root<'a, T>(
buf: &'a [u8],
) -> Result<<T as flatbuffers::Follow<'a>>::Inner, flatbuffers::InvalidFlatbuffer>
where
T: flatbuffers::Follow<'a> + flatbuffers::Verifiable + 'a,
{
flatbuffers::root::<T>(buf)
}
pub type HandlerFn = fn(Vec<u8>, &str) -> Result<(Vec<u8>, &'static str), Error>;
pub type AsyncHandlerFn = fn(
RequestParts,
Vec<u8>,
&str,
std::sync::Arc<dyn std::any::Any + Send + Sync>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ResponseParts, Error>> + Send>,
>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
}
impl HttpMethod {
pub fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
HttpMethod::Patch => "PATCH",
HttpMethod::Head => "HEAD",
HttpMethod::Options => "OPTIONS",
}
}
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy)]
pub struct RouteInfo {
pub path: &'static str,
pub method: HttpMethod,
pub request_type: &'static str,
pub response_type: &'static str,
pub handler: HandlerFn,
pub async_handler: AsyncHandlerFn,
pub openapi: OpenApiRouteInfo,
}
impl std::fmt::Debug for RouteInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RouteInfo")
.field("path", &self.path)
.field("method", &self.method)
.field("request_type", &self.request_type)
.field("response_type", &self.response_type)
.field("handler", &"<function>")
.field("async_handler", &"<async_function>")
.field("openapi", &self.openapi)
.finish()
}
}
inventory::collect!(RouteInfo);
#[derive(Clone, Copy, Debug)]
pub struct StaticRouteInfo {
pub mount: &'static str,
pub dir: &'static str,
pub fallback: Option<&'static str>,
}
inventory::collect!(StaticRouteInfo);
pub fn get_static_routes() -> Vec<StaticRouteInfo> {
inventory::iter::<StaticRouteInfo>
.into_iter()
.copied()
.collect()
}
pub type WorkerFn = fn(
std::sync::Arc<dyn std::any::Any + Send + Sync>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), FlatbedWorkerError>> + Send>,
>;
pub type DrainFn = fn(
std::sync::Arc<dyn std::any::Any + Send + Sync>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), FlatbedWorkerError>> + Send>,
>;
#[derive(Clone, Copy)]
pub struct WorkerInfo {
pub name: &'static str,
pub description: Option<&'static str>,
pub worker: WorkerFn,
}
impl std::fmt::Debug for WorkerInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkerInfo")
.field("name", &self.name)
.field("description", &self.description)
.field("worker", &"<worker_fn>")
.finish()
}
}
#[derive(Clone, Copy)]
pub struct WorkerDrainInfo {
pub name: &'static str,
pub drain: DrainFn,
}
inventory::collect!(WorkerDrainInfo);
pub fn get_worker_drains() -> Vec<&'static WorkerDrainInfo> {
inventory::iter::<WorkerDrainInfo>.into_iter().collect()
}
inventory::collect!(WorkerInfo);
pub fn get_workers() -> Vec<&'static WorkerInfo> {
inventory::iter::<WorkerInfo>.into_iter().collect()
}
pub trait Worker: Send + Sync + 'static {
type Context: Send + Sync + 'static;
const NAME: &'static str;
const DESCRIPTION: Option<&'static str> = None;
fn run(&self, ctx: std::sync::Arc<Self::Context>) -> BoxFuture<Result<(), FlatbedWorkerError>>;
}
pub fn run_basic_worker<W, C>(
ctx: std::sync::Arc<dyn std::any::Any + Send + Sync>,
) -> BoxFuture<Result<(), FlatbedWorkerError>>
where
W: Worker<Context = C> + Default,
C: Send + Sync + 'static,
{
Box::pin(async move {
let ctx: std::sync::Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("worker '{}' context type mismatch", W::NAME));
let worker = W::default();
worker.run(ctx).await
})
}
#[cfg(all(feature = "nats", feature = "k8s"))]
#[macro_export]
macro_rules! register_kube_reconciler {
($reconciler:ty, $context:ty) => {
$crate::inventory::submit! {
$crate::WorkerInfo {
name: <$reconciler as $crate::k8s::KubeReconciler>::NAME,
description: <$reconciler as $crate::k8s::KubeReconciler>::DESCRIPTION,
worker: {
fn __worker(
ctx: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = Result<(), $crate::FlatbedWorkerError>> + Send>,
> {
Box::pin($crate::k8s::run_kube_reconciler::<$reconciler, $context>(ctx))
}
__worker
},
}
}
};
}
#[cfg(feature = "k8s")]
#[macro_export]
macro_rules! register_kube_native_reconciler {
($reconciler:ty, $context:ty) => {
$crate::inventory::submit! {
$crate::WorkerInfo {
name: <$reconciler as $crate::k8s::KubeNativeReconciler>::NAME,
description: <$reconciler as $crate::k8s::KubeNativeReconciler>::DESCRIPTION,
worker: {
fn __worker(
ctx: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = Result<(), $crate::FlatbedWorkerError>> + Send>,
> {
Box::pin($crate::k8s::run_kube_native_reconciler::<$reconciler, $context>(ctx))
}
__worker
},
}
}
};
}
#[cfg(feature = "k8s")]
#[macro_export]
macro_rules! register_kube_watcher {
($reconciler:ty, $context:ty) => {
$crate::inventory::submit! {
$crate::WorkerInfo {
name: <$reconciler as $crate::k8s::KubeWatcher>::NAME,
description: <$reconciler as $crate::k8s::KubeWatcher>::DESCRIPTION,
worker: {
fn __worker(
ctx: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = Result<(), $crate::FlatbedWorkerError>> + Send>,
> {
Box::pin($crate::k8s::run_kube_watcher::<$reconciler, $context>(ctx))
}
__worker
},
}
}
};
}
#[cfg(feature = "nats")]
#[macro_export]
macro_rules! register_stream_worker {
($stream_worker:ty, $context:ty) => {
$crate::inventory::submit! {
$crate::WorkerInfo {
name: <$stream_worker as $crate::nats::StreamWorker>::NAME,
description: <$stream_worker as $crate::nats::StreamWorker>::DESCRIPTION,
worker: {
fn __worker(
ctx: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = Result<(), $crate::FlatbedWorkerError>> + Send>,
> {
Box::pin($crate::nats::run_stream_worker::<$stream_worker, $context>(ctx))
}
__worker
},
}
}
};
}
#[cfg(feature = "nats")]
#[macro_export]
macro_rules! register_kv_worker {
($kv_worker:ty, $context:ty) => {
$crate::inventory::submit! {
$crate::WorkerInfo {
name: <$kv_worker as $crate::kv::KvWorker>::NAME,
description: <$kv_worker as $crate::kv::KvWorker>::DESCRIPTION,
worker: {
fn __worker(
ctx: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = Result<(), $crate::FlatbedWorkerError>> + Send>,
> {
Box::pin($crate::kv::run_kv_worker::<$kv_worker, $context>(ctx))
}
__worker
},
}
}
};
}
#[macro_export]
macro_rules! register_worker {
($worker_type:ty, $context:ty) => {
$crate::inventory::submit! {
$crate::WorkerInfo {
name: <$worker_type as $crate::Worker>::NAME,
description: <$worker_type as $crate::Worker>::DESCRIPTION,
worker: {
fn __worker(
ctx: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>,
) -> ::std::pin::Pin<
Box<dyn ::std::future::Future<Output = Result<(), $crate::FlatbedWorkerError>> + Send>,
> {
$crate::run_basic_worker::<$worker_type, $context>(ctx)
}
__worker
},
}
}
};
}
pub type RouteMap = HashMap<(String, HttpMethod), RouteInfo>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteConflict {
pub path: String,
pub method: HttpMethod,
pub first_request: String,
pub first_response: String,
pub second_request: String,
pub second_response: String,
}
impl std::fmt::Display for RouteConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Route conflict at {} '{}': ({}, {}) vs ({}, {})",
self.method,
self.path,
self.first_request,
self.first_response,
self.second_request,
self.second_response
)
}
}
impl std::error::Error for RouteConflict {}
pub fn get_routes() -> &'static RouteMap {
static ROUTES: LazyLock<RouteMap> = LazyLock::new(|| {
let mut map = HashMap::new();
for route in inventory::iter::<RouteInfo> {
map.insert((route.path.to_string(), route.method), *route);
}
map
});
&ROUTES
}
pub fn get_type_schema(name: &str) -> Option<&'static TypeSchema> {
static MAP: LazyLock<HashMap<&'static str, &'static TypeSchema>> = LazyLock::new(|| {
let mut map = HashMap::new();
for ty in inventory::iter::<TypeSchema> {
map.insert(ty.name, ty);
}
map
});
MAP.get(name).copied()
}
pub fn get_enum_schema(name: &str) -> Option<&'static EnumSchema> {
static MAP: LazyLock<HashMap<&'static str, &'static EnumSchema>> = LazyLock::new(|| {
let mut map = HashMap::new();
for en in inventory::iter::<EnumSchema> {
map.insert(en.name, en);
}
map
});
MAP.get(name).copied()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeConflict {
pub name: String,
pub first_namespace: String,
pub second_namespace: String,
pub kind: &'static str,
}
impl std::fmt::Display for TypeConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} name conflict: '{}' is registered in both namespace '{}' and '{}'",
self.kind, self.name, self.first_namespace, self.second_namespace
)
}
}
impl std::error::Error for TypeConflict {}
pub fn validate_type_registry() -> Result<(), TypeConflict> {
let types = inventory::iter::<TypeSchema>
.into_iter()
.map(|t| (t.name, t.namespace));
find_name_collision("type", types)?;
let enums = inventory::iter::<EnumSchema>
.into_iter()
.map(|e| (e.name, e.namespace));
find_name_collision("enum", enums)?;
Ok(())
}
fn find_name_collision<'a>(
kind: &'static str,
entries: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Result<(), TypeConflict> {
let mut seen: HashMap<&str, &str> = HashMap::new();
for (name, namespace) in entries {
if let Some(prev) = seen.insert(name, namespace) {
if prev != namespace {
return Err(TypeConflict {
name: name.to_string(),
first_namespace: prev.to_string(),
second_namespace: namespace.to_string(),
kind,
});
}
}
}
Ok(())
}
#[allow(clippy::result_large_err)]
pub fn validate_routes() -> Result<(), RouteConflict> {
let mut seen: HashMap<(String, HttpMethod), RouteInfo> = HashMap::new();
for route in inventory::iter::<RouteInfo> {
let key = (route.path.to_string(), route.method);
if let Some(first_route) = seen.get(&key) {
if first_route.request_type != route.request_type
|| first_route.response_type != route.response_type
{
return Err(RouteConflict {
path: route.path.to_string(),
method: route.method,
first_request: first_route.request_type.to_string(),
first_response: first_route.response_type.to_string(),
second_request: route.request_type.to_string(),
second_response: route.response_type.to_string(),
});
}
}
seen.insert(key, *route);
}
Ok(())
}
#[cfg(feature = "openapi")]
mod openapi_generation {
use super::{
get_routes, EnumSchema, FlatbedConfig, HttpMethod, RouteInfo, SchemaInfo, TypeFieldInfo,
TypeSchema,
};
use std::collections::BTreeSet;
use utoipa::openapi::{
content::{Content, ContentBuilder},
extensions::{Extensions, ExtensionsBuilder},
path::{OperationBuilder, PathItemBuilder},
request_body::RequestBodyBuilder,
response::ResponseBuilder,
schema::{
AllOfBuilder, ArrayBuilder, KnownFormat, ObjectBuilder, Schema, SchemaFormat,
SchemaType, Type,
},
server::ServerBuilder,
ComponentsBuilder, Deprecated, InfoBuilder, OpenApi, OpenApiBuilder, PathsBuilder, Ref,
RefOr, Required,
};
pub fn get_route_versions() -> BTreeSet<String> {
let mut versions = BTreeSet::new();
for route in get_routes().values() {
let version = route.openapi.version.unwrap_or("v1");
versions.insert(version.to_string());
}
if versions.is_empty() {
versions.insert("v1".to_string());
}
versions
}
pub fn get_latest_version() -> String {
get_route_versions()
.into_iter()
.next_back()
.unwrap_or_else(|| "v1".to_string())
}
fn generate_fbs_schema_text(schema: &SchemaInfo) -> String {
let mut fbs = format!("table {} {{\n", schema.name.trim_end_matches("Json"));
for field in schema.fields {
fbs.push_str(&format!(" {}: {};\n", field.name, field.fbs_type));
}
fbs.push('}');
fbs
}
fn registered_names() -> (BTreeSet<&'static str>, BTreeSet<&'static str>) {
let types = crate::inventory::iter::<TypeSchema>
.into_iter()
.map(|t| t.name)
.collect();
let enums = crate::inventory::iter::<EnumSchema>
.into_iter()
.map(|e| e.name)
.collect();
(types, enums)
}
fn scalar_schema(fbs_type: &str, extensions: Option<Extensions>) -> Schema {
let (schema_type, format) = match fbs_type {
"bool" => (Type::Boolean, None),
"string" => (Type::String, None),
"int8" | "byte" | "uint8" | "ubyte" | "int16" | "short" | "uint16" | "ushort"
| "int32" | "int" | "uint32" | "uint" => (Type::Integer, Some(KnownFormat::Int32)),
"int64" | "long" | "uint64" | "ulong" => (Type::Integer, Some(KnownFormat::Int64)),
"float32" | "float" => (Type::Number, Some(KnownFormat::Float)),
"float64" | "double" => (Type::Number, Some(KnownFormat::Double)),
_ => (Type::String, None),
};
let mut builder = ObjectBuilder::new().schema_type(SchemaType::new(schema_type));
if let Some(format) = format {
builder = builder.format(Some(SchemaFormat::KnownFormat(format)));
}
Schema::Object(builder.extensions(extensions).build())
}
fn schema_for_fbs_type(
fbs_type: &str,
type_names: &BTreeSet<&str>,
enum_names: &BTreeSet<&str>,
) -> RefOr<Schema> {
if let Some(inner) = fbs_type.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let items = schema_for_fbs_type(inner, type_names, enum_names);
return RefOr::T(Schema::Array(ArrayBuilder::new().items(items).build()));
}
if type_names.contains(fbs_type) || enum_names.contains(fbs_type) {
RefOr::Ref(Ref::from_schema_name(fbs_type))
} else {
RefOr::T(scalar_schema(fbs_type, None))
}
}
fn property_schema(
field: &TypeFieldInfo,
type_names: &BTreeSet<&str>,
enum_names: &BTreeSet<&str>,
) -> RefOr<Schema> {
let extensions = || {
ExtensionsBuilder::new()
.add("x-fbs-type", field.fbs_type)
.add("x-fbs-id", field.field_id)
.build()
};
if let Some(inner) = field
.fbs_type
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
{
let items = schema_for_fbs_type(inner, type_names, enum_names);
return RefOr::T(Schema::Array(
ArrayBuilder::new()
.items(items)
.extensions(Some(extensions()))
.build(),
));
}
if type_names.contains(field.fbs_type) || enum_names.contains(field.fbs_type) {
return RefOr::T(Schema::AllOf(
AllOfBuilder::new()
.item(Ref::from_schema_name(field.fbs_type))
.extensions(Some(extensions()))
.build(),
));
}
RefOr::T(scalar_schema(field.fbs_type, Some(extensions())))
}
fn add_type_components(
components_builder: &mut ComponentsBuilder,
type_names: &BTreeSet<&str>,
enum_names: &BTreeSet<&str>,
) {
for ty in crate::inventory::iter::<TypeSchema> {
let mut obj = ObjectBuilder::new().schema_type(SchemaType::new(Type::Object));
for field in ty.fields {
obj = obj.property(field.name, property_schema(field, type_names, enum_names));
if field.required {
obj = obj.required(field.name);
}
}
*components_builder =
std::mem::take(components_builder).schema(ty.name, Schema::Object(obj.build()));
}
for en in crate::inventory::iter::<EnumSchema> {
let obj = ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.enum_values(Some(en.variants.iter().copied()))
.build();
*components_builder =
std::mem::take(components_builder).schema(en.name, Schema::Object(obj));
}
}
fn to_utoipa_method(method: &HttpMethod) -> utoipa::openapi::path::HttpMethod {
match method {
HttpMethod::Get => utoipa::openapi::path::HttpMethod::Get,
HttpMethod::Post => utoipa::openapi::path::HttpMethod::Post,
HttpMethod::Put => utoipa::openapi::path::HttpMethod::Put,
HttpMethod::Delete => utoipa::openapi::path::HttpMethod::Delete,
HttpMethod::Patch => utoipa::openapi::path::HttpMethod::Patch,
HttpMethod::Head => utoipa::openapi::path::HttpMethod::Head,
HttpMethod::Options => utoipa::openapi::path::HttpMethod::Options,
}
}
fn flatbuffers_content(schema: Option<&SchemaInfo>) -> Content {
ContentBuilder::new()
.schema(Some(Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.format(Some(SchemaFormat::Custom("binary".to_string())))
.description(Some(format!(
"FlatBuffers binary data.\n\nSchema:\n```fbs\n{}\n```",
schema.map(generate_fbs_schema_text).unwrap_or_default()
)))
.build(),
)))
.build()
}
fn json_ref_content(
schema: Option<&SchemaInfo>,
type_names: &BTreeSet<&str>,
enum_names: &BTreeSet<&str>,
) -> Content {
let mut content = ContentBuilder::new();
if let Some(schema) = schema {
let body: RefOr<Schema> = if type_names.contains(schema.name) {
RefOr::Ref(Ref::from_schema_name(schema.name))
} else {
RefOr::T(inline_schema(schema, type_names, enum_names))
};
content = content.schema(Some(body));
}
content.build()
}
fn inline_schema(
schema: &SchemaInfo,
type_names: &BTreeSet<&str>,
enum_names: &BTreeSet<&str>,
) -> Schema {
let mut obj = ObjectBuilder::new().schema_type(SchemaType::new(Type::Object));
for field in schema.fields {
obj = obj.property(
field.name,
schema_for_fbs_type(field.fbs_type, type_names, enum_names),
);
if field.required {
obj = obj.required(field.name);
}
}
Schema::Object(obj.build())
}
fn build_operation(
route: &RouteInfo,
type_names: &BTreeSet<&str>,
enum_names: &BTreeSet<&str>,
) -> utoipa::openapi::path::Operation {
let request_body = RequestBodyBuilder::new()
.content(
"application/json",
json_ref_content(
route.openapi.request_schema.as_ref(),
type_names,
enum_names,
),
)
.content(
"application/x-flatbuffers",
flatbuffers_content(route.openapi.request_schema.as_ref()),
)
.required(Some(Required::True))
.build();
let response = ResponseBuilder::new()
.description("Successful response")
.content(
"application/json",
json_ref_content(
route.openapi.response_schema.as_ref(),
type_names,
enum_names,
),
)
.content(
"application/x-flatbuffers",
flatbuffers_content(route.openapi.response_schema.as_ref()),
)
.build();
let mut operation_builder = OperationBuilder::new()
.request_body(Some(request_body))
.response("200", response);
if let Some(tag) = route.openapi.tag {
operation_builder = operation_builder.tag(tag);
}
if let Some(summary) = route.openapi.summary {
operation_builder = operation_builder.summary(Some(summary.to_string()));
}
if let Some(operation_id) = route.openapi.operation_id {
operation_builder = operation_builder.operation_id(Some(operation_id.to_string()));
}
if route.openapi.deprecated {
operation_builder = operation_builder.deprecated(Some(Deprecated::True));
}
operation_builder.build()
}
pub fn generate_openapi_spec(config: &FlatbedConfig, version: &str) -> OpenApi {
let (type_names, enum_names) = registered_names();
let mut paths_builder = PathsBuilder::new();
let mut components_builder = ComponentsBuilder::new();
add_type_components(&mut components_builder, &type_names, &enum_names);
let routes: Vec<&RouteInfo> = get_routes()
.values()
.filter(|r| r.openapi.version.unwrap_or("v1") == version)
.collect();
let mut path_groups: std::collections::BTreeMap<&str, Vec<&RouteInfo>> =
std::collections::BTreeMap::new();
for route in &routes {
path_groups.entry(route.path).or_default().push(route);
}
for (path, path_routes) in &path_groups {
let mut path_item_builder = PathItemBuilder::new();
for route in path_routes {
let operation = build_operation(route, &type_names, &enum_names);
let http_method = to_utoipa_method(&route.method);
path_item_builder = path_item_builder.operation(http_method, operation);
}
paths_builder = paths_builder.path(*path, path_item_builder.build());
}
let mut info_builder = InfoBuilder::new().title(config.title).version(version);
if let Some(desc) = config.description {
info_builder = info_builder.description(Some(desc.to_string()));
}
let mut builder = OpenApiBuilder::new()
.info(info_builder.build())
.paths(paths_builder.build())
.components(Some(components_builder.build()));
if let Some(ref url) = config.external_url {
builder = builder.servers(Some(vec![ServerBuilder::new().url(url.clone()).build()]));
}
builder.build()
}
pub fn get_openapi_json_for_version(config: &FlatbedConfig, version: &str) -> String {
let spec = generate_openapi_spec(config, version);
serde_json::to_string_pretty(&spec).unwrap_or_else(|_| "{}".to_string())
}
}
#[cfg(feature = "openapi")]
pub use openapi_generation::{
generate_openapi_spec, get_latest_version, get_openapi_json_for_version, get_route_versions,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_builder() {
let _builder = new_builder();
}
#[test]
fn test_new_builder_with_capacity() {
let _builder = new_builder_with_capacity(1024);
}
#[test]
fn test_request_without_context() {
let req: Request<String> = Request {
body: "test body".to_string(),
ctx: (),
headers: HeaderMap::new(),
method: Method::POST,
path: "/test".to_string(),
path_params: HashMap::new(),
query_params: HashMap::new(),
request_id: "req-123".to_string(),
};
assert_eq!(*req, "test body");
assert_eq!(req.body, "test body");
assert_eq!(req.path, "/test");
assert_eq!(req.request_id(), "req-123");
}
#[test]
fn test_request_with_context() {
#[derive(Debug)]
struct AppCtx {
app_name: String,
}
let ctx = AppCtx {
app_name: "test-app".to_string(),
};
let req: Request<String, AppCtx> = Request {
body: "test body".to_string(),
ctx,
headers: HeaderMap::new(),
method: Method::POST,
path: "/test".to_string(),
path_params: HashMap::new(),
query_params: HashMap::new(),
request_id: "req-456".to_string(),
};
assert_eq!(req.ctx.app_name, "test-app");
assert_eq!(*req, "test body");
}
#[test]
fn test_request_params_and_queries() {
let mut path_params = HashMap::new();
path_params.insert("id".to_string(), "123".to_string());
let mut query_params = HashMap::new();
query_params.insert("filter".to_string(), "active".to_string());
let req: Request<String> = Request {
body: "test".to_string(),
ctx: (),
headers: HeaderMap::new(),
method: Method::POST,
path: "/users/123".to_string(),
path_params,
query_params,
request_id: "req-789".to_string(),
};
assert_eq!(req.param("id"), Some("123"));
assert_eq!(req.param("nonexistent"), None);
assert_eq!(req.query("filter"), Some("active"));
assert_eq!(req.query("nonexistent"), None);
}
#[test]
fn test_request_headers() {
let mut headers = HeaderMap::new();
headers.insert("x-custom", HeaderValue::from_static("custom-value"));
let req: Request<String> = Request {
body: "test".to_string(),
ctx: (),
headers,
method: Method::POST,
path: "/test".to_string(),
path_params: HashMap::new(),
query_params: HashMap::new(),
request_id: "req-abc".to_string(),
};
assert_eq!(req.header("x-custom"), Some("custom-value"));
assert_eq!(req.header("nonexistent"), None);
}
#[test]
fn test_response_ok() {
let resp = Response::ok("success".to_string());
assert_eq!(resp.body, "success");
assert_eq!(resp.status, StatusCode::OK);
assert!(resp.headers.is_empty());
}
#[test]
fn test_response_created() {
let resp = Response::created("new resource".to_string());
assert_eq!(resp.status, StatusCode::CREATED);
}
#[test]
fn test_response_with_status() {
let resp = Response::with_status("accepted".to_string(), StatusCode::ACCEPTED);
assert_eq!(resp.status, StatusCode::ACCEPTED);
}
#[test]
fn test_response_builder_pattern() {
let resp = Response::ok("test".to_string())
.status(StatusCode::CREATED)
.header("x-custom", "value");
assert_eq!(resp.status, StatusCode::CREATED);
assert_eq!(
resp.headers.get("x-custom").and_then(|v| v.to_str().ok()),
Some("value")
);
}
#[test]
fn test_flatbed_worker_error_new() {
let err = FlatbedWorkerError::new("TASK_ERROR", "task failed");
assert_eq!(err.code, "TASK_ERROR");
assert_eq!(err.message, "task failed");
}
#[test]
fn test_flatbed_worker_error_display() {
let err = FlatbedWorkerError::new("CACHE_ERROR", "cache warmup failed");
assert_eq!(format!("{}", err), "[CACHE_ERROR] cache warmup failed");
}
#[test]
fn test_flatbed_route_error_bad_request() {
let err = FlatbedRouteError::bad_request("invalid input");
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert_eq!(err.message, "invalid input");
assert!(err.code.is_empty());
assert!(err.details.is_none());
}
#[test]
fn test_flatbed_route_error_not_found() {
let err = FlatbedRouteError::not_found("resource not found");
assert_eq!(err.status, StatusCode::NOT_FOUND);
}
#[test]
fn test_flatbed_route_error_internal() {
let err = FlatbedRouteError::internal("something went wrong");
assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_flatbed_route_error_with_code() {
let err = FlatbedRouteError::bad_request("validation failed").code("VALIDATION_ERROR");
assert_eq!(err.code, "VALIDATION_ERROR");
}
#[test]
fn test_flatbed_route_error_with_details() {
#[derive(Debug, Clone)]
struct ErrorDetails {
field: String,
}
let details = ErrorDetails {
field: "email".to_string(),
};
let err = FlatbedRouteError::bad_request("validation failed").with_details(details);
assert!(err.details.is_some());
assert_eq!(err.details.as_ref().unwrap().field, "email");
}
#[test]
fn test_flatbed_route_error_display() {
let err1 = FlatbedRouteError::bad_request("simple error");
assert_eq!(format!("{}", err1), "simple error");
let err2 = FlatbedRouteError::bad_request("coded error").code("ERR_001");
assert_eq!(format!("{}", err2), "[ERR_001] coded error");
}
#[test]
fn test_flatbed_route_error_with_header() {
let err =
FlatbedRouteError::unauthorized("auth required").header("www-authenticate", "Bearer");
assert!(err.headers.contains_key("www-authenticate"));
}
#[test]
#[allow(deprecated)]
fn test_flatbed_error_deprecated_alias() {
let err: FlatbedError = FlatbedError::bad_request("test");
assert_eq!(err.status, StatusCode::BAD_REQUEST);
}
#[test]
fn test_request_parts_new() {
let parts = RequestParts::new(Method::POST, "/api/test".to_string());
assert_eq!(parts.method, Method::POST);
assert_eq!(parts.path, "/api/test");
assert!(!parts.request_id.is_empty()); }
#[test]
fn test_request_parts_request_id_from_header() {
let mut parts = RequestParts::new(Method::POST, "/test".to_string());
parts
.headers
.insert("x-request-id", HeaderValue::from_static("custom-id-123"));
let parts = parts.with_request_id_from_header();
assert_eq!(parts.request_id, "custom-id-123");
}
#[test]
fn test_response_parts_ok() {
let parts = ResponseParts::ok(vec![1, 2, 3], "application/json");
assert_eq!(parts.body, vec![1, 2, 3]);
assert_eq!(parts.status, StatusCode::OK);
assert_eq!(parts.content_type, "application/json");
}
#[test]
fn test_response_parts_owned_content_type() {
let ext = "js";
let parts = ResponseParts::ok(vec![], format!("application/{ext}"));
assert_eq!(parts.content_type, "application/js");
}
#[test]
fn test_response_raw() {
let mut resp = Response::raw(b"<h1>hi</h1>".to_vec(), "text/html; charset=utf-8");
assert_eq!(resp.status, StatusCode::OK);
let (bytes, content_type) = resp.take_raw().expect("raw payload set");
assert_eq!(bytes, b"<h1>hi</h1>");
assert_eq!(content_type, "text/html; charset=utf-8");
}
#[test]
fn test_response_ok_has_no_raw() {
let mut resp = Response::ok(());
assert!(resp.take_raw().is_none());
}
#[test]
fn test_response_raw_status_builder_preserves_raw() {
let mut resp = Response::raw(b"data".to_vec(), "text/plain").status(StatusCode::CREATED);
assert_eq!(resp.status, StatusCode::CREATED);
assert!(resp.take_raw().is_some(), "status() must preserve raw");
}
#[test]
fn test_response_parts_with_request_id() {
let parts = ResponseParts::ok(vec![], "application/json").with_request_id("req-123");
assert_eq!(
parts
.headers
.get("x-request-id")
.and_then(|v| v.to_str().ok()),
Some("req-123")
);
}
#[test]
fn find_name_collision_flags_same_name_across_namespaces() {
let entries = [("Foo", "v_1"), ("Bar", "v_1"), ("Foo", "v_2")];
let err = find_name_collision("type", entries)
.expect_err("a name shared across namespaces must be a conflict");
assert_eq!(err.name, "Foo");
assert_eq!(err.kind, "type");
assert_eq!(err.first_namespace, "v_1");
assert_eq!(err.second_namespace, "v_2");
}
#[test]
fn find_name_collision_accepts_unique_names() {
let entries = [("Foo", "v_1"), ("Bar", "v_1"), ("Baz", "v_2")];
assert!(find_name_collision("type", entries).is_ok());
}
#[test]
fn find_name_collision_ignores_identical_reregistration() {
let entries = [("Foo", "v_1"), ("Foo", "v_1")];
assert!(find_name_collision("enum", entries).is_ok());
}
#[test]
fn test_flatbed_config_new() {
let config = FlatbedConfig::new("Test API");
assert_eq!(config.title, "Test API");
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 8080);
assert!(config.description.is_none());
assert!(config.external_url.is_none());
}
#[test]
fn test_flatbed_config_default() {
let config = FlatbedConfig::default();
assert_eq!(config.title, "API");
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 8080);
}
#[test]
fn test_flatbed_config_builder_pattern() {
let config = FlatbedConfig::new("My API")
.host("127.0.0.1")
.port(3000)
.description("A test API")
.external_url("https://api.example.com");
assert_eq!(config.title, "My API");
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 3000);
assert_eq!(config.description, Some("A test API"));
assert_eq!(
config.external_url,
Some("https://api.example.com".to_string())
);
}
#[test]
fn test_flatbed_config_bind_address() {
let config = FlatbedConfig::new("API").host("0.0.0.0").port(8080);
assert_eq!(config.bind_address(), "0.0.0.0:8080");
let config2 = FlatbedConfig::new("API").host("127.0.0.1").port(3000);
assert_eq!(config2.bind_address(), "127.0.0.1:3000");
}
#[test]
fn test_flatbed_config_debug() {
let config = FlatbedConfig::new("Test API").host("localhost").port(9000);
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("Test API"));
assert!(debug_str.contains("localhost"));
assert!(debug_str.contains("9000"));
}
struct TestWorkerCtx {
pub label: String,
}
struct RecordingWorker;
impl Default for RecordingWorker {
fn default() -> Self {
RecordingWorker
}
}
impl Worker for RecordingWorker {
type Context = TestWorkerCtx;
const NAME: &'static str = "recording-worker";
const DESCRIPTION: Option<&'static str> = Some("Records calls for testing");
fn run(
&self,
ctx: std::sync::Arc<Self::Context>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), FlatbedWorkerError>> + Send>,
> {
Box::pin(async move {
if ctx.label == "fail" {
return Err(FlatbedWorkerError::new("TEST", "intentional failure"));
}
Ok(())
})
}
}
#[tokio::test]
async fn run_basic_worker_downcasts_and_runs() {
let ctx = std::sync::Arc::new(TestWorkerCtx {
label: "success".to_string(),
});
let dyn_ctx: std::sync::Arc<dyn std::any::Any + Send + Sync> = ctx;
let result = run_basic_worker::<RecordingWorker, TestWorkerCtx>(dyn_ctx).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn run_basic_worker_propagates_error() {
let ctx = std::sync::Arc::new(TestWorkerCtx {
label: "fail".to_string(),
});
let dyn_ctx: std::sync::Arc<dyn std::any::Any + Send + Sync> = ctx;
let result = run_basic_worker::<RecordingWorker, TestWorkerCtx>(dyn_ctx).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, "TEST");
}
#[tokio::test]
async fn run_basic_worker_panics_on_context_type_mismatch() {
let wrong_ctx: std::sync::Arc<dyn std::any::Any + Send + Sync> =
std::sync::Arc::new("wrong type".to_string());
let result = std::panic::AssertUnwindSafe(
run_basic_worker::<RecordingWorker, TestWorkerCtx>(wrong_ctx),
);
let panic_result = futures::FutureExt::catch_unwind(result).await;
assert!(
panic_result.is_err(),
"run_basic_worker should panic on context type mismatch"
);
}
#[test]
fn worker_description_default_is_some_when_set() {
assert_eq!(
<RecordingWorker as Worker>::DESCRIPTION,
Some("Records calls for testing")
);
}
struct MinimalWorker;
impl Default for MinimalWorker {
fn default() -> Self {
MinimalWorker
}
}
impl Worker for MinimalWorker {
type Context = TestWorkerCtx;
const NAME: &'static str = "minimal-worker";
fn run(
&self,
_ctx: std::sync::Arc<Self::Context>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), FlatbedWorkerError>> + Send>,
> {
Box::pin(async { Ok(()) })
}
}
#[test]
fn worker_description_default_is_none() {
assert!(<MinimalWorker as Worker>::DESCRIPTION.is_none());
}
register_worker!(MinimalWorker, TestWorkerCtx);
#[test]
fn register_worker_macro_makes_worker_discoverable() {
let workers = get_workers();
let found = workers
.iter()
.find(|w| w.name == <MinimalWorker as Worker>::NAME);
assert!(
found.is_some(),
"MinimalWorker should be discoverable via inventory"
);
}
#[test]
fn registered_worker_name_matches_trait_constant() {
let workers = get_workers();
let found = workers.iter().find(|w| w.name == "minimal-worker");
assert!(found.is_some());
let info = found.unwrap();
assert_eq!(info.name, <MinimalWorker as Worker>::NAME);
assert_eq!(info.description, <MinimalWorker as Worker>::DESCRIPTION);
}
#[tokio::test]
async fn registered_worker_can_be_invoked_via_inventory() {
let workers = get_workers();
let info = workers
.iter()
.find(|w| w.name == "minimal-worker")
.expect("MinimalWorker should be registered");
let ctx = std::sync::Arc::new(TestWorkerCtx {
label: "inventory-test".to_string(),
});
let dyn_ctx: std::sync::Arc<dyn std::any::Any + Send + Sync> = ctx;
let result = (info.worker)(dyn_ctx).await;
assert!(result.is_ok());
}
}