versa 1.7.2

Versa types and utilities for developing Versa client applications in Rust
Documentation
//! This is a regular crate doc comment, but it also contains a partial
//! Cargo manifest.  Note the use of a *fenced* code block, and the
//! `cargo` "language".
//!
//! ```cargo
//! [dependencies]
//! prettyplease = "0.2"
//! ureq = "3.0.11"
//! schemars = "0.8"
//! serde_json = "1.0"
//! syn = "2.0"
//! typify = "0.2"
//! ```

use std::{fs, path::Path};

use typify::{TypeSpace, TypeSpaceSettings};

const SCHEMA_URL: &'static str = "https://raw.githubusercontent.com/versa-protocol/schema";
const SCHEMA_PATH: &'static str = "data";
const SCHEMAS: [&'static str; 2] = ["receipt", "itinerary"];

/// Define the latest schema version
const LATEST_SCHEMA_VERSION: &'static str = "2.1.1";

/// Define LTS versions we want to generate bindings for
#[rustfmt::skip]
const LTS_SCHEMA_VERSIONS: [&'static str; 4] = [
  "1.11.0",
  "2.0.0",
  "2.1.0",
  LATEST_SCHEMA_VERSION
];

fn get_schema_definition_json(schema_name: &str, version: &str) -> Result<String, ()> {
  let schema_url = format!(
    "{}/{}/{}/{}.schema.json",
    SCHEMA_URL, version, SCHEMA_PATH, schema_name
  );

  match ureq::get(&schema_url).call() {
    Ok(mut res) => {
      let response_status = res.status();
      if response_status != ureq::http::StatusCode::OK {
        println!(
          "Error fetching schema version {}: {}",
          version, response_status
        );
        return Err(());
      }
      return match res.body_mut().read_to_string() {
        Ok(text) => Ok(text),
        Err(e) => {
          println!("Error fetching schema version {}: {:?}", version, e);
          Err(())
        }
      };
    }
    Err(e) => {
      println!("Error fetching schema version {}: {:?}", version, e);
      return Err(());
    }
  }
}

fn generate_schema_bindings(schema_name: &str, version: &str, output_dir: &Path) {
  let Ok(content) = get_schema_definition_json(schema_name, version) else {
    return;
  };

  let schema = serde_json::from_str::<schemars::schema::RootSchema>(&content).unwrap();

  let mut type_space = TypeSpace::new(TypeSpaceSettings::default().with_struct_builder(true));
  type_space.add_root_schema(schema).unwrap();

  let contents = prettyplease::unparse(&syn::parse2::<syn::File>(type_space.to_stream()).unwrap())
    .replace("chrono::naive::NaiveDate", "String");

  let mut out_file = output_dir.to_path_buf();
  out_file.push(format!("{}.rs", schema_name));
  fs::write(out_file, contents).unwrap();
}

fn strip_additional_properties(value: &mut serde_json::Value) {
  match value {
    serde_json::Value::Object(map) => {
      map.remove("additionalProperties");
      for v in map.values_mut() {
        strip_additional_properties(v);
      }
    }
    serde_json::Value::Array(arr) => {
      for v in arr {
        strip_additional_properties(v);
      }
    }
    _ => {}
  }
}

fn generate_tolerant_schema_bindings(schema_name: &str, version: &str, output_dir: &Path) {
  let Ok(content) = get_schema_definition_json(schema_name, version) else {
    return;
  };

  let mut val = serde_json::from_str::<serde_json::Value>(&content).unwrap();

  strip_additional_properties(&mut val);

  let stripped_schema = serde_json::to_string(&val).unwrap();

  let schema = serde_json::from_str::<schemars::schema::RootSchema>(&stripped_schema).unwrap();

  let mut type_space = TypeSpace::new(TypeSpaceSettings::default().with_struct_builder(true));
  type_space.add_root_schema(schema).unwrap();

  let contents = prettyplease::unparse(&syn::parse2::<syn::File>(type_space.to_stream()).unwrap())
    .replace("chrono::naive::NaiveDate", "String");

  let mut out_file = output_dir.to_path_buf();
  out_file.push(format!("{}_unstrict.rs", schema_name));
  fs::write(out_file, contents).unwrap();
}

fn bundle_schema_definition(schema: &str, version: &str) {
  let Ok(content) = get_schema_definition_json(schema, version) else {
    return;
  };
  let safe_version = format!("{}", version.replace('.', "_").replace('-', "_"));

  // write content to file with safe version name
  let safe_version_name = format!("v{}", safe_version);

  let mut out_file = Path::new("src/schema/json").to_path_buf();

  out_file.push(format!("{}_{}.json", schema, safe_version_name));
  fs::write(out_file, content).unwrap();

  println!(
    "Bundled schema definition for {} version {}",
    schema, version
  );
}

fn bundle_schema_definitions(schema: &str) {
  for version in LTS_SCHEMA_VERSIONS {
    bundle_schema_definition(schema, version);
  }
}

fn generate_version_mod_file(version: &str) {
  let safe_version = version.replace('.', "_").replace('-', "_");
  let version_dir = Path::new("src/schema").join(format!("v{}", safe_version));

  let mod_content = format!(
    r#"//! Schema bindings for version {}

pub mod itinerary;
pub mod itinerary_unstrict;
pub mod receipt;
pub mod receipt_unstrict;
"#,
    version
  );

  fs::write(version_dir.join("mod.rs"), mod_content).unwrap();
}

fn output_current_schema_version() {
  let mut out_file = Path::new("src/schema").to_path_buf();
  out_file.push("current.rs");
  fs::write(
    out_file,
    format!(
      r#"//! Current schema version constant
pub const SCHEMA_VERSION: &str = "{}";
"#,
      LATEST_SCHEMA_VERSION
    ),
  )
  .unwrap();
}

fn update_main_mod_file() {
  let escaped_latest_version = LATEST_SCHEMA_VERSION.replace('.', "_").replace('-', "_");
  let mut mod_content = format!(
    r#"//! Schema module containing type definitions for all LTS versions
// NOTE: This file is auto-generated by codegen.rs; DO NOT EDIT THIS FILE DIRECTLY

pub mod current;
pub mod downshift;

// Re-export latest version as the default
pub use v{escaped_latest_version}::*;

// Version-specific modules
"#,
  );

  // Add version modules
  for version in LTS_SCHEMA_VERSIONS {
    let safe_version = version.replace('.', "_").replace('-', "_");
    mod_content.push_str(&format!("pub mod v{};\n", safe_version));
  }

  mod_content.push_str(
    r#"
#[cfg(feature = "validator")]
mod json;

#[cfg(feature = "validator")]
pub mod validator;
"#,
  );

  fs::write(Path::new("src/schema/mod.rs"), mod_content).unwrap();
}

fn main() {
  // Create schema directory if it doesn't exist
  fs::create_dir_all("src/schema").unwrap();

  // Delete all json files in schema/json
  let json_dir = Path::new("src/schema/json");
  if json_dir.exists() {
    for entry in fs::read_dir(json_dir).unwrap() {
      let entry = entry.unwrap();
      if entry
        .path()
        .extension()
        .map(|s| s == "json")
        .unwrap_or(false)
      {
        fs::remove_file(entry.path()).unwrap();
      }
    }
  } else {
    fs::create_dir_all(json_dir).unwrap();
  }

  output_current_schema_version();

  // Generate bindings for each LTS version
  for version in LTS_SCHEMA_VERSIONS {
    let safe_version = version.replace('.', "_").replace('-', "_");
    let version_dir = Path::new("src/schema").join(format!("v{}", safe_version));

    // Create version directory
    fs::create_dir_all(&version_dir).unwrap();

    println!("Generating bindings for version {}", version);

    // Generate bindings for each schema
    for schema in SCHEMAS.iter() {
      generate_schema_bindings(schema, version, &version_dir);
      generate_tolerant_schema_bindings(schema, version, &version_dir);
    }

    // Generate mod.rs for this version
    generate_version_mod_file(version);
  }

  // Generate bindings for the latest version in the root schema directory
  println!("Generating latest version bindings in root schema directory");
  let schema_dir = Path::new("src/schema");
  for schema in SCHEMAS.iter() {
    generate_schema_bindings(schema, LATEST_SCHEMA_VERSION, schema_dir);
    generate_tolerant_schema_bindings(schema, LATEST_SCHEMA_VERSION, schema_dir);
  }

  // Bundle all schema definitions as JSON
  for schema in SCHEMAS.iter() {
    bundle_schema_definitions(schema);
  }

  // Update the main mod.rs file
  update_main_mod_file();

  println!("Schema generation complete!");
}