diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index f9f761338..f211fc29c 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -9,7 +9,7 @@ use std::{convert::Infallible, net::IpAddr, sync::Arc}; use axum::extract::{FromRef, FromRequestParts}; use ipnetwork::IpNetwork; use mas_context::LogContext; -use mas_data_model::{BoxClock, BoxRng, SiteConfig, SystemClock}; +use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, SystemClock}; use mas_handlers::{ ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter, MetadataCache, RequesterFingerprint, passwords::PasswordManager, @@ -27,7 +27,7 @@ use rand::SeedableRng; use sqlx::PgPool; use tracing::Instrument; -use crate::telemetry::METER; +use crate::{VERSION, telemetry::METER}; #[derive(Clone)] pub struct AppState { @@ -214,6 +214,12 @@ impl FromRef for Arc { } } +impl FromRef for AppVersion { + fn from_ref(_input: &AppState) -> Self { + AppVersion(VERSION) + } +} + impl FromRequestParts for BoxClock { type Rejection = Infallible; diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 6be06b4d9..337c05d89 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -18,6 +18,7 @@ pub(crate) mod upstream_oauth2; pub(crate) mod user_agent; pub(crate) mod users; mod utils; +mod version; /// Error when an invalid state transition is attempted. #[derive(Debug, Error)] @@ -57,4 +58,5 @@ pub use self::{ UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken, }, utils::{BoxClock, BoxRng}, + version::AppVersion, }; diff --git a/crates/data-model/src/version.rs b/crates/data-model/src/version.rs new file mode 100644 index 000000000..86d890fc1 --- /dev/null +++ b/crates/data-model/src/version.rs @@ -0,0 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +/// A structure which holds information about the running version of the app +#[derive(Debug, Clone, Copy)] +pub struct AppVersion(pub &'static str); diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index e5e158be3..194f839f3 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -20,7 +20,7 @@ use axum::{ use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use indexmap::IndexMap; use mas_axum_utils::InternalError; -use mas_data_model::{BoxRng, SiteConfig}; +use mas_data_model::{AppVersion, BoxRng, SiteConfig}; use mas_http::CorsLayerExt; use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; @@ -164,6 +164,7 @@ where UrlBuilder: FromRef, Arc: FromRef, SiteConfig: FromRef, + AppVersion: FromRef, { // We *always* want to explicitly set the possible responses, beacuse the // infered ones are not necessarily correct diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index c0b5d8ddb..dc09c90b4 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -11,7 +11,7 @@ use aide::axum::{ routing::{get_with, post_with}, }; use axum::extract::{FromRef, FromRequestParts}; -use mas_data_model::{BoxRng, SiteConfig}; +use mas_data_model::{AppVersion, BoxRng, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; @@ -28,6 +28,7 @@ mod user_emails; mod user_registration_tokens; mod user_sessions; mod users; +mod version; pub fn router() -> ApiRouter where @@ -35,6 +36,7 @@ where Arc: FromRef, PasswordManager: FromRef, SiteConfig: FromRef, + AppVersion: FromRef, Arc: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, @@ -44,6 +46,10 @@ where "/site-config", get_with(self::site_config::handler, self::site_config::doc), ) + .api_route( + "/version", + get_with(self::version::handler, self::version::doc), + ) .api_route( "/compat-sessions", get_with(self::compat_sessions::list, self::compat_sessions::list_doc), diff --git a/crates/handlers/src/admin/v1/version.rs b/crates/handlers/src/admin/v1/version.rs new file mode 100644 index 000000000..2fe53940b --- /dev/null +++ b/crates/handlers/src/admin/v1/version.rs @@ -0,0 +1,62 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::transform::TransformOperation; +use axum::{Json, extract::State}; +use mas_data_model::AppVersion; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::admin::call_context::CallContext; + +#[derive(Serialize, JsonSchema)] +pub struct Version { + /// The semver version of the app + pub version: &'static str, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("version") + .tag("server") + .summary("Get the version currently running") + .response_with::<200, Json, _>(|t| t.example(Version { version: "v1.0.0" })) +} + +#[tracing::instrument(name = "handler.admin.v1.version", skip_all)] +pub async fn handler( + _: CallContext, + State(AppVersion(version)): State, +) -> Json { + Json(Version { version }) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_add_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::get("/api/admin/v1/version").bearer(&token).empty(); + + let response = state.request(request).await; + + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "version": "v0.0.0-test" + } + "#); + } +} diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 6eed219da..1b73c05c3 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -60,6 +60,7 @@ impl_from_ref!(mas_keystore::Keystore); impl_from_ref!(mas_handlers::passwords::PasswordManager); impl_from_ref!(Arc); impl_from_ref!(mas_data_model::SiteConfig); +impl_from_ref!(mas_data_model::AppVersion); fn main() -> Result<(), Box> { let (mut api, _) = mas_handlers::admin_api_router::(); diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index e43194776..a25fda9dc 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -28,7 +28,7 @@ use mas_axum_utils::{ cookies::{CookieJar, CookieManager}, }; use mas_config::RateLimitingConfig; -use mas_data_model::{BoxClock, BoxRng, SiteConfig, clock::MockClock}; +use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, clock::MockClock}; use mas_email::{MailTransport, Mailer}; use mas_i18n::Translator; use mas_keystore::{Encrypter, JsonWebKey, JsonWebKeySet, Keystore, PrivateKey}; @@ -575,6 +575,12 @@ impl FromRef for reqwest::Client { } } +impl FromRef for AppVersion { + fn from_ref(_input: &TestState) -> Self { + AppVersion("v0.0.0-test") + } +} + impl FromRequestParts for ActivityTracker { type Rejection = Infallible; diff --git a/docs/api/spec.json b/docs/api/spec.json index 98f8b2532..74e42f734 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -50,6 +50,30 @@ } } }, + "/api/admin/v1/version": { + "get": { + "tags": [ + "server" + ], + "summary": "Get the version currently running", + "operationId": "version", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + }, + "example": { + "version": "v1.0.0" + } + } + } + } + } + } + }, "/api/admin/v1/compat-sessions": { "get": { "tags": [ @@ -3710,6 +3734,18 @@ } } }, + "Version": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "description": "The semver version of the app", + "type": "string" + } + } + }, "PaginationParams": { "type": "object", "properties": {