diff --git a/Cargo.lock b/Cargo.lock index 370f8793..0a743d73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3149,6 +3149,7 @@ dependencies = [ "serde", "thiserror 2.0.11", "utoipa", + "utoipa-actix-web", "utoipa-swagger-ui", "xhandler", ] @@ -3193,6 +3194,16 @@ dependencies = [ "tera", ] +[[package]] +name = "openubl-xtask" +version = "0.1.1" +dependencies = [ + "anyhow", + "clap", + "openubl-server", + "tokio", +] + [[package]] name = "ordered-float" version = "3.9.2" @@ -4423,6 +4434,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial_test" version = "3.2.0" @@ -5422,6 +5446,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.7.1" @@ -5466,9 +5496,21 @@ dependencies = [ "indexmap", "serde", "serde_json", + "serde_yaml", "utoipa-gen", ] +[[package]] +name = "utoipa-actix-web" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f" +dependencies = [ + "actix-service", + "actix-web", + "utoipa", +] + [[package]] name = "utoipa-gen" version = "5.3.1" diff --git a/Cargo.toml b/Cargo.toml index d2f98954..b799f3bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "server/server", "server/cli", "server/signature", + "server/xtask", "server/ui/crate", ] @@ -73,6 +74,7 @@ aws-smithy-runtime-api = "1.7" async-trait = "0.1" env_logger = "0.11" utoipa = "5.3" +utoipa-actix-web = "0.1" utoipa-swagger-ui = "9.0" actix-web = "4.9" actix-web-httpauth = "0.8" diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 00000000..8864d05d --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,152 @@ +openapi: 3.1.0 +info: + title: Openubl + description: Enviar archivos XML a la SUNAT API + license: + name: Apache License, Version 2.0 + identifier: Apache-2.0 + version: 0.1.1 +paths: + /api/credentials: + get: + operationId: list_credentials + responses: + '200': + description: List credentials + post: + operationId: create_credentials + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NewCredentialsDto' + required: true + responses: + '200': + description: Create credentials + /api/credentials/{credentials_id}: + get: + operationId: get_credentials + parameters: + - name: credentials_id + in: path + required: true + schema: + type: integer + format: int32 + responses: + '200': + description: Get credential + put: + operationId: update_credentials + parameters: + - name: credentials_id + in: path + required: true + schema: + type: integer + format: int32 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NewCredentialsDto' + required: true + responses: + '204': + description: Update credentials + delete: + operationId: delete_credentials + parameters: + - name: credentials_id + in: path + required: true + schema: + type: integer + format: int32 + responses: + '204': + description: Delete credentials + /api/documents: + get: + operationId: list_documents + responses: + '200': + description: List documents + /api/documents/{document_id}/download: + get: + operationId: get_document_file + parameters: + - name: document_id + in: path + required: true + schema: + type: integer + format: int32 + responses: + '200': + description: Get document's file + /api/documents/{document_id}/send: + post: + operationId: send_document + parameters: + - name: document_id + in: path + required: true + schema: + type: integer + format: int32 + responses: + '200': + description: Get document's file + /q/health/live: + get: + operationId: liveness + responses: + '200': + description: Liveness + /q/health/read: + get: + operationId: readiness + responses: + '200': + description: Readiness +components: + schemas: + NewCredentialsDto: + type: object + required: + - name + - username_sol + - password_sol + - client_id + - client_secret + - url_invoice + - url_despatch + - url_perception_retention + - supplier_ids_applied_to + properties: + client_id: + type: string + client_secret: + type: string + description: + type: + - string + - 'null' + name: + type: string + password_sol: + type: string + supplier_ids_applied_to: + type: array + items: + type: string + url_despatch: + type: string + url_invoice: + type: string + url_perception_retention: + type: string + username_sol: + type: string diff --git a/server/server/Cargo.toml b/server/server/Cargo.toml index e788c59a..da9143e2 100644 --- a/server/server/Cargo.toml +++ b/server/server/Cargo.toml @@ -26,7 +26,8 @@ clap = { workspace = true, features = ["derive", "env"] } anyhow = { workspace = true } env_logger = { workspace = true } thiserror = { workspace = true } -utoipa = { workspace = true, features = ["actix_extras"] } +utoipa = { workspace = true, features = ["actix_extras", "yaml"] } +utoipa-actix-web = { workspace = true } utoipa-swagger-ui = { workspace = true, features = ["actix-web"] } actix-web-httpauth = { workspace = true } actix-4-jwt-auth = { workspace = true } diff --git a/server/server/src/dto.rs b/server/server/src/dto.rs index 18e8a2e1..06939556 100644 --- a/server/server/src/dto.rs +++ b/server/server/src/dto.rs @@ -2,7 +2,7 @@ use openubl_entity as entity; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct DocumentDto { pub id: i32, pub supplier_id: String, diff --git a/server/server/src/lib.rs b/server/server/src/lib.rs index 947f42ec..f36b68ad 100644 --- a/server/server/src/lib.rs +++ b/server/server/src/lib.rs @@ -7,9 +7,13 @@ use actix_multipart::form::tempfile::TempFileConfig; use actix_web::middleware::Logger; use actix_web::{web, App, HttpServer}; +use openapi::ApiDoc; use openubl_api::system::InnerSystem; use openubl_common::config::Database; use openubl_storage::StorageSystem; +use utoipa::OpenApi; +use utoipa_actix_web::AppExt; +use utoipa_swagger_ui::SwaggerUi; use crate::server::credentials::{ create_credentials, delete_credentials, get_credentials, list_credentials, update_credentials, @@ -20,6 +24,7 @@ use actix_web_static_files::{deps::static_files::Resource, ResourceFiles}; use openubl_ui::{openubl_ui, UI}; mod dto; +pub mod openapi; pub mod server; pub struct UiResources { @@ -89,11 +94,25 @@ impl ServerRun { let app_state = Arc::new(AppState { system, storage }); HttpServer::new(move || { - App::new() - .app_data(web::Data::from(app_state.clone())) + let (app, api) = App::new() .wrap(Logger::default()) + .into_utoipa_app() + // + .openapi(ApiDoc::openapi()) + // + .app_data(web::Data::from(app_state.clone())) .app_data(TempFileConfig::default()) - .configure(configure) + // q + .service(utoipa_actix_web::scope("/q").configure(configure_q)) + // API + .service(utoipa_actix_web::scope("/api").configure(configure_api)) + .split_for_parts(); + + app + // Swagger + .service(SwaggerUi::new("/swagger-ui/{_:.*}").url("/openapi.json", api)) + .service(web::redirect("/swagger-ui", "/swagger-ui/")) + // UI .service(ResourceFiles::new("/", ui.resources()).resolve_not_found_to("")) }) .bind(self.bind_addr)? @@ -109,11 +128,13 @@ pub struct AppState { pub storage: StorageSystem, } -pub fn configure(config: &mut web::ServiceConfig) { +pub fn configure_q(config: &mut utoipa_actix_web::service_config::ServiceConfig) { // Health config.service(health::liveness); config.service(health::readiness); +} +pub fn configure_api(config: &mut utoipa_actix_web::service_config::ServiceConfig) { // Documents config.service(list_documents); config.service(get_document_file); diff --git a/server/server/src/openapi.rs b/server/server/src/openapi.rs new file mode 100644 index 00000000..51545e2c --- /dev/null +++ b/server/server/src/openapi.rs @@ -0,0 +1,35 @@ +use actix_web::App; +use utoipa::{ + openapi::{Info, License}, + OpenApi, +}; +use utoipa_actix_web::AppExt; + +use crate::{configure_api, configure_q}; + +#[derive(OpenApi)] +#[openapi()] +pub struct ApiDoc; + +pub fn default_openapi_info() -> Info { + let mut info = Info::new("Openubl", env!("CARGO_PKG_VERSION")); + info.description = Some("Enviar archivos XML a la SUNAT API".into()); + info.license = { + let mut license = License::new("Apache License, Version 2.0"); + license.identifier = Some("Apache-2.0".into()); + Some(license) + }; + info +} + +pub async fn create_openapi() -> anyhow::Result { + let (_, mut openapi) = App::new() + .into_utoipa_app() + .service(utoipa_actix_web::scope("/q").configure(configure_q)) + .service(utoipa_actix_web::scope("/api").configure(configure_api)) + .split_for_parts(); + + openapi.info = default_openapi_info(); + + Ok(openapi) +} diff --git a/server/xtask/Cargo.toml b/server/xtask/Cargo.toml new file mode 100644 index 00000000..f599ec7d --- /dev/null +++ b/server/xtask/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "openubl-xtask" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "xtask" +path = "src/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +openubl-server = { workspace = true } + +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +tokio = { workspace = true, features = ["full"] } diff --git a/server/xtask/src/main.rs b/server/xtask/src/main.rs new file mode 100644 index 00000000..2759dc80 --- /dev/null +++ b/server/xtask/src/main.rs @@ -0,0 +1,28 @@ +use clap::{Parser, Subcommand}; + +mod openapi; + +#[derive(Debug, Parser)] +pub struct Xtask { + #[command(subcommand)] + command: Command, +} + +impl Xtask { + pub async fn run(self) -> anyhow::Result<()> { + match self.command { + Command::Openapi(command) => command.run().await, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Used to generate and/or validate the openapi spec + Openapi(openapi::Openapi), +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + Xtask::parse().run().await +} diff --git a/server/xtask/src/openapi.rs b/server/xtask/src/openapi.rs new file mode 100644 index 00000000..eb6de9e8 --- /dev/null +++ b/server/xtask/src/openapi.rs @@ -0,0 +1,102 @@ +use anyhow::{anyhow, Context}; +use clap::Parser; +use openubl_server::openapi::create_openapi; +use std::env::current_dir; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +#[derive(Debug, Parser, Default)] +pub struct Openapi { + /// skip generating the openapi file? + #[arg(long, default_value = "false")] + no_generate: bool, + + /// skip validating the openapi file?? + #[arg(long, default_value = "false")] + validate: bool, +} + +impl Openapi { + pub async fn run(self) -> anyhow::Result<()> { + if !self.no_generate { + generate_openapi(None).await?; + } + + if self.validate { + validate()?; + } + + Ok(()) + } +} + +fn command_exists(cmd: &str) -> bool { + match Command::new("which").arg(cmd).output() { + Ok(output) => output.status.success(), + Err(_) => false, + } +} + +pub async fn generate_openapi(base: Option<&Path>) -> anyhow::Result<()> { + let mut path = PathBuf::from("openapi.yaml"); + if let Some(base) = base { + path = base.join(path); + } + + // create spec from actual server + + println!("Building openapi"); + + let doc = create_openapi() + .await? + .to_yaml() + .context("Failed to convert openapi spec to yaml")?; + + // write + + println!("Writing openapi to {:?}", &path); + + fs::write(path, doc).context("Failed to write openapi spec")?; + + Ok(()) +} + +pub fn validate() -> anyhow::Result<()> { + let command = if command_exists("podman") { + "podman" + } else if command_exists("docker") { + "docker" + } else { + return Err(anyhow!( + "This openapi validation requires podman or docker to be installed." + )); + }; + + println!("Validating openapi at {:?}", "openapi.yaml"); + + // run the openapi validator container + if Command::new(command) + .args([ + "run", + "--rm", + "-v", + format!("{}:/src", current_dir()?.to_str().unwrap()).as_str(), + "--security-opt", + "label=disable", + "docker.io/openapitools/openapi-generator-cli:v7.7.0", + "validate", + "-i", + "/src/openapi.yaml", + ]) + .status() + .map_err(|_| anyhow!("Failed to validate openapi.yaml"))? + .success() + { + Ok(()) + } else { + Err(anyhow!("Failed to validate openapi.yaml")) + } +}