DEV Community

Florian Fromm
Florian Fromm

Posted on

Embedding KCL in Rust

What is KCL?

KCL is a new & interesting configuration language.
It looks a bit like a mixture of YAML and HCL.

# This is a KCL document

title = "KCL Example"

owner = {
    name = "The KCL Authors"
    date = "2020-01-02T03:04:05"
}

database = {
    enabled = True
    ports = [8000, 8001, 8002]
    data = [["delta", "phi"], [3.14]]
    temp_targets = {cpu = 79.5, case = 72.0}
}

servers = [
    {ip = "10.0.0.1", role = "frontend"}
    {ip = "10.0.0.2", role = "backend"}
]
Enter fullscreen mode Exit fullscreen mode

But what it brings to the table is KCL adds typing and data validation to your configuration.

Validation and typing can be applied to your data via schema.

schema User:
    name: str
    age: int
    message?: str
    data: Data
    labels: {str:}
    hc: [int]

    check:
        age > 10, "age must > 10"

schema Data:
    id: int
    value: int = 9000
Enter fullscreen mode Exit fullscreen mode

With a schema we can define which fields a certain kind of data should have.
Furthermore we can add types, set default values and even apply constraints to the fields via check.

After the definition, a schema can be instantiated.

User {
  name = "Foo"
  age = 42
  data = Data {
    id = 0
  }
  labels = {
    bar = 12
    baz = "some label"
  }
  hc = [1,2,3]
}
Enter fullscreen mode Exit fullscreen mode

KCL code can then be 'compiled' into standard JSON or YAML.
The user code snippet from above would 'compile' to:

{
  "name": "Foo",
  "age": 42,
  "data": {
    "id": 0,
    "value": 9000
  },
  "labels": {
    "bar": 12,
    "baz": "some label"
  },
  "hc": [
    1,
    2,
    3
  ]
}
Enter fullscreen mode Exit fullscreen mode

or

name: Foo
age: 42
data:
  id: 0
  value: 9000
labels:
  bar: 12
  baz: some label
hc:
- 1
- 2
- 3
Enter fullscreen mode Exit fullscreen mode

KCL has even more interesting features, like control flow, modules, and even a registry for these modules, so that schemas can be easily shared and reused.
But that should be enough as a KCL intro, if you want to know more about KCL just visit kcl-lang.io.

Embedding KCL in Rust

After realising that KCL itself is written in Rust (in most examples you see KCL as a CLI tool), I decided it would be an interesting idea to embed KCL in my Rust application to validate the configuration file of the application.

Luckily, KCL has a lib that offers multiple language bindings and can easily be added via:

cargo add --git https://github.com/kcl-lang/lib kcl-lang
Enter fullscreen mode Exit fullscreen mode

Afterwards, you can compile KCL in your Rust code like this:

let api = API::default();
let args = &ExecProgramArgs {
    k_filename_list: vec!["main.k".to_string()],
    k_code_list: vec!["a = 1".to_string()],
    ..Default::default()
};
let exec_result = api.exec_program(args)?;
println!("{}", exec_result.yaml_result);
Enter fullscreen mode Exit fullscreen mode

I assume that is the CLI API, because we have to add a list of files (they have no relevancy for that use case, but you can't skip them) and receive the Stdout & Stderr output the CLI would produce with the exec_result.

The idea was to add the configuration schema to the source code and include it during compile time via include_str!() macro.

The schema looks like this:

# schema.k
schema Configuration:
  api: API = API {}
  gateway: Gateway = Gateway {}

schema API:
  port: int = 8150
  check:
    1 <= port <= 65535, "API Port not in range 1 to 65535"

schema Gateway:
  port: int = 80
  check:
    1 <= port <= 65535, "Gatewayport Port not in range 1 to 65535"
Enter fullscreen mode Exit fullscreen mode

The corresponding Rust struct would be:

#[derive(Debug, Serialize, Deserialize)]
pub struct Configuration {
    pub api: APIConfiguration,
    pub gateway: GatewayConfiguration,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct APIConfiguration {
    pub port: u16,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct GatewayConfiguration {
    pub port: u16,
}
Enter fullscreen mode Exit fullscreen mode

I intended to enable the users to write their own configuration file in KCl like:

# Long version
api = {
  port = 8150
}
# Short version
gateway.port = 8080
Enter fullscreen mode Exit fullscreen mode

Then compile that configuration file against my configuration schema that includes all necessary default values.

So I want to combine the user configuration file content with my schema definition to the following:

# schema.k content
schema Configuration:
  api: API = API {}
  gateway: Gateway = Gateway {}

schema API:
  port: int = 8150
  check:
    1 <= port <= 65535, "API Port not in range 1 to 65535"

schema Gateway:
  port: int = 80
  check:
    1 <= port <= 65535, "Gatewayport Port not in range 1 to 65535"

Configuration {
  # User configuration file content
  api = {
    port = 8150
  }
  gateway.port = 8080
}
Enter fullscreen mode Exit fullscreen mode

And finally, the necessary Rust code to deserialize the configuration struct from the compiled KCL code:

impl Configuration {
    pub fn default() -> Result<Self> {
        Self::process_kcl(None)
    }

    pub fn from_configuration_file(path: &Path) -> Result<Self> {
        Self::process_kcl(Some(path))
    }

    fn process_kcl(path: Option<&Path>) -> Result<Self> {
        let mut file_name = "default-configuration.k".to_string();
        let mut code = String::new();
        if let Some(path) = path {
            file_name = "configuration.k".to_string();
            code = std::fs::read_to_string(path)?;
        }
        let code = format!("{}\nConfiguration{{\n{}}}", SCHEMA, code);
        let args = &ExecProgramArgs {
            k_filename_list: vec![file_name],
            k_code_list: vec![code],
            ..Default::default()
        };
        let api = API::default();
        let exec_result = api.exec_program(args)?;
        if !exec_result.err_message.is_empty() {
            return Err(anyhow!(
                "Configuration error:\n\n{}",
                exec_result.err_message
            ));
        }
        serde_json::from_str(&exec_result.json_result)
          .map_err(|err| anyhow!(err))
    }
}
Enter fullscreen mode Exit fullscreen mode

Without providing a configuration file we compile against a Configuration{} instance, which then gets all the default values from the schema.

A bit strange seems that we don't get an error result if we compile against some user configuration that is not valid for the provided schema.

The only way to check for a compilation error is to check if the exec_result.err_message is "". This error message is the stderr output that the CLI tool would produce if the compilation fails.
Cumbersome, but at least we get a good error message with specific information about what is wrong with the configuration KCL code.

Last step is then to deserialize the Configuration struct from the json representation of the compilation result -> we can configure our Rust application via KCL configuration file.

Last step is then to deserialize the Configuration struct from the JSON representation of the compilation result -> we can configure our Rust application via KCL configuration file.

Conclusion

First thought: It works and that is a good thing!

Second thought: Working with the CLI API in the Rust code is somehow awkward and afterwards deserializing the struct again from JSON or YAML representation seems unnecessary.
I'm sure there is a smarter way to directly parse the KCL and create the struct with the serde values that are produced by the parser.
Sadly the documentation of the kcl-lang lib is kept very short and I couldn't find an approach like that.

What about a Procedural Macro?

All that boilerplate code could also be generated by a procedural macro that is applied to the Configuration struct, similar to what e.g. serde, clap etc. do.

Nevertheless, KCL is interesting

KCL configuration is in my opinion a great supplement to Rust.
Similar to Rust, KCL uses types & constraints to minimize the possibility that our configuration is incorrect.

Top comments (0)