martinohmann / hcl-rs

HCL parsing and encoding libraries for rust with serde support
Apache License 2.0
119 stars 14 forks source link

restricting eval body to subset of Expression variants #323

Open iamjoncannon opened 4 months ago

iamjoncannon commented 4 months ago

Hi Martin, first thanks so much for this library!

I have a question--

I would like to restrict a property on an HCL body to a subset of hcl::Expression variants during hcl::from_body(body) schema validation.

The body schema is:

pub struct CallBlock {
    base_url: hcl::Expression,
    path: Option<hcl::Expression>,
    headers: Option<hcl::Expression>,
    body: Option<HclObject>,
    after: Option<Vec<HclObject>>,
    outputs: Option<Vec<hcl::Traversal>>,
}

I can encapsulate the Expression::Object variant--

pub type HclObject = hcl::Object<hcl::ObjectKey, hcl::Expression>;

However, rather than the general hcl::Expression, I want to restrict headers to be either--

pub enum ObjectOrVariable {
    Object(HclObject),
    Variable(hcl::Variable),
}

(e.g. user HTTP headers can either be an object with a key or a general expression, or a variable. If the user screws up the header value its their problem, but we validate that its not a )

When I use this ObjectOrVariable enum as the header schema-- Option<Vec<ObjectOrVariable>> -- I get this error with correct HCL-- "unknown variant 'my-key', expected 'Object' or 'Variable'", where my-key was the header key passed.

Thanks in advance for any help, and apologies if I've missed something obvious.

martinohmann commented 3 months ago

Hi @iamjoncannon, could you provide me with a minimal reproducer for the issue? With the context you provided so far it seems like this could solved using the #[serde(untagged)] attribute on the ObjectOrVariable enum: https://serde.rs/enum-representations.html

iamjoncannon commented 3 months ago

Hi @martinohmann, thanks for following up!

I wrote some unit tests to describe my ideal behavior to evaluate blocks during an initial parsing.

My suspicion is that I'm unable to define the types of these variants properly in my enums-- e.g. defining a list as a vec instead of the hcl::Expression::Array variant. Granted I'm not a Rust expert, but I wrestled with this enough to conclude that I might not be able to do this because the hcl::Expression variant types are private?

Being able to define and restrict user inputs for my app would be really, really helpful, so again thank you so much for any time you can spend on my issue.

// [dependencies]
// hcl-rs = "0.16.7"
// serde_json = "1.0.114"
// serde = "1.0.197"

use serde::{de::DeserializeOwned, Deserialize, Serialize};

#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
pub enum HeaderKey {
    Identifier(hcl::Identifier),
    Traversal(hcl::Traversal),
}

pub type HeaderObject = hcl::Object<hcl::ObjectKey, HeaderKey>;

#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
pub enum HeaderObjectOrVariable {
    Object(HeaderObject),
    Traversal(hcl::Traversal),
}

#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
pub enum ValidHeaderCases {
    Traversal(hcl::Traversal),
    String(String),
    Array(Vec<HeaderObjectOrVariable>),
}

#[derive(Deserialize, Serialize, Debug)]
pub struct IdealRestrictedCallBlock {
    pub headers: ValidHeaderCases,
}

#[derive(Deserialize, Serialize, Debug)]
pub struct UnrestrictedCallBlock {
    pub headers: hcl::Expression,
}

pub fn evaluate<T: DeserializeOwned>(input: &str) -> Result<T, hcl::Error> {
    let call_block_body: hcl::Body = hcl::from_str(input).unwrap();
    let block = call_block_body.into_blocks().next().unwrap().body;
    let call_block_res: Result<T, hcl::Error> = hcl::from_body(block);
    call_block_res
}

fn main() {}

#[cfg(test)]
mod tests {
    use std::fmt::Debug;

    use crate::{evaluate, IdealRestrictedCallBlock, UnrestrictedCallBlock, ValidHeaderCases};

    #[test]
    fn test_trivial_string() {
        // this is just to test untagged solution with a trivial case
        let test_block: &str = r#"
            get "example" {
                headers = "trivial case"
            }
        "#;

        run_unrestricted_and_ideal(test_block, "test_trivial_string");
    }

    #[test]
    fn test_header_block_as_variable() {
        let test_block: &str = r#"
            get "example" {
                headers = my.headers.var
            }
        "#;

        reporter(
            "test_header_block_as_variable UnrestrictedCallBlock",
            &evaluate::<UnrestrictedCallBlock>(test_block),
        );

        let res = evaluate::<IdealRestrictedCallBlock>(test_block);

        match res {
            Ok(res) => match res.headers {
                ValidHeaderCases::Traversal(_var) => assert!(true),
                ValidHeaderCases::String(str) => {
                    // the variable is evaluated as a string template
                    println!("variable evaluated as string: {str}");
                    assert!(false)
                }
                _ => assert!(false),
            },
            Err(err) => {
                eprintln!("test_header_block_as_variable err {err:?}");
                assert!(false);
            }
        }
    }

    #[test]
    fn test_headers_defined_as_variable() {
        let test_block: &str = r#"
            get "example" {
                headers = [
                    my.header.variable
                ]
            }
        "#;

        run_unrestricted_and_ideal(test_block, "test_headers_defined_as_variable");
    }

    #[test]
    fn test_headers_defined_as_string() {
        let test_block: &str = r#"
            get "example" {
                headers = [
                    { "authorization" : "let me in" }
                ]
            }
        "#;

        run_unrestricted_and_ideal(test_block, "test_headers_defined_as_string");
    }

    #[test]
    fn test_individual_headers_as_variables() {
        let test_block: &str = r#"
            get "example" {
                headers = [
                    my.header.variable
                ]
            }
        "#;
        run_unrestricted_and_ideal(test_block, "test_individual_headers_as_variables");
    }

    #[test]
    fn test_individual_headers_values_as_variables() {
        let test_block: &str = r#"
            get "example" {
                headers = [
                    { "authorization" : my.auth.var }
                ]
            }
        "#;
        run_unrestricted_and_ideal(test_block, "test_individual_headers_values_as_variables");
    }

    fn run_unrestricted_and_ideal(test_block: &str, test_name: &str) {
        reporter(
            &format!("{} UnrestrictedCallBlock", test_name),
            &evaluate::<UnrestrictedCallBlock>(test_block),
        );

        reporter(
            &format!("{} IdealRestrictedCallBlock", test_name),
            &evaluate::<IdealRestrictedCallBlock>(test_block),
        );
    }

    fn reporter<T: Debug>(name: &str, result: &Result<T, hcl::Error>) {
        match result {
            Ok(res) => {
                println!("result {name}: {res:?}");
                assert!(true)
            }
            Err(err) => {
                println!("err {name} {err:?}");
                assert!(false)
            }
        }
    }
}