From e000145f3cc4afeba3eb3a8fb1dc0db80e003c60 Mon Sep 17 00:00:00 2001 From: "Ch.-David Blot" Date: Thu, 23 Apr 2026 15:55:44 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=A6=EF=B8=8F=20package:=20split=20?= =?UTF-8?q?build=20into=20a=20spearated=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 5 + Cargo.toml | 96 +++++++-- build.rs | 38 ---- config/oks/cfg.yaml | 4 +- config/osc/cfg.yaml | 4 +- crates/sdk-build/Cargo.toml | 9 + crates/sdk-build/build.rs | 214 +++++++++++++++++++ crates/sdk-build/src/lib.rs | 22 ++ crates/sdk/Cargo.toml | 40 ++++ crates/sdk/build.rs | 38 ++++ {src => crates/sdk/src}/basicauth/config.rs | 0 {src => crates/sdk/src}/basicauth/layer.rs | 0 {src => crates/sdk/src}/basicauth/mod.rs | 0 {src => crates/sdk/src}/basicauth/service.rs | 0 {src => crates/sdk/src}/basicauth/signer.rs | 0 {src => crates/sdk/src}/errors.rs | 0 {src => crates/sdk/src}/lib.rs | 0 {src => crates/sdk/src}/oks.rs | 0 {src => crates/sdk/src}/osc.rs | 0 {src => crates/sdk/src}/policy.rs | 0 {src => crates/sdk/src}/profile.rs | 0 {src => crates/sdk/src}/signv4/config.rs | 0 {src => crates/sdk/src}/signv4/layer.rs | 0 {src => crates/sdk/src}/signv4/mod.rs | 0 {src => crates/sdk/src}/signv4/service.rs | 0 {src => crates/sdk/src}/signv4/signer.rs | 0 {src => crates/sdk/src}/transport.rs | 0 go.mod => hacks/generator/go.mod | 0 go.sum => hacks/generator/go.sum | 0 hacks/generator/main.go | 43 ++-- hacks/generator/schema.go | 12 +- hacks/generator/templates/operations.tmpl | 4 + 32 files changed, 442 insertions(+), 87 deletions(-) delete mode 100644 build.rs create mode 100644 crates/sdk-build/Cargo.toml create mode 100644 crates/sdk-build/build.rs create mode 100644 crates/sdk-build/src/lib.rs create mode 100644 crates/sdk/Cargo.toml create mode 100644 crates/sdk/build.rs rename {src => crates/sdk/src}/basicauth/config.rs (100%) rename {src => crates/sdk/src}/basicauth/layer.rs (100%) rename {src => crates/sdk/src}/basicauth/mod.rs (100%) rename {src => crates/sdk/src}/basicauth/service.rs (100%) rename {src => crates/sdk/src}/basicauth/signer.rs (100%) rename {src => crates/sdk/src}/errors.rs (100%) rename {src => crates/sdk/src}/lib.rs (100%) rename {src => crates/sdk/src}/oks.rs (100%) rename {src => crates/sdk/src}/osc.rs (100%) rename {src => crates/sdk/src}/policy.rs (100%) rename {src => crates/sdk/src}/profile.rs (100%) rename {src => crates/sdk/src}/signv4/config.rs (100%) rename {src => crates/sdk/src}/signv4/layer.rs (100%) rename {src => crates/sdk/src}/signv4/mod.rs (100%) rename {src => crates/sdk/src}/signv4/service.rs (100%) rename {src => crates/sdk/src}/signv4/signer.rs (100%) rename {src => crates/sdk/src}/transport.rs (100%) rename go.mod => hacks/generator/go.mod (100%) rename go.sum => hacks/generator/go.sum (100%) diff --git a/Cargo.lock b/Cargo.lock index 07368ab5..2502ab6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,6 +1123,7 @@ dependencies = [ "hmac", "home", "log", + "osc-sdk-rust-build", "reqwest", "secrecy", "serde", @@ -1135,6 +1136,10 @@ dependencies = [ "tower", ] +[[package]] +name = "osc-sdk-rust-build" +version = "2.0.0-alpha.0" + [[package]] name = "parking_lot" version = "0.12.5" diff --git a/Cargo.toml b/Cargo.toml index 482f5aa8..8cd5c6aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,16 @@ -[package] -name = "osc-sdk-rust" -version = "2.0.0-alpha.0" +[workspace] +resolver = "3" +members = ["crates/sdk-build", "crates/sdk"] + +[workspace.package] edition = "2024" authors = ["Outscale SAS "] license = "BSD-3-Clause" -description = "Outscale API SDK" repository = "https://github.com/outscale/osc-sdk-rust/" +version = "2.0.0-alpha.0" -[dependencies] +[workspace.dependencies] +# SDK dependencies chrono = { version = "0.4.43", features = ["serde"] } futures = "0.3.32" hmac = "0.12.1" @@ -20,24 +23,75 @@ serde_urlencoded = "0.7.1" sha2 = "0.10.9" tokio = "1" tower = { version = "0.5.3", features = ["limit", "retry", "util", "buffer"] } +base64 = "0.22" +reqwest = { version = "^0.13", features = ["json", "http2", "system-proxy", "charset"], default-features = false } -[dependencies.base64] -version = "0.22" - -[dependencies.reqwest] -version = "^0.13" -features = ["json", "http2", "system-proxy", "charset"] -default-features = false - -[dev-dependencies] -tokio = { version = "1", features = ["full", "tracing"] } +# Dev dependencies env_logger = "*" tempfile = "3" serial_test = "3" -[features] -default = ["default-tls", "osc", "oks"] -native-tls = ["reqwest/native-tls"] -default-tls = ["reqwest/default-tls"] -osc = [] -oks = [] +# Internal dependencies +osc-sdk-rust-build = { path = "crates/sdk-build" } + +# https://emschwartz.me/your-clippy-config-should-be-stricter/ +[workspace.lints.clippy] +# Don't Panic - prevent panics from unwraps and unsafe slicing or indexing +string_slice = "warn" +indexing_slicing = "warn" +unwrap_used = "warn" +panic = "warn" +todo = "warn" +unimplemented = "warn" +unreachable = "warn" +get_unwrap = "warn" +unwrap_in_result = "warn" +unchecked_time_subtraction = "warn" +panic_in_result_fn = "warn" +# Optional - see post for caveats +# expect_used = "warn" +# arithmetic_side_effects = "warn" + +# Don't Fail Silently - prevent dropped futures and swallowed errors +let_underscore_future = "warn" +let_underscore_must_use = "warn" +unused_result_ok = "warn" +map_err_ignore = "warn" +assertions_on_result_states = "warn" + +# Don't Do Bad Async Stuff - prevent deadlocks and concurrency bugs +await_holding_lock = "warn" +await_holding_refcell_ref = "warn" +if_let_mutex = "warn" # only relevant on editions before 2024 +large_futures = "warn" + +# Don't Do Unsafe Things with Memory +mem_forget = "warn" +undocumented_unsafe_blocks = "warn" +multiple_unsafe_ops_per_block = "warn" +unnecessary_safety_doc = "warn" +unnecessary_safety_comment = "warn" + +# Don't Do Potentially Incorrect Things with Numbers +float_cmp = "warn" +float_cmp_const = "warn" +lossy_float_literal = "warn" +cast_sign_loss = "warn" +invalid_upcast_comparisons = "warn" +# Optional - these effectively force you to document numeric invariants +# cast_possible_wrap = "warn" +# cast_precision_loss = "warn" +# cast_possible_truncation = "warn" + +# Don't Do Bad Things That are Easy to Avoid +rc_mutex = "warn" +debug_assert_with_mut_call = "warn" +iter_not_returning_iterator = "warn" +expl_impl_clone_on_copy = "warn" +infallible_try_from = "warn" +dbg_macro = "warn" + +# Don't `allow` Your Way Around These Lints - every suppression must be +# a deliberate #[expect(..., reason = "…")] rather than a silent #[allow] +allow_attributes = "warn" +allow_attributes_without_reason = "warn" diff --git a/build.rs b/build.rs deleted file mode 100644 index c31594ca..00000000 --- a/build.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::env; -use std::path::Path; -use std::process::Command; - -fn run_generator(target: &str) { - if env::var_os(format!("CARGO_FEATURE_{}", target.to_uppercase())).is_none() { - return; - } - - let out_dir = env::var_os("OUT_DIR").unwrap(); - let dest_path = Path::new(&out_dir).join(format!("{}.rs", target)); - - Command::new("go") - .args([ - "run", - "./hacks/generator/", - "--config", - &format!("./config/{}/cfg.yaml", target), - "--output", - dest_path.to_str().unwrap(), - ]) - .output() - .unwrap(); - - Command::new("rustfmt") - .args(["--edition", "2024", dest_path.to_str().unwrap()]) - .output() - .unwrap(); -} - -fn main() { - println!("cargo::rerun-if-changed=hacks/generator"); - println!("cargo::rerun-if-changed=build.rs"); - println!("cargo::rerun-if-changed=config"); - - run_generator("osc"); - run_generator("oks"); -} diff --git a/config/oks/cfg.yaml b/config/oks/cfg.yaml index 5d40113c..e34f446b 100644 --- a/config/oks/cfg.yaml +++ b/config/oks/cfg.yaml @@ -1,2 +1,2 @@ -spec: ./config/oks/api.yaml -overlay: ./config/oks/patch.yaml +spec: ./api.yaml +overlay: ./patch.yaml diff --git a/config/osc/cfg.yaml b/config/osc/cfg.yaml index 1659bdd9..e34f446b 100644 --- a/config/osc/cfg.yaml +++ b/config/osc/cfg.yaml @@ -1,2 +1,2 @@ -spec: ./config/osc/api.yaml -overlay: ./config/osc/patch.yaml +spec: ./api.yaml +overlay: ./patch.yaml diff --git a/crates/sdk-build/Cargo.toml b/crates/sdk-build/Cargo.toml new file mode 100644 index 00000000..740e280f --- /dev/null +++ b/crates/sdk-build/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "osc-sdk-rust-build" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Build-time helper to obtain and run code generator" + diff --git a/crates/sdk-build/build.rs b/crates/sdk-build/build.rs new file mode 100644 index 00000000..096022ae --- /dev/null +++ b/crates/sdk-build/build.rs @@ -0,0 +1,214 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const GENERATOR_NAME: &str = "generator"; +const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION"); +const GITHUB_REPO: &str = "outscale/osc-sdk-rust"; +const GO_SOURCE_DIR: &str = "../../hacks/generator"; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={GO_SOURCE_DIR}"); + println!("cargo:rerun-if-env-changed=GENERATOR_BIN"); + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let binary_name = binary_file_name(); + let final_bin = out_dir.join(&binary_name); + + // 0. User-provided generator binary path + if let Ok(generator_bin) = env::var("GENERATOR_BIN") { + let p = PathBuf::from(&generator_bin); + if p.exists() { + eprintln!("[build.rs] Using user-provided binary: {}", p.display()); + emit_env(&p); + return; + } else { + eprintln!( + "[build.rs] user-provided binary {} does not exist. falling back.", + p.display() + ); + } + } + + // 1. Try downloading a pre-built binary from GitHub Releases + if try_download_from_github(&final_bin) { + eprintln!("[build.rs] Downloaded pre-built binary from GitHub."); + emit_env(&final_bin); + return; + } + + // 2. Build the generator from source + if try_build_from_source(&final_bin) { + eprintln!("[build.rs] Built generator from source."); + emit_env(&final_bin); + return; + } + + // 3. Nothing worked + panic!( + "\n\n\ + ============================================================\n\ + ERROR: Could not obtain the `{GENERATOR_NAME}` binary.\n\n\ + Tried (in order):\n\ + 1. $GENERATOR_BIN environment variable\n\ + 2. GitHub release download ({repo} {ver})\n\ + 3. Building from Go source in ./{src}\n\n\ + Options:\n\ + • Install Go (https://go.dev/dl/) so we can build it.\n\ + • Manually download the binary and set GENERATOR_BIN=/path/to/it\n\ + • Check your internet connection for the GitHub download.\n\ + ============================================================\n\n", + repo = GITHUB_REPO, + ver = GENERATOR_VERSION, + src = GO_SOURCE_DIR, + ); +} + +fn emit_env(bin_path: &Path) { + println!("cargo:rustc-env=GENERATOR_BIN_PATH={}", bin_path.display()); + eprintln!("[build.rs] Generator ready: {}", bin_path.display()); +} + +fn go_arch() -> &'static str { + match () { + _ if cfg!(target_arch = "aarch64") => "arm64", + _ if cfg!(target_arch = "x86_64") => "amd64", + _ => panic!("unsupported target architecture for Go generator"), + } +} + +fn go_os() -> &'static str { + match () { + _ if cfg!(target_os = "linux") => "linux", + _ if cfg!(target_os = "macos") => "darwin", + _ if cfg!(target_os = "windows") => "windows", + _ => panic!("unsupported target OS for Go generator"), + } +} + +fn binary_file_name() -> String { + let ext = if go_os() == "windows" { ".exe" } else { "" }; + format!( + "{GENERATOR_NAME}-{GENERATOR_VERSION}-{}-{}{ext}", + go_os(), + go_arch() + ) +} + +fn try_download_from_github(dest: &Path) -> bool { + let file = binary_file_name(); + let url = + format!("https://github.com/{GITHUB_REPO}/releases/download/{GENERATOR_VERSION}/{file}"); + + eprintln!("[build.rs] Attempting download: {url}"); + + if try_download_curl(&url, dest) { + return true; + } + + // Try wget as fallback + if try_download_wget(&url, dest) { + return true; + } + + eprintln!("[build.rs] All download methods failed."); + false +} + +fn try_download_curl(url: &str, dest: &Path) -> bool { + Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--location", + "--output", + &dest.to_string_lossy(), + url, + ]) + .status() + .map(|s| { + if s.success() { + true + } else { + // Clean up partial file + let _ = fs::remove_file(dest); + false + } + }) + .unwrap_or(false) +} + +fn try_download_wget(url: &str, dest: &Path) -> bool { + Command::new("wget") + .args(["--quiet", "-O", &dest.to_string_lossy(), url]) + .status() + .map(|s| { + if s.success() { + true + } else { + let _ = fs::remove_file(dest); + false + } + }) + .unwrap_or(false) +} + +fn try_build_from_source(dest: &Path) -> bool { + let go_version = Command::new("go").arg("version").output(); + match go_version { + Ok(output) if output.status.success() => { + eprintln!( + "[build.rs] Found Go: {}", + String::from_utf8_lossy(&output.stdout).trim() + ); + } + _ => { + eprintln!("[build.rs] `go` not found on PATH, skipping source build."); + return false; + } + } + + let source_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join(GO_SOURCE_DIR); + if !source_dir.join("go.mod").exists() { + eprintln!( + "[build.rs] No go.mod found in {}, skipping source build.", + source_dir.display() + ); + return false; + } + + eprintln!( + "[build.rs] Building Go source in {} → {}", + source_dir.display(), + dest.display() + ); + + let status = Command::new("go") + .arg("build") + .arg("-trimpath") + .args(["-ldflags", "-s -w"]) + .arg("-o") + .arg(dest) + .arg(".") + .current_dir(&source_dir) + .env("CGO_ENABLED", "0") + .env("GOOS", go_os()) + .env("GOARCH", go_arch()) + .status(); + + match status { + Ok(s) if s.success() => true, + Ok(s) => { + eprintln!("[build.rs] `go build` exited with status: {s}"); + let _ = fs::remove_file(dest); + false + } + Err(e) => { + eprintln!("[build.rs] `go build` failed to execute: {e}"); + false + } + } +} diff --git a/crates/sdk-build/src/lib.rs b/crates/sdk-build/src/lib.rs new file mode 100644 index 00000000..a3ea2545 --- /dev/null +++ b/crates/sdk-build/src/lib.rs @@ -0,0 +1,22 @@ +use std::path::Path; +use std::process::Command; + +const BIN_PATH: &str = env!("GENERATOR_BIN_PATH"); + +pub fn binary_path() -> &'static Path { + Path::new(BIN_PATH) +} + +pub fn run_or_panic(args: &[&str]) { + let output = Command::new(BIN_PATH) + .args(args) + .output() + .expect("Failed to execute generator"); + if !output.status.success() { + panic!( + "Generator failed (exit {}):\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml new file mode 100644 index 00000000..14a47a58 --- /dev/null +++ b/crates/sdk/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "osc-sdk-rust" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Outscale API SDK" + +[dependencies] +chrono.workspace = true +futures.workspace = true +hmac.workspace = true +home.workspace = true +log.workspace = true +secrecy.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_urlencoded.workspace = true +sha2.workspace = true +tokio.workspace = true +tower.workspace = true +base64.workspace = true +reqwest.workspace = true + +[dev-dependencies] +tokio = { version = "1", features = ["full", "tracing"] } +env_logger.workspace = true +tempfile.workspace = true +serial_test.workspace = true + +[build-dependencies] +osc-sdk-rust-build.workspace = true + +[features] +default = ["default-tls", "osc", "oks"] +native-tls = ["reqwest/native-tls"] +default-tls = ["reqwest/default-tls"] +osc = [] +oks = [] diff --git a/crates/sdk/build.rs b/crates/sdk/build.rs new file mode 100644 index 00000000..2c1ea481 --- /dev/null +++ b/crates/sdk/build.rs @@ -0,0 +1,38 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn run_generator(target: &str) { + if env::var_os(format!("CARGO_FEATURE_{}", target.to_uppercase())).is_none() { + return; + } + + let out_dir = env::var_os("OUT_DIR").unwrap(); + let dest_path = PathBuf::from(&out_dir).join(format!("{}.rs", target)); + let config_path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()) + .join(format!("../../config/{}/cfg.yaml", target)); + + println!("cargo:rerun-if-changed={}", config_path.to_string_lossy()); + + osc_sdk_rust_build::run_or_panic(&[ + "--config", + config_path.to_str().unwrap(), + "--output", + dest_path.to_str().unwrap(), + ]); + + Command::new("rustfmt") + .args(["--edition", "2024", dest_path.to_str().unwrap()]) + .output() + .unwrap(); +} + +fn main() { + println!( + "cargo::rerun-if-changed={}", + osc_sdk_rust_build::binary_path().to_string_lossy() + ); + + run_generator("osc"); + run_generator("oks"); +} diff --git a/src/basicauth/config.rs b/crates/sdk/src/basicauth/config.rs similarity index 100% rename from src/basicauth/config.rs rename to crates/sdk/src/basicauth/config.rs diff --git a/src/basicauth/layer.rs b/crates/sdk/src/basicauth/layer.rs similarity index 100% rename from src/basicauth/layer.rs rename to crates/sdk/src/basicauth/layer.rs diff --git a/src/basicauth/mod.rs b/crates/sdk/src/basicauth/mod.rs similarity index 100% rename from src/basicauth/mod.rs rename to crates/sdk/src/basicauth/mod.rs diff --git a/src/basicauth/service.rs b/crates/sdk/src/basicauth/service.rs similarity index 100% rename from src/basicauth/service.rs rename to crates/sdk/src/basicauth/service.rs diff --git a/src/basicauth/signer.rs b/crates/sdk/src/basicauth/signer.rs similarity index 100% rename from src/basicauth/signer.rs rename to crates/sdk/src/basicauth/signer.rs diff --git a/src/errors.rs b/crates/sdk/src/errors.rs similarity index 100% rename from src/errors.rs rename to crates/sdk/src/errors.rs diff --git a/src/lib.rs b/crates/sdk/src/lib.rs similarity index 100% rename from src/lib.rs rename to crates/sdk/src/lib.rs diff --git a/src/oks.rs b/crates/sdk/src/oks.rs similarity index 100% rename from src/oks.rs rename to crates/sdk/src/oks.rs diff --git a/src/osc.rs b/crates/sdk/src/osc.rs similarity index 100% rename from src/osc.rs rename to crates/sdk/src/osc.rs diff --git a/src/policy.rs b/crates/sdk/src/policy.rs similarity index 100% rename from src/policy.rs rename to crates/sdk/src/policy.rs diff --git a/src/profile.rs b/crates/sdk/src/profile.rs similarity index 100% rename from src/profile.rs rename to crates/sdk/src/profile.rs diff --git a/src/signv4/config.rs b/crates/sdk/src/signv4/config.rs similarity index 100% rename from src/signv4/config.rs rename to crates/sdk/src/signv4/config.rs diff --git a/src/signv4/layer.rs b/crates/sdk/src/signv4/layer.rs similarity index 100% rename from src/signv4/layer.rs rename to crates/sdk/src/signv4/layer.rs diff --git a/src/signv4/mod.rs b/crates/sdk/src/signv4/mod.rs similarity index 100% rename from src/signv4/mod.rs rename to crates/sdk/src/signv4/mod.rs diff --git a/src/signv4/service.rs b/crates/sdk/src/signv4/service.rs similarity index 100% rename from src/signv4/service.rs rename to crates/sdk/src/signv4/service.rs diff --git a/src/signv4/signer.rs b/crates/sdk/src/signv4/signer.rs similarity index 100% rename from src/signv4/signer.rs rename to crates/sdk/src/signv4/signer.rs diff --git a/src/transport.rs b/crates/sdk/src/transport.rs similarity index 100% rename from src/transport.rs rename to crates/sdk/src/transport.rs diff --git a/go.mod b/hacks/generator/go.mod similarity index 100% rename from go.mod rename to hacks/generator/go.mod diff --git a/go.sum b/hacks/generator/go.sum similarity index 100% rename from go.sum rename to hacks/generator/go.sum diff --git a/hacks/generator/main.go b/hacks/generator/main.go index 4e769da0..c08e94b3 100644 --- a/hacks/generator/main.go +++ b/hacks/generator/main.go @@ -2,11 +2,13 @@ package main import ( "bytes" + _ "embed" "flag" "fmt" "io" "net/url" "os" + "path" "path/filepath" "strconv" "strings" @@ -18,8 +20,12 @@ import ( "gopkg.in/yaml.v3" ) +//go:embed templates/operations.tmpl +var operationsTemplate string + type Operation struct { Name string + Description string Path *PathTemplate Method string PathParameters []Parameter @@ -57,9 +63,10 @@ func AggregateOperations(spec *openapi3.T) ([]Operation, error) { for method, operation := range pathItem.Operations() { op := Operation{ - Name: normalizeOperationName(operation.OperationID, method, path), - Path: pt, - Method: strings.ToUpper(method), + Name: normalizeOperationName(operation.OperationID, method, path), + Description: formatDocComment(operation.Description), + Path: pt, + Method: strings.ToUpper(method), PathParameters: aggregateParameters( pathItem.Parameters, operation.Parameters, @@ -188,6 +195,13 @@ func ReadConfiguration(config *string) (*Configuration, error) { return nil, fmt.Errorf("error unmarshalling configuration: %w", err) } + base := path.Dir(*config) + cfg.SpecPath = path.Join(base, cfg.SpecPath) + if cfg.Overlay != nil { + overlayPath := path.Join(base, *cfg.Overlay) + cfg.Overlay = &overlayPath + } + return &cfg, nil } @@ -262,25 +276,14 @@ func LoadSwaggerWithOverlay(cfg *Configuration) (swagger *openapi3.T, err error) return swagger, nil } -// loadTemplate loads a template file from the templates directory +// loadTemplate returns the embedded template content func loadTemplate(name string) (string, error) { - // Try multiple locations for templates - possiblePaths := []string{ - filepath.Join("templates", name+".tmpl"), // relative to current directory - filepath.Join("hacks", "generator", "templates", name+".tmpl"), // from project root - filepath.Join("..", "..", "templates", name+".tmpl"), // relative from build output - } - - var lastErr error - for _, templatePath := range possiblePaths { - content, err := os.ReadFile(templatePath) - if err == nil { - return string(content), nil - } - lastErr = err + switch name { + case "operations": + return operationsTemplate, nil + default: + return "", fmt.Errorf("unknown template: %s", name) } - - return "", fmt.Errorf("failed to read template file %s.tmpl from any location: %w", name, lastErr) } type paramStructField struct { diff --git a/hacks/generator/schema.go b/hacks/generator/schema.go index 23035c3b..d6736050 100644 --- a/hacks/generator/schema.go +++ b/hacks/generator/schema.go @@ -26,11 +26,15 @@ func formatDocComment(description string) string { } lines := strings.Split(description, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + var docLines []string for _, line := range lines { docLines = append(docLines, "/// "+line) } - return strings.Join(docLines, "\n") + "\n" + return strings.Join(docLines, "\n") } func (t *Type) NullableName(nullable bool) string { @@ -206,7 +210,7 @@ func (s *State) addObject(sc *openapi3.Schema, path string) (*Type, error) { // Add description as doc comment for the field if available var fieldDocs string if scc.Value != nil && scc.Value.Description != "" { - fieldDocs = formatDocComment(scc.Value.Description) + fieldDocs = formatDocComment(scc.Value.Description) + "\n" } prop := fmt.Sprintf( @@ -224,7 +228,7 @@ func (s *State) addObject(sc *openapi3.Schema, path string) (*Type, error) { // Add struct-level documentation structDocs := "/// path: " + path + "\n" if sc.Description != "" { - structDocs = formatDocComment(sc.Description) + structDocs = formatDocComment(sc.Description) + "\n" } return &Type{ @@ -319,7 +323,7 @@ func (s *State) addStringEnum(sc *openapi3.Schema, path string) (*Type, error) { // Add enum-level documentation var enumDocs string if sc.Description != "" { - enumDocs = formatDocComment(sc.Description) + enumDocs = formatDocComment(sc.Description) + "\n" } impl := fmt.Sprintf( diff --git a/hacks/generator/templates/operations.tmpl b/hacks/generator/templates/operations.tmpl index e124f377..b50c6d6d 100644 --- a/hacks/generator/templates/operations.tmpl +++ b/hacks/generator/templates/operations.tmpl @@ -6,12 +6,16 @@ #[allow(async_fn_in_trait)] pub trait Api { {{ range . }} + + {{ .Description }} async fn {{ .Name }}({{ opArgs . }}) -> {{ opRets . }}; {{ end }} } impl Api for Client { {{ range . }} + + {{ .Description }} async fn {{ .Name }}({{ opArgs . }}) -> {{ opRets . }} { {{ urlGen . }} From ea3896e51af1cde323856ffe5cb11fefdc14c9b7 Mon Sep 17 00:00:00 2001 From: "Ch.-David Blot" Date: Tue, 26 May 2026 15:24:38 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=88=EF=B8=8F=20feat:=20document=20off?= =?UTF-8?q?line=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .envrc | 1 + .github/workflows/pr.yml | 2 +- Cargo.toml | 7 - USAGE.md | 144 +++++++++++++++++++ clippy.toml | 2 + {examples => crates/sdk/examples}/README.md | 0 {examples => crates/sdk/examples}/project.rs | 2 +- {examples => crates/sdk/examples}/vm.rs | 0 crates/sdk/src/lib.rs | 1 - crates/sdk/src/transport.rs | 2 +- flake.lock | 61 ++++++++ flake.nix | 109 ++++++++++++++ hacks/generator/schema.go | 12 ++ hacks/generator/templates/operations.tmpl | 5 +- 14 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 .envrc create mode 100644 USAGE.md create mode 100644 clippy.toml rename {examples => crates/sdk/examples}/README.md (100%) rename {examples => crates/sdk/examples}/project.rs (97%) rename {examples => crates/sdk/examples}/vm.rs (100%) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 89187428..feb924de 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -48,4 +48,4 @@ jobs: - name: Mutants run: | - cargo mutants --no-shuffle -vV --in-diff git.diff + cargo mutants -e "crates/sdk-build/*/*.rs" --no-shuffle -vV --in-diff git.diff diff --git a/Cargo.toml b/Cargo.toml index 8cd5c6aa..25cb1df7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,9 +48,6 @@ get_unwrap = "warn" unwrap_in_result = "warn" unchecked_time_subtraction = "warn" panic_in_result_fn = "warn" -# Optional - see post for caveats -# expect_used = "warn" -# arithmetic_side_effects = "warn" # Don't Fail Silently - prevent dropped futures and swallowed errors let_underscore_future = "warn" @@ -78,10 +75,6 @@ float_cmp_const = "warn" lossy_float_literal = "warn" cast_sign_loss = "warn" invalid_upcast_comparisons = "warn" -# Optional - these effectively force you to document numeric invariants -# cast_possible_wrap = "warn" -# cast_precision_loss = "warn" -# cast_possible_truncation = "warn" # Don't Do Bad Things That are Easy to Avoid rc_mutex = "warn" diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 00000000..bcae642f --- /dev/null +++ b/USAGE.md @@ -0,0 +1,144 @@ +# Outscale SDK for Rust — Usage Guide + +## Table of Contents + +- [Installation](#installation) +- [Feature Flags](#feature-flags) +- [Offline Mode](#offline-mode) + - [How the Generator Pipeline Works](#how-the-generator-pipeline-works) + - [Pre-building the Generator](#pre-building-the-generator) + - [Offline Build with GENERATOR_BIN](#offline-build-with-generator_bin) + - [Offline Build with Nix](#offline-build-with-nix) + +--- + +## Installation + +Add the SDK to your `Cargo.toml`: + +```toml +[dependencies] +osc-sdk-rust = { git = "https://github.com/outscale/osc-sdk-rust.git", branch = "v2" } +``` + +**Requirements:** + +- Rust stable toolchain (2024 edition) +- Go toolchain (for building the code generator; see [Offline Mode](#offline-mode) for pre-built alternatives) +- Valid OUTSCALE API credentials + +--- + +## Feature Flags + +| Feature | Default | Description | +| ------- | ------- | -------------------------------------------- | +| `osc` | Yes | Enables the OSC (compute) API client | +| `oks` | Yes | Enables the OKS (Kubernetes) API client | +| `default-tls` | Yes | Uses reqwest's default Rustls TLS backend | +| `native-tls` | No | Uses OpenSSL via reqwest's native-tls feature | + +To enable only the OSC API: + +```toml +[dependencies] +osc-sdk-rust = { git = "...", branch = "v2", default-features = false, features = ["osc"] } +``` + +--- + +## Offline Mode + +By default, building `osc-sdk-rust` requires network access for two reasons: + +1. **Rust crate dependencies** — fetched from crates.io (solved by `cargo vendor` or a pre-populated `Cargo.lock`) +2. **The code generator binary** — the `osc-sdk-rust-build` crate acquires the Go generator at build time + +This section covers how to eliminate requirement #2. Once the generator is locally available, you can build the SDK without network access to GitHub or any Go module proxy. + +### How the Generator Pipeline Works + +The generator is a Go program that reads the OpenAPI specification (`config/{osc,oks}/api.yaml`) and its overlay patches (`config/{osc,oks}/patch.yaml`), then emits Rust code (structs, enums, the `Api` trait, and the `Client` implementation). + +### Pre-building the Generator + +The simplest offline strategy: build the generator once, then point `GENERATOR_BIN` at it. + +```bash +# Build the generator from source +cd hacks/generator +CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ~/bin/osc-sdk-generator . + +# Verify +~/bin/osc-sdk-generator --help +``` + +Now any subsequent `cargo build` of the SDK can use this binary: + +```bash +GENERATOR_BIN=$HOME/bin/osc-sdk-generator cargo build +``` + +The `sdk-build` crate checks `GENERATOR_BIN` first — if the file exists, it uses it and skips both the GitHub download and the local `go build` attempts. No network access needed for code generation. + +### Offline Build with GENERATOR_BIN + +For a fully offline build, combine a pre-built generator with vendored Rust dependencies: + +```bash +# Step 1: Pre-build the generator (do once, with network) +cd hacks/generator +CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/osc-sdk-generator . + +# Step 2: Vendor Rust dependencies (do once, with network) +cargo vendor +mkdir -p .cargo +cat > .cargo/config.toml < Result<(), Box> { loop { match client.get_project(project_id.clone()).await { Err(e) => { - log::error!("API error: {}", e.to_string()); + log::error!("API error: {}", e); break; } Ok(project_resp) diff --git a/examples/vm.rs b/crates/sdk/examples/vm.rs similarity index 100% rename from examples/vm.rs rename to crates/sdk/examples/vm.rs diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 2c4b2575..b03f6e83 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -1,4 +1,3 @@ -#![deny(clippy::unwrap_used, clippy::print_stderr, clippy::print_stdout)] pub mod basicauth; pub mod errors; pub mod policy; diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs index 9b46ecd9..05607f46 100644 --- a/crates/sdk/src/transport.rs +++ b/crates/sdk/src/transport.rs @@ -34,7 +34,7 @@ fn build_transport(profile: &Profile) -> Result { .map_err(|e| crate::Error::InvalidClientCertificate(e.to_string())) } - #[cfg(feature = "native-tls")] + #[cfg(all(feature = "native-tls", not(feature = "default-tls")))] fn mk_identity( mut key: Vec, mut cert: Vec, diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..c5e45dfd --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..22dc4a84 --- /dev/null +++ b/flake.nix @@ -0,0 +1,109 @@ +{ + description = "Outscale SDK for Rust — offline-capable build"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = nixpkgs.legacyPackages.${system}; + + # Builds the code generator from hacks/generator/ so it can be + # injected into the Rust build via GENERATOR_BIN. This eliminates + # the need for network access (GitHub releases, Go module proxy) + # during the Rust crate build phase. + # + # First build will fail with a hash mismatch. Copy the expected + # hash from the error message into `vendorHash` and rebuild. + generator = pkgs.buildGoModule { + pname = "generator"; + version = "2.0.0-alpha.0"; + src = ./hacks/generator; + + vendorHash = "sha256-B1C3u6iQNMqIuJ3rUQiXI9XOGMPIL5gItmCMuhEfC9Q="; + + # The go.mod declares `module gogo`, so buildGoModule produces + # $out/bin/gogo. Rename to the name sdk-build expects. + postInstall = '' + if [ -f "$out/bin/gogo" ]; then + mv "$out/bin/gogo" "$out/bin/generator" + fi + ''; + + env.CGO_ENABLED = "0"; + ldflags = ["-s" "-w"]; + }; + + # Builds the SDK crate and its examples. GENERATOR_BIN points + # sdk-build at the Nix-built generator — both the GitHub download + # and the `go build` fallback are skipped entirely. + # + # The cargoLock pre-fetches every Rust crate dependency into the + # Nix store. The build phase itself runs with no network access. + # + # Examples at the workspace root import `crates/sdk`; Cargo only + # discovers examples under a package's own examples/ directory, + # so we symlink them there in postPatch. + examples = pkgs.rustPlatform.buildRustPackage { + pname = "osc-sdk-rust-examples"; + version = "2.0.0-alpha.0"; + src = ./.; + cargoLock = {lockFile = ./Cargo.lock;}; + + nativeBuildInputs = [pkgs.rustfmt]; + + GENERATOR_BIN = "${generator}/bin/generator"; + + cargoBuildFlags = "--examples"; + + installPhase = '' + mkdir -p $out/bin + bins=$(find /build \ + -type f \ + -executable | grep "release/examples") + + echo "$bins" + for bin in $bins; do + cp "$bin" "$out/bin/$(basename "$bin")" + done + ''; + + doCheck = false; + }; + in { + packages = { + inherit generator examples; + default = examples; + }; + + devShells.default = pkgs.mkShellNoCC { + nativeBuildInputs = with pkgs; [ + cargo + rustc + rustfmt + clippy + go + golangci-lint + ]; + + GENERATOR_BIN = "${generator}/bin/generator"; + + shellHook = '' + echo "osc-sdk-rust development shell" + echo " GENERATOR_BIN = $GENERATOR_BIN" + echo + echo "Usage:" + echo " cargo run --example vm # OSC example" + echo " cargo run --example project # OKS example" + ''; + }; + } + ); +} diff --git a/hacks/generator/schema.go b/hacks/generator/schema.go index d6736050..a81fcb9a 100644 --- a/hacks/generator/schema.go +++ b/hacks/generator/schema.go @@ -31,7 +31,19 @@ func formatDocComment(description string) string { } var docLines []string + var docLazyContinuation bool for _, line := range lines { + if strings.HasPrefix(line, "* ") { + docLazyContinuation = true + } + + if docLazyContinuation && line == "" { + docLazyContinuation = false + } + + if docLazyContinuation && !strings.HasPrefix(line, " ") { + line = " " + line + } docLines = append(docLines, "/// "+line) } return strings.Join(docLines, "\n") diff --git a/hacks/generator/templates/operations.tmpl b/hacks/generator/templates/operations.tmpl index b50c6d6d..3c656d7f 100644 --- a/hacks/generator/templates/operations.tmpl +++ b/hacks/generator/templates/operations.tmpl @@ -3,7 +3,7 @@ {{ opParamStruct . }} {{ end }} -#[allow(async_fn_in_trait)] +#[allow(async_fn_in_trait, clippy::doc_lazy_continuation)] pub trait Api { {{ range . }} @@ -15,7 +15,10 @@ pub trait Api { impl Api for Client { {{ range . }} + {{ if .Description }} + #[allow(clippy::doc_lazy_continuation)] {{ .Description }} + {{- end }} async fn {{ .Name }}({{ opArgs . }}) -> {{ opRets . }} { {{ urlGen . }}