diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 45a8efe234..8b053d6238 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -76,6 +76,7 @@ jobs: openssl-3 pam-devel python-langtable-data + python3-openapi_spec_validator timezone xkeyboard-config @@ -101,6 +102,11 @@ jobs: - name: Run the tests run: cargo tarpaulin --out xml -- --nocapture + - name: Generate and validate the OpenAPI specification + run: | + cargo xtask openapi + openapi-spec-validator out/openapi/* + # send the code coverage for the Rust part to the coveralls.io - name: Coveralls GitHub Action uses: coverallsapp/github-action@v2 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b5be81ef8b..c97431a08a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ "regex", "serde", "thiserror", + "utoipa", ] [[package]] @@ -4479,6 +4480,7 @@ dependencies = [ "quote", "regex", "syn 2.0.79", + "uuid", ] [[package]] @@ -4884,6 +4886,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "agama-cli", + "agama-server", "clap", "clap-markdown", "clap_complete", diff --git a/rust/agama-lib/src/network/settings.rs b/rust/agama-lib/src/network/settings.rs index b70128cf31..dbce91e11c 100644 --- a/rust/agama-lib/src/network/settings.rs +++ b/rust/agama-lib/src/network/settings.rs @@ -27,14 +27,14 @@ use std::default::Default; use std::net::IpAddr; /// Network settings for installation -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NetworkSettings { /// Connections to use in the installation pub connections: Vec, } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct MatchSettings { #[serde(skip_serializing_if = "Vec::is_empty", default)] pub driver: Vec, @@ -56,7 +56,7 @@ impl MatchSettings { } /// Wireless configuration -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct WirelessSettings { /// Password of the wireless network @@ -94,7 +94,7 @@ pub struct WirelessSettings { pub pmf: i32, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct BondSettings { pub mode: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -114,7 +114,7 @@ impl Default for BondSettings { } /// IEEE 802.1x (EAP) settings -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IEEE8021XSettings { /// List of EAP methods used diff --git a/rust/agama-lib/src/network/types.rs b/rust/agama-lib/src/network/types.rs index 4cc796a68e..c2119b8f7b 100644 --- a/rust/agama-lib/src/network/types.rs +++ b/rust/agama-lib/src/network/types.rs @@ -36,7 +36,7 @@ pub struct Device { pub state: DeviceState, } -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SSID(pub Vec); impl SSID { @@ -80,7 +80,7 @@ pub enum DeviceType { // For now this mirrors NetworkManager, because it was less mental work than coming up with // what exactly Agama needs. Expected to be adapted. -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum DeviceState { #[default] @@ -145,7 +145,7 @@ impl fmt::Display for DeviceState { } } -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Status { #[default] @@ -183,7 +183,7 @@ impl TryFrom<&str> for Status { } /// Bond mode -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] pub enum BondMode { #[serde(rename = "balance-rr")] RoundRobin = 0, diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 0bc3832973..fde5d31c20 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -43,7 +43,7 @@ pub struct Pattern { } /// Represents the reason why a pattern is selected. -#[derive(Clone, Copy, Debug, PartialEq, Serialize_repr)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize_repr, utoipa::ToSchema)] #[repr(u8)] pub enum SelectedBy { /// The pattern was selected by the user. diff --git a/rust/agama-locale-data/Cargo.toml b/rust/agama-locale-data/Cargo.toml index a1b2274f87..c95c3214cd 100644 --- a/rust/agama-locale-data/Cargo.toml +++ b/rust/agama-locale-data/Cargo.toml @@ -13,3 +13,4 @@ flate2 = "1.0.34" chrono-tz = "0.8.6" regex = "1" thiserror = "1.0.64" +utoipa = "4.2.3" diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index 7f83074b68..65bb72b91a 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -26,7 +26,7 @@ use std::sync::OnceLock; use std::{fmt::Display, str::FromStr}; use thiserror::Error; -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] pub struct LocaleId { // ISO-639 pub language: String, @@ -100,9 +100,11 @@ static KEYMAP_ID_REGEX: OnceLock = OnceLock::new(); /// let id_with_dashes: KeymapId = "es-ast".parse().unwrap(); /// assert_eq!(id, id_with_dashes); /// ``` -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] pub struct KeymapId { + /// Keyboard layout (e.g., "es" in "es(ast)") pub layout: String, + /// Keyboard variante (e.g., "ast" in "es(ast)") pub variant: Option, } diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 07202aa937..a1aaafa64e 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -30,7 +30,7 @@ tracing-journald = "0.3.0" tracing = "0.1.40" clap = { version = "4.5.19", features = ["derive", "wrap_help"] } tower = { version = "0.4.13", features = ["util"] } -utoipa = { version = "4.2.3", features = ["axum_extras"] } +utoipa = { version = "4.2.0", features = ["axum_extras", "uuid"] } config = "0.14.0" rand = "0.8.5" axum-extra = { version = "0.9.4", features = ["cookie", "typed-header"] } diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index 94664fb86f..b3ff78289a 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -46,7 +46,6 @@ use openssl::ssl::{Ssl, SslAcceptor, SslMethod}; use tokio::sync::broadcast::channel; use tokio_openssl::SslStream; use tower::Service; -use utoipa::OpenApi; const DEFAULT_WEB_UI_DIR: &str = "/usr/share/agama/web_ui"; const TOKEN_FILE: &str = "/run/agama/token"; @@ -58,8 +57,6 @@ enum Commands { /// This command starts the server in the given ports. The secondary port, if enabled, uses SSL. /// If no certificate is specified, agama-web-server generates a self-signed one. Serve(ServeArgs), - /// Generates the API documentation in OpenAPI format. - Openapi, } /// Manage Agama's HTTP/JSON API. @@ -379,16 +376,9 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { Ok(()) } -/// Display the API documentation in OpenAPI format. -fn openapi_command() -> anyhow::Result<()> { - println!("{}", web::ApiDoc::openapi().to_pretty_json().unwrap()); - Ok(()) -} - async fn run_command(cli: Cli) -> anyhow::Result<()> { match cli.command { Commands::Serve(options) => serve_command(options).await, - Commands::Openapi => openapi_command(), } } diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index 14518cb45c..73f85dd634 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -693,7 +693,7 @@ impl TryFrom for NetworkConnection { } } -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum ConnectionConfig { #[default] Ethernet, @@ -707,7 +707,7 @@ pub enum ConnectionConfig { Tun(TunConfig), } -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum PortConfig { #[default] None, @@ -730,7 +730,7 @@ impl From for ConnectionConfig { #[error("Invalid MAC address: {0}")] pub struct InvalidMacAddress(String); -#[derive(Debug, Default, Clone, PartialEq, Serialize)] +#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] pub enum MacAddress { MacAddress(macaddr::MacAddr6), Preserve, @@ -791,7 +791,7 @@ impl From for zbus::fdo::Error { } #[skip_serializing_none] -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IpConfig { pub method4: Ipv4Method, @@ -810,7 +810,7 @@ pub struct IpConfig { } #[skip_serializing_none] -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct MatchConfig { #[serde(skip_serializing_if = "Vec::is_empty")] pub driver: Vec, @@ -826,7 +826,7 @@ pub struct MatchConfig { #[error("Unknown IP configuration method name: {0}")] pub struct UnknownIpMethod(String); -#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Ipv4Method { #[default] @@ -862,7 +862,7 @@ impl FromStr for Ipv4Method { } } -#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Ipv6Method { #[default] @@ -910,7 +910,7 @@ impl From for zbus::fdo::Error { } } -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IpRoute { pub destination: IpInet, @@ -939,7 +939,7 @@ impl From<&IpRoute> for HashMap<&str, Value<'_>> { } } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum VlanProtocol { #[default] IEEE802_1Q, @@ -972,7 +972,7 @@ impl fmt::Display for VlanProtocol { } } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct VlanConfig { pub parent: String, pub id: u32, @@ -980,7 +980,7 @@ pub struct VlanConfig { } #[serde_as] -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct WirelessConfig { pub mode: WirelessMode, @@ -1122,7 +1122,7 @@ impl TryFrom for WirelessSettings { } } -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum WirelessMode { Unknown = 0, AdHoc = 1, @@ -1160,7 +1160,7 @@ impl fmt::Display for WirelessMode { } } -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, utoipa::ToSchema)] pub enum SecurityProtocol { #[default] WEP, // No encryption or WEP ("none") @@ -1206,7 +1206,7 @@ impl TryFrom<&str> for SecurityProtocol { } } -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum GroupAlgorithm { Wep40, Wep104, @@ -1244,7 +1244,7 @@ impl fmt::Display for GroupAlgorithm { } } -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum PairwiseAlgorithm { Tkip, Ccmp, @@ -1276,7 +1276,7 @@ impl fmt::Display for PairwiseAlgorithm { } } -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum WPAProtocolVersion { Wpa, Rsn, @@ -1308,7 +1308,7 @@ impl fmt::Display for WPAProtocolVersion { } } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct WEPSecurity { pub auth_alg: WEPAuthAlg, pub wep_key_type: WEPKeyType, @@ -1317,7 +1317,7 @@ pub struct WEPSecurity { pub wep_key_index: u32, } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum WEPKeyType { #[default] Unknown = 0, @@ -1338,7 +1338,7 @@ impl TryFrom for WEPKeyType { } } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum WEPAuthAlg { #[default] Unset, @@ -1373,7 +1373,7 @@ impl fmt::Display for WEPAuthAlg { } } -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum WirelessBand { A, // 5GHz BG, // 2.4GHz @@ -1401,7 +1401,7 @@ impl TryFrom<&str> for WirelessBand { } } -#[derive(Debug, Default, Clone, PartialEq, Serialize)] +#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] pub struct BondOptions(pub HashMap); impl TryFrom<&str> for BondOptions { @@ -1434,7 +1434,7 @@ impl fmt::Display for BondOptions { } } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct BondConfig { pub mode: BondMode, pub options: BondOptions, @@ -1478,7 +1478,7 @@ impl TryFrom for BondSettings { } } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct BridgeConfig { pub stp: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -1493,7 +1493,7 @@ pub struct BridgeConfig { pub ageing_time: Option, } -#[derive(Debug, Default, PartialEq, Clone, Serialize)] +#[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct BridgePortConfig { #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, @@ -1501,14 +1501,14 @@ pub struct BridgePortConfig { pub path_cost: Option, } -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct InfinibandConfig { pub p_key: Option, pub parent: Option, pub transport_mode: InfinibandTransportMode, } -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum InfinibandTransportMode { #[default] Datagram, @@ -1541,14 +1541,14 @@ impl fmt::Display for InfinibandTransportMode { } } -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum TunMode { #[default] Tun = 1, Tap = 2, } -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct TunConfig { pub mode: TunMode, pub group: Option, @@ -1569,7 +1569,7 @@ pub enum NetworkChange { DeviceUpdated(String, Device), } -#[derive(Default, Debug, PartialEq, Clone, Serialize)] +#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct IEEE8021XConfig { pub eap: Vec, pub phase2_auth: Option, @@ -1658,7 +1658,7 @@ impl TryFrom for IEEE8021XSettings { #[error("Invalid eap method: {0}")] pub struct InvalidEAPMethod(String); -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum EAPMethod { LEAP, MD5, @@ -1705,7 +1705,7 @@ impl fmt::Display for EAPMethod { #[error("Invalid phase2-auth method: {0}")] pub struct InvalidPhase2AuthMethod(String); -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum Phase2AuthMethod { PAP, CHAP, diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 11fb6adff9..a9d411c8a7 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -456,7 +456,8 @@ async fn proposal(State(state): State>) -> Result>) -> Result, Error> { state.software.probe().await?; diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 5660d3939d..4709da1446 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -189,7 +189,8 @@ async fn set_config( responses( (status = 200, description = "Devices were probed and an initial proposal were performed"), (status = 400, description = "The D-Bus service could not perform the action") - ) + ), + operation_id = "storage_probe" )] async fn probe(State(state): State>) -> Result, Error> { Ok(Json(state.client.probe().await?)) diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 7f77b8dfd1..2a7ad06e75 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -41,7 +41,7 @@ use axum::Router; mod auth; pub mod common; mod config; -mod docs; +pub mod docs; mod event; mod http; mod service; @@ -50,7 +50,6 @@ mod ws; use agama_lib::{connection, error::ServiceError}; pub use config::ServiceConfig; -pub use docs::ApiDoc; pub use event::{Event, EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; use std::path::Path; diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index fcafe61c02..ce9b8d240e 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -18,143 +18,44 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use utoipa::OpenApi; -#[derive(OpenApi)] -#[openapi( - info(description = "Agama web API description"), - paths( - crate::l10n::web::get_config, - crate::l10n::web::keymaps, - crate::l10n::web::locales, - crate::l10n::web::set_config, - crate::l10n::web::timezones, - crate::manager::web::finish_action, - crate::manager::web::install_action, - crate::manager::web::installer_status, - crate::manager::web::probe_action, - crate::network::web::add_connection, - crate::network::web::apply, - crate::network::web::connect, - crate::network::web::connections, - crate::network::web::delete_connection, - crate::network::web::devices, - crate::network::web::disconnect, - crate::network::web::update_connection, - crate::questions::web::answer_question, - crate::questions::web::get_answer, - crate::questions::web::delete_question, - crate::questions::web::create_question, - crate::questions::web::list_questions, - crate::software::web::get_config, - crate::software::web::patterns, - crate::software::web::probe, - crate::software::web::products, - crate::software::web::proposal, - crate::software::web::set_config, - crate::storage::web::actions, - crate::storage::web::devices_dirty, - crate::storage::web::get_proposal_settings, - crate::storage::web::probe, - crate::storage::web::product_params, - crate::storage::web::set_proposal_settings, - crate::storage::web::staging_devices, - crate::storage::web::system_devices, - crate::storage::web::usable_devices, - crate::storage::web::volume_for, - crate::storage::web::iscsi::delete_node, - crate::storage::web::iscsi::discover, - crate::storage::web::iscsi::initiator, - crate::storage::web::iscsi::login_node, - crate::storage::web::iscsi::logout_node, - crate::storage::web::iscsi::nodes, - crate::storage::web::iscsi::update_initiator, - crate::storage::web::iscsi::update_node, - crate::storage::web::dasd::probe, - crate::storage::web::dasd::supported, - crate::storage::web::dasd::devices, - crate::storage::web::dasd::format, - crate::storage::web::dasd::enable, - crate::storage::web::dasd::disable, - crate::storage::web::dasd::set_diag, - crate::storage::web::zfcp::supported, - crate::storage::web::zfcp::controllers, - crate::storage::web::zfcp::activate_controller, - crate::storage::web::zfcp::activate_disk, - crate::storage::web::zfcp::deactivate_disk, - crate::storage::web::zfcp::get_disks, - crate::storage::web::zfcp::get_wwpns, - crate::storage::web::zfcp::get_luns, - crate::users::web::get_root_config, - crate::users::web::get_user_config, - crate::users::web::patch_root, - crate::users::web::remove_first_user, - crate::users::web::set_first_user, - super::http::ping - ), - components( - schemas(agama_lib::manager::InstallationPhase), - schemas(agama_lib::network::settings::NetworkConnection), - schemas(agama_lib::network::types::DeviceType), - schemas(agama_lib::product::Product), - schemas(agama_lib::software::Pattern), - schemas(agama_lib::storage::model::Action), - schemas(agama_lib::storage::model::BlockDevice), - schemas(agama_lib::storage::model::Component), - schemas(agama_lib::storage::model::Device), - schemas(agama_lib::storage::model::DeviceInfo), - schemas(agama_lib::storage::model::DeviceSid), - schemas(agama_lib::storage::model::Drive), - schemas(agama_lib::storage::model::DriveInfo), - schemas(agama_lib::storage::model::DeviceSize), - schemas(agama_lib::storage::model::Filesystem), - schemas(agama_lib::storage::model::LvmLv), - schemas(agama_lib::storage::model::LvmVg), - schemas(agama_lib::storage::model::Md), - schemas(agama_lib::storage::model::Multipath), - schemas(agama_lib::storage::model::Partition), - schemas(agama_lib::storage::model::PartitionTable), - schemas(agama_lib::storage::model::ProposalSettings), - schemas(agama_lib::storage::model::ProposalSettingsPatch), - schemas(agama_lib::storage::model::ProposalTarget), - schemas(agama_lib::storage::model::Raid), - schemas(agama_lib::storage::model::SpaceAction), - schemas(agama_lib::storage::model::SpaceActionSettings), - schemas(agama_lib::storage::model::UnusedSlot), - schemas(agama_lib::storage::model::Volume), - schemas(agama_lib::storage::model::VolumeOutline), - schemas(agama_lib::storage::model::VolumeTarget), - schemas(agama_lib::storage::model::dasd::DASDDevice), - schemas(agama_lib::storage::model::dasd::DASDFormatSummary), - schemas(agama_lib::storage::model::zfcp::ZFCPDisk), - schemas(agama_lib::storage::model::zfcp::ZFCPController), - schemas(agama_lib::storage::client::iscsi::ISCSIAuth), - schemas(agama_lib::storage::client::iscsi::ISCSIInitiator), - schemas(agama_lib::storage::client::iscsi::ISCSINode), - schemas(agama_lib::storage::client::iscsi::LoginResult), - schemas(agama_lib::users::FirstUser), - schemas(crate::l10n::Keymap), - schemas(crate::l10n::LocaleEntry), - schemas(crate::l10n::TimezoneEntry), - schemas(agama_lib::localization::model::LocaleConfig), - schemas(crate::manager::web::InstallerStatus), - schemas(crate::network::model::Connection), - schemas(crate::network::model::Device), - schemas(agama_lib::questions::model::Answer), - schemas(agama_lib::questions::model::GenericAnswer), - schemas(agama_lib::questions::model::GenericQuestion), - schemas(agama_lib::questions::model::PasswordAnswer), - schemas(agama_lib::questions::model::Question), - schemas(agama_lib::questions::model::QuestionWithPassword), - schemas(agama_lib::software::model::SoftwareConfig), - schemas(crate::software::web::SoftwareProposal), - schemas(crate::storage::web::ProductParams), - schemas(crate::storage::web::iscsi::DiscoverParams), - schemas(crate::storage::web::iscsi::InitiatorParams), - schemas(crate::storage::web::iscsi::LoginParams), - schemas(crate::storage::web::iscsi::NodeParams), - schemas(agama_lib::users::model::RootConfig), - schemas(agama_lib::users::model::RootPatchSettings), - schemas(super::http::PingResponse) - ) -)] -pub struct ApiDoc; +use utoipa::openapi::{Components, InfoBuilder, OpenApiBuilder, Paths}; + +mod network; +pub use network::NetworkApiDocBuilder; +mod storage; +pub use storage::StorageApiDocBuilder; +mod software; +pub use software::SoftwareApiDocBuilder; +mod l10n; +pub use l10n::L10nApiDocBuilder; +mod questions; +pub use questions::QuestionsApiDocBuilder; +mod manager; +pub use manager::ManagerApiDocBuilder; +mod users; +pub use users::UsersApiDocBuilder; +mod misc; +pub use misc::MiscApiDocBuilder; + +pub trait ApiDocBuilder { + fn title(&self) -> String { + "Agama HTTP API".to_string() + } + + fn paths(&self) -> Paths; + + fn components(&self) -> Components; + + fn build(&self) -> utoipa::openapi::OpenApi { + let info = InfoBuilder::new() + .title(self.title()) + .version("0.1.0") + .build(); + + OpenApiBuilder::new() + .info(info) + .paths(self.paths()) + .components(Some(self.components())) + .build() + } +} diff --git a/rust/agama-server/src/web/docs/l10n.rs b/rust/agama-server/src/web/docs/l10n.rs new file mode 100644 index 0000000000..a6a17ead4f --- /dev/null +++ b/rust/agama-server/src/web/docs/l10n.rs @@ -0,0 +1,32 @@ +use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct L10nApiDocBuilder; + +impl ApiDocBuilder for L10nApiDocBuilder { + fn title(&self) -> String { + "Localization HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .build() + } +} diff --git a/rust/agama-server/src/web/docs/manager.rs b/rust/agama-server/src/web/docs/manager.rs new file mode 100644 index 0000000000..7df91f2803 --- /dev/null +++ b/rust/agama-server/src/web/docs/manager.rs @@ -0,0 +1,27 @@ +use utoipa::openapi::{ComponentsBuilder, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct ManagerApiDocBuilder; + +impl ApiDocBuilder for ManagerApiDocBuilder { + fn title(&self) -> String { + "Manager HTTP API".to_string() + } + + fn paths(&self) -> utoipa::openapi::Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> utoipa::openapi::Components { + ComponentsBuilder::new() + .schema_from::() + .schema_from::() + .build() + } +} diff --git a/rust/agama-server/src/web/docs/misc.rs b/rust/agama-server/src/web/docs/misc.rs new file mode 100644 index 0000000000..f788dccfeb --- /dev/null +++ b/rust/agama-server/src/web/docs/misc.rs @@ -0,0 +1,23 @@ +use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct MiscApiDocBuilder; + +impl ApiDocBuilder for MiscApiDocBuilder { + fn title(&self) -> String { + "Miscelaneous HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .build() + } +} diff --git a/rust/agama-server/src/web/docs/network.rs b/rust/agama-server/src/web/docs/network.rs new file mode 100644 index 0000000000..23ad3de3cb --- /dev/null +++ b/rust/agama-server/src/web/docs/network.rs @@ -0,0 +1,101 @@ +use serde_json::json; +use utoipa::openapi::{Components, ComponentsBuilder, ObjectBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct NetworkApiDocBuilder; + +impl ApiDocBuilder for NetworkApiDocBuilder { + fn title(&self) -> String { + "Network HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema( + "IpAddr", + ObjectBuilder::new() + .schema_type(utoipa::openapi::SchemaType::String) + .description(Some("An IP address (IPv4 or IPv6)".to_string())) + .example(Some(json!("192.168.1.100"))) + .build(), + ) + .schema( + "IpInet", + ObjectBuilder::new() + .schema_type(utoipa::openapi::SchemaType::String) + .description(Some( + "An IP address (IPv4 or IPv6) including the prefix".to_string(), + )) + .example(Some(json!("192.168.1.254/24"))) + .build(), + ) + .schema( + "macaddr.MacAddr6", + ObjectBuilder::new() + .schema_type(utoipa::openapi::SchemaType::String) + .description(Some("MAC address in EUI-48 format".to_string())) + .build(), + ) + .build() + } +} diff --git a/rust/agama-server/src/web/docs/questions.rs b/rust/agama-server/src/web/docs/questions.rs new file mode 100644 index 0000000000..b69d30b585 --- /dev/null +++ b/rust/agama-server/src/web/docs/questions.rs @@ -0,0 +1,31 @@ +use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct QuestionsApiDocBuilder; + +impl ApiDocBuilder for QuestionsApiDocBuilder { + fn title(&self) -> String { + "Questions HTTP API".to_string() + } + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .build() + } +} diff --git a/rust/agama-server/src/web/docs/software.rs b/rust/agama-server/src/web/docs/software.rs new file mode 100644 index 0000000000..2fe9da2790 --- /dev/null +++ b/rust/agama-server/src/web/docs/software.rs @@ -0,0 +1,35 @@ +use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct SoftwareApiDocBuilder; + +impl ApiDocBuilder for SoftwareApiDocBuilder { + fn title(&self) -> String { + "Software HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .build() + } +} diff --git a/rust/agama-server/src/web/docs/storage.rs b/rust/agama-server/src/web/docs/storage.rs new file mode 100644 index 0000000000..0d492c4057 --- /dev/null +++ b/rust/agama-server/src/web/docs/storage.rs @@ -0,0 +1,75 @@ +use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct StorageApiDocBuilder; + +impl ApiDocBuilder for StorageApiDocBuilder { + fn title(&self) -> String { + "Storage HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .build() + } +} diff --git a/rust/agama-server/src/web/docs/users.rs b/rust/agama-server/src/web/docs/users.rs new file mode 100644 index 0000000000..5826334f5c --- /dev/null +++ b/rust/agama-server/src/web/docs/users.rs @@ -0,0 +1,35 @@ +use utoipa::openapi::{ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; + +pub struct UsersApiDocBuilder; + +impl ApiDocBuilder for UsersApiDocBuilder { + fn title(&self) -> String { + "Users HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> utoipa::openapi::Components { + ComponentsBuilder::new() + .schema_from::() + .schema_from::() + .schema_from::() + .schema( + "zbus.zvariant.OwnedValue", + utoipa::openapi::ObjectBuilder::new() + .description(Some("Additional user information (unused)".to_string())) + .build(), + ) + .build() + } +} diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 9c73676acb..5e6e29a1b2 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,11 @@ +------------------------------------------------------------------- +Tue Oct 22 09:46:41 UTC 2024 - Imobach Gonzalez Sosa + +- Improve OpenAPI specification generation (gh#agama-project/agama#1564): + - Add a lot of missing elements to make the specification valid. + - Use a xtask to generate the OpenAPI specification at build time. + - Ship the specification in a separate package (agama-openapi). + ------------------------------------------------------------------- Wed Oct 16 15:07:33 UTC 2024 - Imobach Gonzalez Sosa diff --git a/rust/package/agama.spec b/rust/package/agama.spec index b260d34779..5c6655c47d 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -107,6 +107,14 @@ BuildArch: noarch %description -n agama-cli-zsh-completion Zsh command-line completion support for %{name}-cli. +%package -n agama-openapi +Summary: Agama's OpenAPI Specification + +%description -n agama-openapi +The OpenAPI Specification (OAS) allows describing an HTTP API in an standard and +language-agnostic way. This package contains the specification for Agama's HTTP +API. + %prep %autosetup -a1 -n agama # Remove exec bits to prevent an issue in fedora shebang checking. Uncomment only if required. @@ -117,6 +125,7 @@ Zsh command-line completion support for %{name}-cli. cargo run --package xtask -- manpages gzip out/man/* cargo run --package xtask -- completions +cargo run --package xtask -- openapi %install install -D -d -m 0755 %{buildroot}%{_bindir} @@ -140,6 +149,10 @@ install -Dm644 %{_builddir}/agama/out/shell/%{name}.bash %{buildroot}%{_datadir} install -Dm644 %{_builddir}/agama/out/shell/_%{name} %{buildroot}%{_datadir}/zsh/site-functions/_%{name} install -Dm644 %{_builddir}/agama/out/shell/%{name}.fish %{buildroot}%{_datadir}/fish/vendor_completions.d/%{name}.fish +# install OpenAPI specification +mkdir -p %{buildroot}%{_datadir}/agama/openapi +install -m 0644 %{_builddir}/agama/out/openapi/* %{buildroot}%{_datadir}/agama/openapi + %check PATH=$PWD/share/bin:$PATH %ifarch aarch64 @@ -188,4 +201,9 @@ echo $PATH %dir %{_datadir}/zsh %{_datadir}/zsh/* +%files -n agama-openapi +%dir %{_datadir}/agama +%dir %{_datadir}/agama/openapi +%{_datadir}/agama/openapi/*.json + %changelog diff --git a/rust/xtask/Cargo.toml b/rust/xtask/Cargo.toml index ae9740772d..e32411ebd5 100644 --- a/rust/xtask/Cargo.toml +++ b/rust/xtask/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] agama-cli = { path = "../agama-cli" } +agama-server = { path = "../agama-server" } clap = { version = "4.5.19", default-features = false } clap-markdown = "0.1.4" clap_complete = "4.5.32" diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index ac8f09dfab..b7077130d1 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -1,9 +1,14 @@ use std::{env, path::PathBuf}; mod tasks { - use std::{fs::File, io::Write}; + use std::{fs::File, io::Write, path::Path}; use agama_cli::Cli; + use agama_server::web::docs::{ + ApiDocBuilder, L10nApiDocBuilder, ManagerApiDocBuilder, MiscApiDocBuilder, + NetworkApiDocBuilder, QuestionsApiDocBuilder, SoftwareApiDocBuilder, StorageApiDocBuilder, + UsersApiDocBuilder, + }; use clap::CommandFactory; use clap_complete::aot; use clap_markdown::MarkdownOptions; @@ -19,7 +24,7 @@ mod tasks { clap_complete::generate_to(aot::Fish, &mut cmd, "agama", &out_dir)?; clap_complete::generate_to(aot::Zsh, &mut cmd, "agama", &out_dir)?; - println!("Generate shell completions at {}", out_dir.display()); + println!("Generate shell completions at {}.", out_dir.display()); Ok(()) } @@ -36,7 +41,7 @@ mod tasks { let mut file = File::create(&filename)?; file.write_all(markdown.as_bytes())?; - println!("Generate Markdown documentation at {}", filename.display()); + println!("Generate Markdown documentation at {}.", filename.display()); Ok(()) } @@ -47,7 +52,36 @@ mod tasks { let cmd = Cli::command(); clap_mangen::generate_to(cmd, &out_dir)?; - println!("Generate manpages documentation at {}", out_dir.display()); + println!("Generate manpages documentation at {}.", out_dir.display()); + Ok(()) + } + + /// Generate Agama's OpenAPI specification. + pub fn generate_openapi() -> std::io::Result<()> { + let out_dir = create_output_dir("openapi")?; + + write_openapi(L10nApiDocBuilder {}, out_dir.join("l10n.json"))?; + write_openapi(ManagerApiDocBuilder {}, out_dir.join("manager.json"))?; + write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; + write_openapi(SoftwareApiDocBuilder {}, out_dir.join("software.json"))?; + write_openapi(StorageApiDocBuilder {}, out_dir.join("storage.json"))?; + write_openapi(UsersApiDocBuilder {}, out_dir.join("users.json"))?; + write_openapi(QuestionsApiDocBuilder {}, out_dir.join("questions.json"))?; + write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; + println!( + "Generate the OpenAPI specification at {}.", + out_dir.display() + ); + Ok(()) + } + + fn write_openapi>(builder: T, path: P) -> std::io::Result<()> + where + T: ApiDocBuilder, + { + let openapi = builder.build().to_pretty_json()?; + let mut file = File::create(path)?; + file.write_all(openapi.as_bytes())?; Ok(()) } } @@ -71,6 +105,7 @@ fn main() -> std::io::Result<()> { "completions" => tasks::generate_completions(), "markdown" => tasks::generate_markdown(), "manpages" => tasks::generate_manpages(), + "openapi" => tasks::generate_openapi(), other => { eprintln!("Unknown task '{}'", other); std::process::exit(1);