Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Custom Object Types

Custom and unknown STIX types (x-* or anything else) already parse and match with zero registration — they become generic value-backed objects that preserve every property. Registration is for the three things the generic path can’t give you: typed access, validation, and computed properties.

Rust: typed structs

Register your own struct for a type; parsed bundles then carry your type, and you can downcast back to it:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use stix::model::{ModelRegistry, ObjectView, StixValue};

#[derive(Debug, Serialize, Deserialize)]
struct AcmeWidget { #[serde(rename = "type")] type_: String, id: String, risk_score: i64 }

impl ObjectView for AcmeWidget {
    fn id(&self) -> Option<&str> { Some(&self.id) }
    fn type_(&self) -> Option<&str> { Some(&self.type_) }
    fn property(&self, name: &str) -> Option<StixValue> {
        match name {
            "risk_score" => Some(StixValue::Integer(self.risk_score)),
            // a computed property — synthesized, not stored in the JSON:
            "risk_band" => Some(StixValue::String(
                if self.risk_score > 80 { "high" } else { "low" }.into())),
            _ => None,
        }
    }
}

let mut registry = ModelRegistry::new();
registry.register::<AcmeWidget>("x-acme-widget");
let bundle = registry.parse_bundle(json)?;

// typed access after parsing:
if let Some(w) = bundle.objects[0].downcast_ref::<AcmeWidget>() { /* w.risk_score */ }
}

Because risk_band is exposed through ObjectView, the pattern [x-acme-widget:risk_band = 'high'] matches a property that never existed in the JSON. A runnable version lives in the repo: cargo run -p stix-rust --example custom_model.

Rust: data-level hooks

No struct needed — register a Value → Result<Value> hook for validation and enrichment. It runs once per object at parse time; the result is stored as data, so matching stays callback-free:

#![allow(unused)]
fn main() {
registry.register_handler("x-acme-widget", |mut obj| {
    if obj.get("risk_score").is_none() {
        return Err(stix::model::ModelError::InvalidObject("missing risk_score".into()));
    }
    let score = obj["risk_score"].as_i64().unwrap_or(0);
    obj["risk_band"] = serde_json::json!(if score > 80 { "high" } else { "low" });
    Ok(obj)
});
}

A hook rejection surfaces from parse_bundle as a validation error.

The same idea in every binding

Each binding exposes the identical import-time hook — a host function taking and returning the object; throwing rejects it (a ValidationError / ValidationException):

# Python
engine.register_type("x-acme-widget", lambda obj: {**obj,
    "risk_band": "high" if obj.get("risk_score", 0) > 80 else "low"})
// TypeScript (Node and wasm)
engine.registerType("x-acme-widget", (obj) => ({
  ...obj, risk_band: obj.risk_score > 80 ? "high" : "low" }));
// Java
engine.registerType("x-acme-widget", obj -> {
    long score = ((Number) obj.getOrDefault("risk_score", 0)).longValue();
    obj.put("risk_band", score > 80 ? "high" : "low");
    return obj;
});

Hooks always run at parse/import time, synchronously — never during matching — so there is no cross-language callback overhead on the hot path.