Plugin System

Temps is a modular monolith. Every feature — from the HTTP proxy to analytics to deployments — is a plugin that implements the TempsPlugin trait. This page is the end-to-end reference for writing plugins: how services are registered, how dependencies are wired, how routes are exposed, and how to test the whole thing.


Overview

A plugin is a self-contained unit that bundles four things:

  1. Services — the business logic layer (e.g. BackupService, DeployerService).
  2. Routes — Axum handlers exposed under a consistent URL prefix.
  3. OpenAPI schema — automatically merged into the global /swagger-ui/ docs.
  4. Lifecycle hooks — a two-phase init so plugins can cross-reference each other's services.

Plugins talk to each other only through the service registry. They never import each other's internal modules, which keeps the dependency graph flat and the build fast.

Why a plugin system?

  • Name
    Modularity
    Description

    Each crate owns its services, errors, handlers, and migrations. Adding a feature means adding a crate, not touching 20 files.

  • Name
    Type-safe DI
    Description

    The service registry is keyed by TypeId. require_service::<Arc<T>>() fails at startup with a clear error if a dependency is missing — never at request time.

  • Name
    Testability
    Description

    Services are constructed with Arc<T> and Arc<dyn Trait> dependencies, so tests inject mocks without a DI container.

  • Name
    OpenAPI out of the box
    Description

    Every plugin contributes its utoipa schemas. The global OpenAPI doc is the union of every registered plugin.

  • Name
    Zero runtime overhead
    Description

    Dispatch is a single Arc clone at startup. No reflection, no dynamic loading, no plugin FFI.


Example Plugins

The easiest way to learn the plugin system is to read working code. Official plugins live in a dedicated monorepo at gotempsh/plugins — each one is a standalone Cargo crate you can clone, build, and hack on.

  • Name
    example-plugin
    Description

    A minimal "hello world" plugin with a web UI bundle. The shortest path to understanding the plugin protocol, stdin/stdout channel, and UI asset layout. View source →

  • Name
    lighthouse-plugin
    Description

    Runs Google Lighthouse audits after every successful deployment and tracks Core Web Vitals over time. Demonstrates deploy-event subscriptions and background workers. View source →

  • Name
    indexnow-plugin
    Description

    Automatically pings the IndexNow API (Bing, Yandex, Seznam) with the list of URLs changed in each deployment. Good template for search-engine integrations. View source →

  • Name
    google-indexing-plugin
    Description

    Notifies the Google Indexing API when pages are published or removed. Shows how to store and refresh a Google service-account credential inside plugin storage. View source →

Option 1: install a prebuilt binary

Every tagged release ships per-platform binaries for every plugin.

curl -L -o ~/.temps/plugins/temps-lighthouse-plugin \
  https://github.com/gotempsh/plugins/releases/latest/download/temps-lighthouse-plugin-x86_64-linux
chmod +x ~/.temps/plugins/temps-lighthouse-plugin

Option 2: build from source

git clone https://github.com/gotempsh/plugins
cd plugins
cargo build --release -p temps-lighthouse-plugin

cp target/release/temps-lighthouse-plugin ~/.temps/plugins/
chmod +x ~/.temps/plugins/temps-lighthouse-plugin

Then open Settings → Plugins in the Temps dashboard and click Reload Plugins.


External Plugins (Binaries)

Temps supports two kinds of plugins:

  • In-process plugins — Rust crates compiled into the temps binary, implementing the TempsPlugin trait. Everything from the rest of this page is about in-process plugins.
  • External plugins — standalone binaries (any language) that Temps spawns as child processes and talks to over stdin/stdout. These are what you install via Settings → Plugins.

External plugins are the right choice when you:

  • Want to ship a plugin without forking or recompiling Temps itself.
  • Need to use a non-Rust language (Python, Go, TypeScript, etc.).
  • Are distributing a proprietary or commercial plugin as a prebuilt binary.

How it works

  1. On startup (and on Reload), Temps scans $TEMPS_DATA_DIR/plugins/ (default ~/.temps/plugins).
  2. Every executable file is spawned as a child process.
  3. Temps and the plugin exchange JSON messages over stdin/stdout — the protocol is defined in the temps-plugin-sdk crate.
  4. Plugins can register routes, bundle a web UI, subscribe to deploy events, and persist data.

Installing an external plugin

# 1. Drop the binary into the plugins directory
cp ./my-plugin ~/.temps/plugins/

# 2. Make it executable
chmod +x ~/.temps/plugins/my-plugin

# 3. Reload from the dashboard (Settings → Plugins → Reload)
#    or restart the Temps server

The dashboard will show the plugin name, version, and whether it exposes a UI or requires database access. If you don't see it after Reload, check the Temps server logs for the plugin's stderr output.


Plugin Architecture

The TempsPlugin trait

Every plugin implements this trait. Only name and register_services are required — the rest are opt-in.

#[async_trait]
pub trait TempsPlugin: Send + Sync {
    /// Unique identifier, used in logs and error messages.
    fn name(&self) -> &'static str;

    /// Phase 1: register the services this plugin provides.
    /// Called for every plugin BEFORE any `initialize` is called.
    fn register_services(
        &self,
        ctx: &ServiceRegistrationContext,
    ) -> Result<(), PluginError>;

    /// Phase 2 (optional): initialize services that depend on
    /// services from OTHER plugins. All registrations are complete
    /// by the time this runs.
    async fn initialize(&self, _ctx: &PluginContext) -> Result<(), PluginError> {
        Ok(())
    }

    /// Configure HTTP routes for this plugin (optional).
    fn configure_routes(&self, _ctx: &PluginContext) -> Option<PluginRoutes> {
        None
    }

    /// Contribute to the global OpenAPI schema (optional).
    fn openapi_schema(&self) -> Option<OpenApi> {
        None
    }
}

Two-phase initialization

The registration context enforces a strict ordering that makes circular dependencies impossible:

Loading diagram...

During Phase 1, each plugin registers the services it owns. It may require_service to pull in core dependencies (database, config) but MUST NOT look up services from other plugins — they may not be registered yet.

During Phase 2, the registry is frozen. Plugins can safely cross-reference each other, because every service is guaranteed to exist. This is where a plugin wires, say, its notification sender into the audit service.


Lifecycle

Loading diagram...

Key invariants:

  • require_service fails fast with a clear error if the dependency was never registered. This is how Temps catches configuration mistakes at startup instead of at 3am.
  • Services are stored as Arc<T>. Every clone is cheap. Never pass the raw registry into handlers — pass the specific Arc<Service> instance.
  • Routes are configured once at startup. You cannot mount new routes at runtime.

Build Your First Plugin

This walks through creating a minimal "pings" plugin that exposes POST /api/pings and stores a row in the database. It shows every layer: crate, error type, service, handler, plugin.

1. Create the crate

Add it to the workspace in Cargo.toml:

[workspace]
members = [
    # ...
    "crates/temps-pings",
]

And in crates/temps-pings/Cargo.toml:

[package]
name = "temps-pings"
version.workspace = true
edition.workspace = true

[dependencies]
temps-core = { path = "../temps-core" }
temps-entities = { path = "../temps-entities" }
async-trait = { workspace = true }
axum = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true }

2. Define a typed error enum

Every crate owns its error type. Include IDs in messages so logs are greppable.

// crates/temps-pings/src/error.rs
use thiserror::Error;

#[derive(Error, Debug)]
pub enum PingError {
    #[error("Ping {ping_id} not found")]
    NotFound { ping_id: i32 },

    #[error("Validation error: {message}")]
    Validation { message: String },

    #[error("Database error: {0}")]
    Database(#[from] sea_orm::DbErr),
}

3. Write the service

The service owns business logic. Handlers NEVER touch the database directly.

// crates/temps-pings/src/service.rs
use std::sync::Arc;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
use temps_entities::pings::{self, Entity as Ping};
use crate::error::PingError;

pub struct PingService {
    db: Arc<DatabaseConnection>,
}

impl PingService {
    pub fn new(db: Arc<DatabaseConnection>) -> Self {
        Self { db }
    }

    pub async fn create(&self, message: String) -> Result<pings::Model, PingError> {
        if message.trim().is_empty() {
            return Err(PingError::Validation {
                message: "message cannot be empty".into(),
            });
        }

        let model = pings::ActiveModel {
            message: Set(message),
            ..Default::default()
        }
        .insert(self.db.as_ref())
        .await?;

        Ok(model)
    }

    pub async fn get(&self, ping_id: i32) -> Result<pings::Model, PingError> {
        Ping::find_by_id(ping_id)
            .one(self.db.as_ref())
            .await?
            .ok_or(PingError::NotFound { ping_id })
    }
}

4. Write the handler

Handlers handle auth, DTOs, audit, and HTTP mapping — nothing else.

// crates/temps-pings/src/handlers.rs
use std::sync::Arc;
use axum::{extract::{Path, State}, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use temps_core::problemdetails::{self, Problem};
use utoipa::ToSchema;
use crate::{error::PingError, service::PingService};

#[derive(Clone)]
pub struct PingAppState {
    pub ping_service: Arc<PingService>,
}

#[derive(Deserialize, ToSchema)]
pub struct CreatePingRequest { pub message: String }

#[derive(Serialize, ToSchema)]
pub struct PingResponse { pub id: i32, pub message: String }

impl From<temps_entities::pings::Model> for PingResponse {
    fn from(m: temps_entities::pings::Model) -> Self {
        Self { id: m.id, message: m.message }
    }
}

// Map domain errors to HTTP responses. Every variant explicitly.
impl From<PingError> for Problem {
    fn from(err: PingError) -> Self {
        match err {
            PingError::NotFound { .. } => problemdetails::new(StatusCode::NOT_FOUND)
                .with_title("Ping Not Found")
                .with_detail(err.to_string()),
            PingError::Validation { .. } => problemdetails::new(StatusCode::BAD_REQUEST)
                .with_title("Validation Error")
                .with_detail(err.to_string()),
            PingError::Database(_) => problemdetails::new(StatusCode::INTERNAL_SERVER_ERROR)
                .with_title("Internal Server Error")
                .with_detail(err.to_string()),
        }
    }
}

#[utoipa::path(
    post, path = "/api/pings", tag = "Pings",
    request_body = CreatePingRequest,
    responses(
        (status = 201, body = PingResponse),
        (status = 400, body = ProblemDetails),
    ),
)]
pub async fn create_ping(
    State(state): State<PingAppState>,
    Json(req): Json<CreatePingRequest>,
) -> Result<(StatusCode, Json<PingResponse>), Problem> {
    let ping = state.ping_service.create(req.message).await?;
    Ok((StatusCode::CREATED, Json(ping.into())))
}

#[utoipa::path(get, path = "/api/pings/{id}", tag = "Pings")]
pub async fn get_ping(
    State(state): State<PingAppState>,
    Path(id): Path<i32>,
) -> Result<Json<PingResponse>, Problem> {
    let ping = state.ping_service.get(id).await?;
    Ok(Json(ping.into()))
}

5. Implement TempsPlugin

This is the glue.

// crates/temps-pings/src/plugin.rs
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use temps_core::plugin::{
    PluginContext, PluginError, PluginRoutes,
    ServiceRegistrationContext, TempsPlugin,
};
use crate::{
    handlers::{create_ping, get_ping, PingAppState},
    service::PingService,
};

pub struct PingsPlugin;

#[async_trait]
impl TempsPlugin for PingsPlugin {
    fn name(&self) -> &'static str { "pings" }

    fn register_services(
        &self,
        ctx: &ServiceRegistrationContext,
    ) -> Result<(), PluginError> {
        // `require_service` fails fast with a clear error at startup
        // if the DB plugin hasn't registered the connection yet.
        let db = ctx.require_service::<Arc<DatabaseConnection>>()?;
        ctx.register_service(Arc::new(PingService::new(db)));
        Ok(())
    }

    fn configure_routes(&self, ctx: &PluginContext) -> Option<PluginRoutes> {
        let ping_service = ctx.require_service::<Arc<PingService>>().ok()?;
        let state = PingAppState { ping_service };

        let router = axum::Router::new()
            .route("/api/pings", axum::routing::post(create_ping))
            .route("/api/pings/{id}", axum::routing::get(get_ping))
            .with_state(state);

        Some(PluginRoutes::new(router))
    }

    fn openapi_schema(&self) -> Option<utoipa::openapi::OpenApi> {
        Some(crate::api_doc::ApiDoc::openapi())
    }
}

6. Register the plugin

In temps-bootstrap (or wherever your plugin list lives):

let plugins: Vec<Box<dyn TempsPlugin>> = vec![
    Box::new(temps_database::DatabasePlugin::new(db_config)),
    Box::new(temps_auth::AuthPlugin::new(auth_config)),
    Box::new(temps_pings::PingsPlugin),
    // ...
];

That's it — the platform handles registration, routing, and OpenAPI merging.


Service Registration

require_service vs get_service

// get_service: returns Option<Arc<T>>. Use when the dependency is optional.
let emails: Option<Arc<EmailService>> = ctx.get_service();

// require_service: returns Result<Arc<T>, PluginError>.
// Fails plugin init with a descriptive error. Use for mandatory deps.
let db: Arc<DatabaseConnection> = ctx.require_service()?;

Rule of thumb: if your plugin cannot function without the dependency, use require_service. If it can degrade gracefully (e.g. "email notifications disabled when email plugin missing"), use get_service.

Type-keyed lookup

The registry is keyed by TypeId, not by name. That means:

  • One instance per type. Registering Arc<MyService> twice overwrites the first.
  • Different types are distinct entries. Arc<PingService> and Arc<dyn PingSender> are separate, even if PingService implements PingSender.

If you want to expose both the concrete type and a trait object, register both:

let svc: Arc<PingService> = Arc::new(PingService::new(db));
ctx.register_service(svc.clone());
ctx.register_service(svc as Arc<dyn PingSender>);

Registration flow

Loading diagram...

Common pitfalls

  • Name
    Looking up other-plugin services in Phase 1
    Description

    A plugin's register_services runs in registration order. If you look up a service from another plugin here, that plugin might not have registered yet. Move the lookup to initialize.

  • Name
    Storing the registry in the service
    Description

    Never pass &ServiceRegistry or ServiceRegistrationContext into a service constructor. Resolve dependencies explicitly and pass Arc<T> values.

  • Name
    Using Option<T> for required deps
    Description

    If the plugin can't work without a service, make it non-optional in the constructor signature. Let startup fail loudly.

  • Name
    Sharing deps via accessor methods
    Description

    Don't expose fn db(&self) -> &DatabaseConnection on services. Each component that needs the DB should receive its own Arc<DatabaseConnection> clone.


Route Configuration

URL conventions

PrefixUse
/api/admin/*Admin-only APIs, guarded by permission_guard!(auth, AdminRead) etc.
/api/<plugin>/*Plugin-owned APIs (e.g. /api/backups, /api/pings).
/api/public/*Unauthenticated public endpoints.
/api/_temps/*Reserved for internal Temps use.

Path parameters

Always use Axum's {param} syntax, never :param:

.route("/api/pings/{id}", axum::routing::get(get_ping))
.route("/api/projects/{project_id}/backups/{backup_id}", ...)

Per-plugin state

Each plugin builds its own AppState struct containing the Arc<Service> instances it needs, and attaches it via .with_state(...). This keeps routers decoupled — a plugin's state doesn't leak into other plugins.

#[derive(Clone)]
pub struct PingAppState {
    pub ping_service: Arc<PingService>,
    pub audit_service: Arc<AuditService>,
}

Middleware

Apply auth, request-ID, and tracing middleware at the plugin-router level, not on individual routes:

let router = axum::Router::new()
    .route("/api/pings", axum::routing::post(create_ping))
    .route_layer(axum::middleware::from_fn(require_auth))
    .with_state(state);

OpenAPI Integration

Each plugin defines its own utoipa ApiDoc and returns it from openapi_schema. The bootstrap layer merges every plugin's schema into a single doc served at /swagger-ui/ and /openapi.json.

// crates/temps-pings/src/api_doc.rs
use utoipa::OpenApi;
use crate::handlers::{CreatePingRequest, PingResponse};

#[derive(OpenApi)]
#[openapi(
    paths(
        crate::handlers::create_ping,
        crate::handlers::get_ping,
    ),
    components(schemas(CreatePingRequest, PingResponse)),
    tags((name = "Pings", description = "Ping endpoints")),
)]
pub struct ApiDoc;

Tips:

  • Use tag = "Pings" consistently on every #[utoipa::path(...)] macro so endpoints group correctly in Swagger UI.
  • Document every response code your handler can produce, including 401, 403, and the RFC 7807 Problem schema for errors.
  • Re-export shared response types (like ProblemDetails) from temps-core so every plugin references the same schema.

Testing Plugins

Unit-test the service

Use MockDatabase from Sea-ORM to test happy paths and every error variant:

#[cfg(test)]
mod tests {
    use super::*;
    use sea_orm::{DatabaseBackend, MockDatabase};

    #[tokio::test]
    async fn create_rejects_empty_message() {
        let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let svc = PingService::new(Arc::new(db));

        let err = svc.create("   ".into()).await.unwrap_err();
        assert!(matches!(err, PingError::Validation { .. }));
    }

    #[tokio::test]
    async fn get_returns_not_found_for_missing_id() {
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results(vec![Vec::<pings::Model>::new()])
            .into_connection();
        let svc = PingService::new(Arc::new(db));

        let err = svc.get(42).await.unwrap_err();
        assert!(matches!(err, PingError::NotFound { ping_id: 42 }));
    }
}

Integration-test the plugin

Use TempsPluginTestContext (from temps-core::plugin::testing) to run the full register→initialize→route flow in-process:

#[tokio::test]
async fn pings_plugin_registers_service() {
    let ctx = TempsPluginTestContext::builder()
        .with_service(Arc::new(test_db().await))
        .with_plugin(PingsPlugin)
        .build()
        .await
        .expect("plugin init");

    let svc = ctx.require_service::<Arc<PingService>>().unwrap();
    let ping = svc.create("hello".into()).await.unwrap();
    assert_eq!(ping.message, "hello");
}

Contract-test the HTTP layer

Drive the Axum router directly with tower::ServiceExt::oneshot:

let app = PingsPlugin.configure_routes(&ctx).unwrap().into_router();
let response = app
    .oneshot(Request::post("/api/pings")
        .header("content-type", "application/json")
        .body(r#"{"message":"hi"}"#.into()).unwrap())
    .await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);

Built-in Plugins

Temps ships with 40+ plugins. These are the most commonly extended:

  • Name
    DatabasePlugin
    Description

    Registers Arc<DatabaseConnection> for every other plugin. Runs migrations at startup.

  • Name
    AuthPlugin
    Description

    Session management, JWT issuance, and the RequireAuth extractor used by every write handler.

  • Name
    ProxyPlugin
    Description

    Pingora-backed HTTP/HTTPS router. Handles TLS termination and analytics capture.

  • Name
    DeployerPlugin
    Description

    Container builds and deploys. Depends on GitService, BlobService, LogService.

  • Name
    DomainsPlugin
    Description

    DNS provisioning and Let's Encrypt certificate management.

  • Name
    AnalyticsPlugin
    Description

    Event ingestion, sessions, funnels, and session replay.

  • Name
    BackupPlugin
    Description

    Scheduled and on-demand backups for Postgres, MySQL, MongoDB, Redis, and S3-backed volumes.

  • Name
    ErrorTrackingPlugin
    Description

    Sentry-compatible ingest endpoint and issue management.

  • Name
    BlobPlugin / KVPlugin
    Description

    Object storage and a Redis-backed KV store, exposed as platform services.

  • Name
    AuditPlugin
    Description

    Append-only audit log for every write operation across the platform.

Plugin dependency graph (abridged)

Loading diagram...

Injecting Plugins from Another Binary

Everything above assumes you add your plugin to the OSS plugin list and rebuild the temps binary. But you can also reuse the entire CLI from an out-of-tree binary — one that wraps the same subcommands while registering its own additional plugins. This is exactly how the Enterprise Edition binary (temps-ee/apps/temps-cli) layers EE plugins on top of OSS without forking the CLI.

The library entrypoint

temps-cli builds both a binary and a library target. The binary (src/main.rs) is a thin delegate:

// crates/temps-cli/src/main.rs
fn main() -> anyhow::Result<()> {
    temps_cli::run(Vec::new())
}

The library (crates/temps-cli/src/lib.rs) exposes the full subcommand surface so another crate can call into it:

ItemSignaturePurpose
Clipub struct CliThe clap parser (log flags + command).
Commandspub enum CommandsEvery subcommand (Serve, Proxy, Setup, …).
dispatchpub fn dispatch(cli: Cli, extra_plugins: Vec<Box<dyn TempsPlugin>>) -> anyhow::Result<()>Routes a parsed Cli to its command. extra_plugins is forwarded to serve only.
runpub fn run(extra_plugins: Vec<Box<dyn TempsPlugin>>) -> anyhow::Result<()>Parses args, installs tracing + the rustls crypto provider, then dispatches.

The [lib] target is declared in crates/temps-cli/Cargo.toml:

[lib]
name = "temps_cli"
path = "src/lib.rs"

An EE-bundled binary then becomes its own thin delegate that passes a non-empty plugin vec:

// temps-ee/apps/temps-cli/src/main.rs (illustrative)
fn main() -> anyhow::Result<()> {
    temps_cli::run(vec![
        Box::new(temps_ee_teams::TeamsPlugin::new()),
        // ... other EE plugins
    ])
}

Where extra plugins get registered

dispatch forwards extra_plugins only to temps serve — every other subcommand has no plugin lifecycle and ignores them. Inside serve, the plumbing is:

  • ServeCommand::execute() is just self.execute_with_extra_plugins(Vec::new()).
  • ServeCommand::execute_with_extra_plugins(extra_plugins) carries the vec into ConsoleApiParams, which has a public field extra_plugins: Vec<Box<dyn TempsPlugin>> (OSS callers pass an empty vec).
  • The console registers these plugins last — after every OSS plugin and immediately before plugin_manager.initialize_plugins().

Because they register last, extra plugins can resolve any OSS service via require_service in their register_services and initialize phases. They observe the full OSS service registry, so an EE plugin can wrap or extend OSS services without the OSS crates knowing it exists.

Sandbox API URL resolution

A related fix lives in the agent-workflow sandbox. When the platform launches a sandbox container for an agent run, it injects a TEMPS_API_URL env var so the in-sandbox CLI can reach the control plane. That value is now resolved in this priority order:

PrioritySource
1The TEMPS_INTERNAL_API_URL process env var, if set.
2The platform's configured external_url (from settings), trailing slash trimmed, if non-empty.
3The hard-coded fallback http://host.docker.internal:3000.

Previously the resolution fell straight from (1) to the host.docker.internal:3000 fallback. Adding the external_url step means remote and production control planes reach the API on agent-workflow runs without operators having to set TEMPS_INTERNAL_API_URL explicitly.


Best Practices

  1. One crate per plugin. Keeps compile times fast and boundaries clean.
  2. Typed errors, not anyhow. Define a domain error enum per crate, with #[from] for common conversions and structured fields for context.
  3. Services own transactions. Handlers call one service method per request. If a handler needs to coordinate multiple services, add a method to the service layer instead.
  4. Audit every write. In handlers, call audit_service.create_audit_log(...) after successful POST/PATCH/DELETE. Log-but-continue on audit failures.
  5. Mask secrets in responses. Never serialize raw API keys, tokens, or passwords. Use "***" masking in the response DTO.
  6. Paginate everything. Default 20, max 100, sorted by created_at DESC. See the architecture guide for the standard pagination helper.
  7. Test Docker-backed code gracefully. If a test requires Docker, skip at runtime with a log message instead of #[ignore]. Keeps CI green on machines without Docker.

Troubleshooting

PluginError::ServiceNotFound { service_type: "Arc<X>" } at startup

A plugin called require_service::<Arc<X>>() but no plugin registered that type. Check:

  • Is the providing plugin in your plugin list, before the consumer?
  • Did you register as the right type? Arc<X> and Arc<dyn Trait> are separate keys.
  • Are you looking up in register_services instead of initialize? The providing plugin may register later in Phase 1 — move the lookup to Phase 2.

Routes return 404 in production but work in tests

configure_routes returned None because a require_service call failed. Scan startup logs for WARN/ERROR lines from your plugin and confirm its dependencies registered.

OpenAPI docs missing your endpoints

  • Did you implement openapi_schema? It defaults to None.
  • Is every handler decorated with #[utoipa::path(...)] and listed in paths(...) on the ApiDoc?
  • Are request/response types listed in components(schemas(...))?

Panic: thread 'main' panicked at 'already borrowed: BorrowMutError'

You're trying to register a service from inside a handler or background task. Registration is only valid during Phase 1. Move the call back into register_services.


Additional Resources

Was this page helpful?