odata_client_codegen 0.1.0

Strongly-typed OData client code generation
Documentation
//! Functionality to generate a rust source code module which represents Entity Data Model items for
//! an OData endpoint. This generation can be performed as a procedural macro from the
//! `odata_client_derive` crate, or as a `build.rs` action, using the
//! [`write_module_build_artifact`] or [`generate_module`] functions.

mod codegen;
mod entity_data_model_parse;
mod entity_model;
mod entity_model_filter;
#[cfg(test)]
mod input_file_test;

use std::{
    ffi::OsStr,
    fs::{self, DirBuilder},
    io::Write,
    path::Path,
    process::{Command, Stdio},
};

use anyhow::{anyhow, Context};
use bumpalo::Bump;
use codegen::generate_client_module;
use proc_macro2::TokenStream;
use which::which;

use entity_model::construct::construct_entity_model;

pub use entity_model::construct::ConstructConfig;
pub use entity_model_filter::item_name_whitelist::ItemNameWhitelist;
pub use entity_model_filter::EntityModelFilter;

/// Generate OData client token stream from XML string.
///
/// `construct_config` specifies workarounds to overcome inconsistencies in a provided entity model
/// document. If none is required, pass in `ConstructConfig::default()`.
///
/// `entity_model_filter` optionally specifies filters for items in the entity model document for
/// which to generate code. See [`EntityModelFilter`] for more info.
pub fn generate_module(
    metadata_xml: &str,
    service_url: String,
    construct_config: ConstructConfig,
    entity_model_filter: Option<&mut dyn EntityModelFilter>,
) -> Result<TokenStream, anyhow::Error> {
    const TRIMMED_XML_LEN: usize = 100;

    // Trimmed input for error messages
    let trimmed_input_str = || {
        format!(
            "{}{}",
            &metadata_xml[0..TRIMMED_XML_LEN].replace("\n", ""),
            if metadata_xml.len() > TRIMMED_XML_LEN {
                "..."
            } else {
                ""
            }
        )
    };

    let t_edmx = serde_xml_rs::from_str(metadata_xml).with_context(|| {
        format!(
            "Unable to parse XML input as entity model document:\n{}",
            trimmed_input_str()
        )
    })?;

    let arena = Bump::new();
    let entity_model = construct_entity_model(t_edmx, service_url, &arena, construct_config)
        .with_context(|| {
            format!(
                "Unable to construct consistent entity model from document:\n{}",
                trimmed_input_str()
            )
        })?;

    Ok(generate_client_module(
        &entity_model,
        &arena,
        entity_model_filter,
    ))
}

/// Convenience function to be used in a build script (`build.rs`). Generates an OData client module
/// from the provided metadata XML, and writes to a location in the build output directory. Source
/// XML filepath and target code filepath are specified relative to the crate/workspace root. To use
/// the generated module, the user should manually declare a bodiless module within their code, and
/// add the `#[path = "..."]` attribute, specifying the path to the generated module, relative to
/// the consuming source file. E.g.:
/// ```toml
/// // Cargo.toml
///
/// [dependencies]
/// odata_client = "*"
///
/// [build-dependencies]
/// odata_client_codegen = "*"
/// ```
/// ```
/// // build.rs
///
/// use odata_client_codegen::{write_module_build_artifact, ConstructConfig, ItemNameWhitelist};
///
/// # fn dontrun() { // Doctest: test compilation only
/// fn main() {
///     // Generate code items for entity set "Foo" and singleton "Bar" only
///     let mut item_filter = ItemNameWhitelist::new()
///         .with_entity_sets(vec!["Foo"])
///         .with_singletons(vec!["Bar"]);
///
///     // Generate module & write to `my_service.rs`
///     write_module_build_artifact(
///         "./my_service_metadata.xml",
///         "./target/odata_clients/my_service.rs",
///         "https://example.ru/odata_api/",
///         true,
///         ConstructConfig::default(),
///         Some(&mut item_filter),
///     ).unwrap();
/// }
/// # }
/// ```
/// ```ignore
/// // src/main.rs
///
/// #[path = "../target/odata_clients/my_service.rs"]
/// mod my_service;
/// ```
///
/// This function emits `rerun-if-changed` directives, which disables the default behaviour of
/// re-running the build script on any included file change.
///
/// The generated file can be formatted using rustfmt by setting the `apply_rustfmt` argument to
/// `true`. If the `rustfmt` executable is not available in the executing shell's path, set this
/// argument to `false`.
///
/// ---
///
/// **N.B.** Writing to a directory other than that specified by the `OUT_DIR` env var is
/// unsupported usage of build scripts. But it works, and specifying a static target path allows for
/// using the `#[path = "..."]` attribute (or even writing the module straight into the `src`
/// directory). This provides better goto & autocomplete support from rust-analyzer.
// TODO: add support for fetching from service endpoint. Should cache endpoint xml in `OUT_DIR`:
// if fetch fails, used cached xml instead.
pub fn write_module_build_artifact(
    metadata_xml_path: &str,
    target_module_path: &str,
    service_url: &str,
    apply_rustfmt: bool,
    construct_config: ConstructConfig,
    entity_model_filter: Option<&mut dyn EntityModelFilter>,
) -> Result<(), anyhow::Error> {
    let target_module_path = Path::new(target_module_path);

    if target_module_path.extension() != Some(OsStr::new("rs")) {
        return Err(anyhow!(
            "Specified target module code file path '{:?}' does not end with '.rs'",
            target_module_path
        ));
    }

    let metadata_xml = fs::read_to_string(metadata_xml_path)
        .with_context(|| "Could not read metadata XML file")?;
    let module_content_stream = generate_module(
        &metadata_xml,
        service_url.to_owned(),
        construct_config,
        entity_model_filter,
    )?;

    let module_content_buf = if apply_rustfmt {
        run_rustfmt(&module_content_stream.to_string())?
    } else {
        module_content_stream.to_string()
    };

    let module_parent_dir = target_module_path.parent().unwrap();

    DirBuilder::new()
        .recursive(true)
        .create(module_parent_dir)?;

    fs::write(target_module_path, module_content_buf)?;

    println!("cargo:rerun-if-changed={}", metadata_xml_path);
    // TODO: determine if we should add "cargo:rerun-if-changed={target_module_path}". Not sure if
    // this would behave as we want (rerun if something other than build script has changed target
    // module file), or if it would just cause the build script to run every time.

    Ok(())
}

/// Runs `rustfmt`, if available on the system, on the provided input code.
pub(crate) fn run_rustfmt(input: &str) -> Result<String, anyhow::Error> {
    let rustfmt_path = which("rustfmt")
        .with_context(|| "Could not run `rustfmt`; ensure it is installed on the system")?;

    let mut fmt_proc = Command::new(rustfmt_path)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .with_context(|| "Failed to spawn `rustfmt` process")?;

    {
        let mut stdin = fmt_proc.stdin.take().unwrap();
        stdin.write(input.as_bytes())?;
    }

    let rustfmt_output = fmt_proc.wait_with_output()?;

    if rustfmt_output.status.success() {
        let formatted = String::from_utf8(rustfmt_output.stdout).unwrap();
        Ok(formatted)
    } else {
        let rustfmt_err_out = std::str::from_utf8(&rustfmt_output.stderr).unwrap();

        Err(anyhow!(
            "Syntax error in token stream:\n{}",
            rustfmt_err_out
        ))
    }
}