Repository: candlecorp/wick (language: rust)

Issue:
Missing values for optional configuration types throw an error
**Describe the bug**
A quick description of the bug. Add error messages and terms you think others experience this issue would search for.

**To Reproduce**

Make a component or operation with an optional type in the config and invoke it with no value.

```yaml
with:
  - name: any
    type: string?
```

**Expected behavior**
Operation is invoked without issue

**Actual behavior**
Wick complains about the config not having the right fields.




Discussion / hints:

Fix patch:
diff --git a/crates/wick/wick-config/src/v1/conversions/types.rs b/crates/wick/wick-config/src/v1/conversions/types.rs
index c9d5dabc7..546d55c0f 100644
--- a/crates/wick/wick-config/src/v1/conversions/types.rs
+++ b/crates/wick/wick-config/src/v1/conversions/types.rs
@@ -131,13 +131,11 @@ impl TryFrom<v1::Field> for wick::Field {
   type Error = ManifestError;
 
   fn try_from(value: v1::Field) -> std::result::Result<Self, Self::Error> {
-    Ok(Self {
-      name: value.name,
-      description: value.description,
-      ty: value.ty.try_into()?,
-      default: None,
-      required: true,
-    })
+    Ok(Self::new_with_description(
+      value.name,
+      value.ty.try_into()?,
+      value.description,
+    ))
   }
 }
 
diff --git a/crates/wick/wick-interface-types/src/field.rs b/crates/wick/wick-interface-types/src/field.rs
index 46e8a1f6a..0e2a83394 100644
--- a/crates/wick/wick-interface-types/src/field.rs
+++ b/crates/wick/wick-interface-types/src/field.rs
@@ -32,12 +32,16 @@ pub struct Field {
 
 impl Field {
   pub fn new(name: impl AsRef<str>, ty: Type) -> Self {
+    Self::new_with_description(name, ty, None)
+  }
+
+  pub fn new_with_description(name: impl AsRef<str>, ty: Type, desc: Option<String>) -> Self {
     Self {
       name: name.as_ref().to_owned(),
-      description: None,
+      description: desc,
       #[cfg(feature = "value")]
       default: None,
-      required: true,
+      required: !matches!(ty, Type::Optional { .. }),
       ty,
     }
   }
diff --git a/crates/wick/wick-packet/src/validation.rs b/crates/wick/wick-packet/src/validation.rs
index a5ee8b337..f0958ffb3 100644
--- a/crates/wick/wick-packet/src/validation.rs
+++ b/crates/wick/wick-packet/src/validation.rs
@@ -1,9 +1,14 @@
 use tracing::warn;
-use wick_interface_types::Field;
+use wick_interface_types::{Field, Type};
 
 use crate::{Error, RuntimeConfig};
 
 pub fn expect_configuration_matches(name: &str, config: Option<&RuntimeConfig>, fields: &[Field]) -> Result<(), Error> {
+  let fields = fields
+    .iter()
+    .filter(|f| f.required() || !matches!(f.ty(), Type::Optional { .. }))
+    .collect::<Vec<&Field>>();
+
   if config.is_none() {
     if fields.is_empty() {
       return Ok(());
@@ -47,6 +52,9 @@ mod test {
 
   #[rstest::rstest]
   #[case(json!({"required_field": "value"}),vec![Field::new("required_field", Type::String)])]
+  #[case(json!({"optional_field": "value"}),vec![Field::new("optional_field", Type::Optional { ty: Box::new(Type::String)})])]
+  #[case(json!({"optional_field": serde_json::Value::Null}),vec![Field::new("optional_field", Type::Optional { ty: Box::new(Type::String)})])]
+  #[case(json!({}),vec![Field::new("optional_field", Type::Optional { ty: Box::new(Type::String)})])]
   #[case(json!({}),vec![])]
 
   fn config_validation_positive(#[case] config: serde_json::Value, #[case] fields: Vec<Field>) -> Result<()> {


Test patch:
diff --git a/crates/wick/flow-graph-interpreter/tests/test/mod.rs b/crates/wick/flow-graph-interpreter/tests/test/mod.rs
index 351ee251b..713e7caf9 100644
--- a/crates/wick/flow-graph-interpreter/tests/test/mod.rs
+++ b/crates/wick/flow-graph-interpreter/tests/test/mod.rs
@@ -38,7 +38,7 @@ pub(crate) async fn base_setup(
   use tokio_stream::StreamExt;
   use wick_packet::{Entity, Invocation};
   let options = Some(InterpreterOptions { ..Default::default() });
-  let mut def = wick_config::WickConfiguration::load_from_file_sync(manifest)?;
+  let mut def = wick_config::WickConfiguration::fetch(manifest, Default::default()).await?;
   def.set_root_config(component_config);
   let mut def = def.finish()?.try_component_config()?;
   let network = from_def(&mut def)?;
diff --git a/crates/wick/wick-runtime/tests/manifests/v1/component-context-vars-passthrough-child.yaml b/crates/wick/wick-runtime/tests/manifests/v1/component-context-vars-passthrough-child.yaml
new file mode 100644
index 000000000..5eeecbc3c
--- /dev/null
+++ b/crates/wick/wick-runtime/tests/manifests/v1/component-context-vars-passthrough-child.yaml
@@ -0,0 +1,28 @@
+---
+name: 'test'
+kind: wick/component@v1
+metadata:
+  version: '0.0.2'
+component:
+  kind: wick/component/composite@v1
+  with:
+    - name: required
+      type: string
+    - name: optional
+      type: string?
+  operations:
+    - name: test
+      with:
+        - name: required
+          type: string
+        - name: optional
+          type: string?
+      uses:
+        - name: SENDER
+          operation: core::sender
+          with:
+            output: 'root_required: {{ ctx.root_config.required }}, root_optional: {{ ctx.root_config.optional | default: "" }}, required: {{ ctx.config.required }}, optional: {{ ctx.config.optional | default: "" }}'
+      flow:
+        - <>.input -> wick::string::concat[a].left
+        - SENDER.output -> a.right
+        - a.output -> <>
diff --git a/crates/wick/wick-runtime/tests/manifests/v1/component-context-vars-passthrough.yaml b/crates/wick/wick-runtime/tests/manifests/v1/component-context-vars-passthrough.yaml
new file mode 100644
index 000000000..968881db6
--- /dev/null
+++ b/crates/wick/wick-runtime/tests/manifests/v1/component-context-vars-passthrough.yaml
@@ -0,0 +1,36 @@
+---
+name: 'test'
+kind: wick/component@v1
+metadata:
+  version: '0.0.2'
+import:
+  - name: child
+    component:
+      kind: wick/component/manifest@v1
+      ref: ./component-context-vars-passthrough-child.yaml
+      with:
+        required: '{{ ctx.root_config.required }}'
+        optional: '{% if ctx.root_config contains "optional" %}{{ ctx.root_config.optional | output }}{% endif %}{% unless ctx.root_config contains "optional" %}{{ nil | output }}{% endunless %}'
+component:
+  kind: wick/component/composite@v1
+  with:
+    - name: required
+      type: string
+    - name: optional
+      type: string?
+  operations:
+    - name: test
+      with:
+        - name: required
+          type: string
+        - name: optional
+          type: string?
+      uses:
+        - name: CHILD_OP
+          operation: child::test
+          with:
+            required: '{{ ctx.config.required }}'
+            optional: '{% if ctx.config contains "optional" %}{{ ctx.config.optional | output }}{% endif %}{% unless ctx.config contains "optional" %}{{ nil | output }}{% endunless %}'
+      flow:
+        - <>.input -> CHILD_OP.input
+        - CHILD_OP.output -> <>.output
diff --git a/crates/wick/wick-runtime/tests/utils/mod.rs b/crates/wick/wick-runtime/tests/utils/mod.rs
index efe9add49..628d414cb 100644
--- a/crates/wick/wick-runtime/tests/utils/mod.rs
+++ b/crates/wick/wick-runtime/tests/utils/mod.rs
@@ -25,7 +25,7 @@ pub async fn common_test(
   target: &str,
   mut expected: Vec<Packet>,
 ) -> anyhow::Result<()> {
-  base_test(path, stream, Entity::local(target), expected, None).await
+  base_test(path, stream, Entity::local(target), expected, None, None).await
 }
 
 #[allow(unused)]
@@ -34,9 +34,10 @@ pub async fn test_with_config(
   stream: PacketStream,
   target: &str,
   mut expected: Vec<Packet>,
-  config: RuntimeConfig,
+  root_config: Option<RuntimeConfig>,
+  config: Option<RuntimeConfig>,
 ) -> anyhow::Result<()> {
-  base_test(path, stream, Entity::local(target), expected, Some(config)).await
+  base_test(path, stream, Entity::local(target), expected, root_config, config).await
 }
 
 #[allow(unused)]
@@ -45,10 +46,11 @@ pub async fn base_test(
   stream: PacketStream,
   target: Entity,
   mut expected: Vec<Packet>,
+  root_config: Option<RuntimeConfig>,
   config: Option<RuntimeConfig>,
 ) -> anyhow::Result<()> {
   let cwd = std::env::current_dir()?;
-  let (engine, _) = init_engine_from_yaml(path, config).await?;
+  let (engine, _) = init_engine_from_yaml(path, root_config).await?;
   let inherent = InherentData::new(1, 1000);
 
   let target = if target.component_id() == Entity::LOCAL {
@@ -60,7 +62,7 @@ pub async fn base_test(
   let result = engine
     .invoke(
       Invocation::test("simple schematic", target, stream, Some(inherent))?,
-      Default::default(),
+      config,
     )
     .await?;
 
diff --git a/crates/wick/wick-runtime/tests/v1_components.rs b/crates/wick/wick-runtime/tests/v1_components.rs
index 3415ce25b..5c6ab146d 100644
--- a/crates/wick/wick-runtime/tests/v1_components.rs
+++ b/crates/wick/wick-runtime/tests/v1_components.rs
@@ -1,6 +1,9 @@
 mod utils;
+use std::collections::HashMap;
+
+use serde_json::json;
 use utils::*;
-use wick_packet::{packet_stream, Packet};
+use wick_packet::{packet_stream, packets, Packet, RuntimeConfig};
 
 type Result<T> = anyhow::Result<T, anyhow::Error>;
 
@@ -35,3 +38,78 @@ async fn composite_inherit() -> Result<()> {
   )
   .await
 }
+
+#[test_logger::test(tokio::test)]
+async fn test_context_passthrough() -> Result<()> {
+  test_context_passthrough_base(
+    RuntimeConfig::from(HashMap::from([
+      ("required".to_owned(), json!("required field")),
+      ("optional".to_owned(), json!("optional field")),
+    ])),
+    RuntimeConfig::from(HashMap::from([
+      ("required".to_owned(), json!("required field")),
+      ("optional".to_owned(), json!("optional field")),
+    ])),
+    vec![
+      Packet::encode("output", "[from input]root_required: required field, root_optional: optional field, required: required field, optional: optional field"),
+      Packet::done("output"),
+    ],
+  )
+  .await?;
+
+  Ok(())
+}
+
+#[test_logger::test(tokio::test)]
+async fn test_context_passthrough_root_config_opt() -> Result<()> {
+  test_context_passthrough_base(
+    RuntimeConfig::from(HashMap::from([("required".to_owned(), json!("required field"))])),
+    RuntimeConfig::from(HashMap::from([
+      ("required".to_owned(), json!("required field")),
+      ("optional".to_owned(), json!("optional field")),
+    ])),
+    vec![
+      Packet::encode("output", "[from input]root_required: required field, root_optional: , required: required field, optional: optional field"),
+      Packet::done("output"),
+    ],
+  )
+  .await?;
+
+  Ok(())
+}
+
+#[test_logger::test(tokio::test)]
+async fn test_context_passthrough_op_config_opt() -> Result<()> {
+  test_context_passthrough_base(
+    RuntimeConfig::from(HashMap::from([
+      ("required".to_owned(), json!("required field")),
+      ("optional".to_owned(), json!("optional field")),
+    ])),
+    RuntimeConfig::from(HashMap::from([("required".to_owned(), json!("required field"))])),
+    vec![
+      Packet::encode("output", "[from input]root_required: required field, root_optional: optional field, required: required field, optional: "),
+      Packet::done("output"),
+    ],
+  )
+  .await?;
+
+  Ok(())
+}
+
+async fn test_context_passthrough_base(
+  root_config: RuntimeConfig,
+  config: RuntimeConfig,
+  expected: Vec<Packet>,
+) -> Result<()> {
+  test_with_config(
+    "./tests/manifests/v1/component-context-vars-passthrough.yaml",
+    packets!(("input", "[from input]")).into(),
+    "test",
+    expected,
+    Some(root_config),
+    Some(config),
+  )
+  .await?;
+
+  Ok(())
+}
diff --git a/crates/wick/wick-runtime/tests/wasm.rs b/crates/wick/wick-runtime/tests/wasm.rs
index 8a0f212e2..51adf1b95 100644
--- a/crates/wick/wick-runtime/tests/wasm.rs
+++ b/crates/wick/wick-runtime/tests/wasm.rs
@@ -23,7 +23,8 @@ async fn good_wasm_component_v1() -> Result<()> {
     packet_stream!(("left", 10), ("right", 1001)),
     "add",
     vec![Packet::encode("output", 1011), Packet::done("output")],
-    json!({"default_err":"custom error"}).try_into()?,
+    Some(json!({"default_err":"custom error"}).try_into()?),
+    None,
   )
   .await
 }