Skip to content

Versioning

A long-lived API outlives its first wire shape. URI versioning lets a controller mount under a /v<N> prefix without touching its path = "…" — one attribute, one prefix, every consumer of the route table sees the same thing.

crates/features/src/posts/http/controller.rs
use nest_rs_http::{controller, routes};
#[controller(path = "/posts", version = "1")]
pub struct PostsV1Controller {
#[inject]
svc: Arc<PostsService>,
}
#[routes]
impl PostsV1Controller {
#[get("/")]
#[authorize(Read, posts::Entity)]
async fn list(&self) -> Result<Json<Vec<Post>>> {
Ok(Json(self.svc.feed().await?))
}
}

Routes mount under /v1/posts. The boot log, the OpenAPI document, and the served path all route through the same version_path helper, so they cannot drift:

Terminal window
$ nestrs run dev api
INFO nest_rs::routes: mounted route controller=PostsV1Controller method=GET path=/v1/posts handler=list

The version string is opaque — "1", "2", "beta" all work; the prefix is built as format!("/v{version}"). Stick to integers unless you have a reason not to.

A controller per version, both provided by the feature’s HTTP module:

crates/features/src/posts/http/module.rs
use nest_rs_core::module;
use super::controller::{PostsV1Controller, PostsV2Controller};
use crate::authz::AuthzHttpModule;
use crate::posts::PostsModule;
#[module(
imports = [PostsModule, AuthzHttpModule],
providers = [PostsV1Controller, PostsV2Controller],
)]
pub struct PostsHttpModule;

Both controllers live in the feature’s http/controller.rs — a version is a wire concern, not a second feature, and the app crate holds no controllers at all.

crates/features/src/posts/http/controller.rs
#[controller(path = "/posts", version = "1")]
pub struct PostsV1Controller { /* … */ }
#[controller(path = "/posts", version = "2")]
pub struct PostsV2Controller { /* … */ }

Two controllers, two mount paths (/v1/posts, /v2/posts), two OpenAPI tags by default (the controller struct name groups routes), zero runtime overhead — the version is decided at boot, not per request.

A common pattern: V2 delegates to the same PostsService when the change is purely on the wire (rename a field, add an optional field), and only the controller’s Json<T> shape differs. The service stays one.

The version isn’t a header, isn’t a query, isn’t a content type. Three consequences:

  • Clients pick the version by URL. A migration is one URL change, not a header negotiation.
  • The OpenAPI document carries one path per version. A doc consumer can list both versions side by side.
  • A cache or proxy can route on the path without inspecting headers.

If header-based versioning is a hard requirement (some media-type-driven APIs lean on Accept: application/vnd.api+json; version=2), the URI path is the wrong tool. Use a custom guard that reads the header and dispatches inside the handler.

  • Controllers & routes — back to the route table.
  • OpenAPI — the generated spec includes the version prefix on every operation.
  • ConfigurationHttpTransport::global_prefix for the orthogonal “everything under /api” case (versioning stacks on top: /api/v1/posts).

Built by YV17labs