pub mod inject;
pub mod validate;
use std::{collections::BTreeMap, path::Path};
use eyre::Context;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::types::TypeDef;
pub const MANIFEST_FILENAME: &str = "dora-node.yml";
pub const MANIFEST_API_VERSION: u64 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NodeManifest {
#[serde(rename = "apiVersion")]
pub api_version: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub namespace: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<Category>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
pub runtime: Runtime,
pub entrypoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub platforms: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dora: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub inputs: BTreeMap<String, PortDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub outputs: BTreeMap<String, PortDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, EnvVarDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub types: BTreeMap<String, TypeDef>,
#[serde(default, skip_serializing_if = "Requirements::is_empty")]
pub requirements: Requirements,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Runtime {
Python,
Rust,
C,
Cpp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Category {
Sensor,
Actuator,
Robot,
Transform,
Filter,
MlInference,
Llm,
Speech,
Communication,
Recorder,
Visualization,
Simulator,
Debug,
}
impl Category {
pub fn as_str(self) -> &'static str {
match self {
Category::Sensor => "sensor",
Category::Actuator => "actuator",
Category::Robot => "robot",
Category::Transform => "transform",
Category::Filter => "filter",
Category::MlInference => "ml-inference",
Category::Llm => "llm",
Category::Speech => "speech",
Category::Communication => "communication",
Category::Recorder => "recorder",
Category::Visualization => "visualization",
Category::Simulator => "simulator",
Category::Debug => "debug",
}
}
}
impl std::fmt::Display for Category {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PortDef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl PortDef {
pub fn is_required(&self) -> bool {
self.required.unwrap_or(true)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EnvVarDef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<EnvVarType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<EnvDefault>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum EnvDefault {
Bool(bool),
Integer(i64),
Float(f64),
String(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum EnvVarType {
String,
Int,
Float,
Bool,
}
impl EnvVarType {
pub fn as_str(self) -> &'static str {
match self {
EnvVarType::String => "string",
EnvVarType::Int => "int",
EnvVarType::Float => "float",
EnvVarType::Bool => "bool",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Requirements {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hardware: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub system: BTreeMap<String, String>,
}
impl Requirements {
fn is_empty(&self) -> bool {
self.hardware.is_empty() && self.system.is_empty()
}
}
pub const MAX_MANIFEST_SIZE: usize = 1024 * 1024;
impl NodeManifest {
pub fn parse(yaml: &str) -> eyre::Result<Self> {
if yaml.len() > MAX_MANIFEST_SIZE {
eyre::bail!(
"node manifest too large ({} bytes, max {MAX_MANIFEST_SIZE})",
yaml.len()
);
}
serde_yaml::from_str(yaml).context("failed to parse node manifest")
}
pub fn read(path: &Path) -> eyre::Result<Self> {
use std::io::Read as _;
let file = std::fs::File::open(path)
.with_context(|| format!("failed to read node manifest at `{}`", path.display()))?;
let mut content = String::new();
file.take(MAX_MANIFEST_SIZE as u64 + 1)
.read_to_string(&mut content)
.with_context(|| format!("failed to read node manifest at `{}`", path.display()))?;
Self::parse(&content)
.with_context(|| format!("invalid node manifest at `{}`", path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC_EXAMPLE: &str = r#"
apiVersion: 1
name: dora-yolo
namespace: dora-rs
description: YOLO object detection on camera frames
categories: [ml-inference]
keywords: [vision, detection, yolo]
runtime: python
entrypoint: dora-yolo
platforms: []
dora: ">=0.4"
inputs:
image:
type: std/media/v1/Image
required: true
description: BGR frame to run detection on
outputs:
bbox:
type: std/vision/v1/BBox2D
description: detected bounding boxes
env:
MODEL:
default: yolov8n.pt
description: model weights file or hub id
CONFIDENCE:
type: float
default: 0.4
requirements:
hardware: []
system: {}
example: |
- id: detector
hub: dora-yolo@^0.5
inputs:
image: camera/image
outputs:
- bbox
"#;
#[test]
fn parses_spec_example() {
let m = NodeManifest::parse(SPEC_EXAMPLE).unwrap();
assert_eq!(m.api_version, 1);
assert_eq!(m.name.as_deref(), Some("dora-yolo"));
assert_eq!(m.namespace, "dora-rs");
assert_eq!(m.runtime, Runtime::Python);
assert_eq!(m.entrypoint, "dora-yolo");
assert_eq!(m.categories, vec![Category::MlInference]);
assert_eq!(
m.inputs["image"].r#type.as_deref(),
Some("std/media/v1/Image")
);
assert!(m.inputs["image"].is_required());
assert_eq!(
m.outputs["bbox"].r#type.as_deref(),
Some("std/vision/v1/BBox2D")
);
assert_eq!(m.env["CONFIDENCE"].r#type, Some(EnvVarType::Float));
assert!(m.example.is_some());
}
#[test]
fn ports_may_be_empty() {
let m = NodeManifest::parse(
r#"
apiVersion: 1
name: dora-recorder
namespace: dora-rs
runtime: rust
entrypoint: target/release/dora-recorder
inputs:
data:
type: std/core/v1/Bytes
"#,
)
.unwrap();
assert!(m.outputs.is_empty());
assert_eq!(m.inputs.len(), 1);
}
#[test]
fn unknown_fields_rejected() {
let err = NodeManifest::parse(
r#"
apiVersion: 1
name: x
namespace: y
runtime: python
entrypoint: x
no_such_field: true
"#,
)
.unwrap_err();
assert!(format!("{err:#}").contains("no_such_field"), "{err:#}");
}
#[test]
fn env_defaults_stay_literal() {
let m = NodeManifest::parse(
r#"
apiVersion: 1
name: x
namespace: y
runtime: python
entrypoint: x
env:
MODEL_PATH:
default: $HOME/weights.pt
"#,
)
.unwrap();
assert_eq!(
m.env["MODEL_PATH"].default,
Some(EnvDefault::String("$HOME/weights.pt".into()))
);
}
#[test]
fn checked_in_schema_is_current() {
let schema = schemars::schema_for!(NodeManifest);
let expected = serde_json::to_value(&schema).unwrap();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("dora-node-schema.json");
let on_disk: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
on_disk, expected,
"dora-node-schema.json is stale — run `cargo run -p dora-core --bin generate_schema`"
);
}
#[test]
fn rejects_oversized_manifest() {
let huge = format!("apiVersion: 1\n# {}", "x".repeat(MAX_MANIFEST_SIZE));
let err = NodeManifest::parse(&huge).unwrap_err();
assert!(format!("{err:#}").contains("too large"), "{err:#}");
}
#[test]
fn roundtrips_through_serde() {
let m = NodeManifest::parse(SPEC_EXAMPLE).unwrap();
let yaml = serde_yaml::to_string(&m).unwrap();
let again = NodeManifest::parse(&yaml).unwrap();
assert_eq!(again.name, m.name);
assert_eq!(again.inputs.len(), m.inputs.len());
assert_eq!(again.env.len(), m.env.len());
}
}