use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::card::common::{CredentialRef, NonSecretValue};
use crate::card::verifier::VerificationBinding;
use crate::reference::{CardRef, Ref};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ServiceSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<ServiceComponent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub verified_by: Vec<VerificationBinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tables: Vec<ServiceTable>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_point: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub deployment: BTreeMap<String, NonSecretValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<ServiceRuntime>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub service_config: BTreeMap<String, NonSecretValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credential_refs: Vec<CredentialRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lock_hash: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, NonSecretValue>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ServiceTable {
pub name: String,
pub schema: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct ServiceRuntime {
pub kind: ServiceRuntimeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framework: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<ServiceRuntimeMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub config: BTreeMap<String, NonSecretValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<ServiceRuntimePolicy>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum ServiceRuntimeKind {
Api,
Mcp,
Agent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum ServiceRuntimeMode {
InProcess,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct ServiceRuntimePolicy {
#[serde(default)]
pub runtime_hooks: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct ServiceComponent {
pub alias: String,
#[serde(rename = "ref")]
pub card_ref: Ref,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub verified_by: Vec<VerificationBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<ComponentSource>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub config: BTreeMap<String, NonSecretValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credential_refs: Vec<CredentialRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct ComponentSource {
pub path: String,
pub kind: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct ServiceLock {
#[serde(rename = "ref")]
pub service_ref: CardRef,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<LockedComponent>,
pub lock_hash: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, NonSecretValue>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct LockedComponent {
pub alias: String,
#[serde(rename = "ref")]
pub card_ref: CardRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, NonSecretValue>,
}
#[cfg(test)]
mod runtime_tests {
use crate::card::common::NonSecretValue;
use crate::card::service::{
ServiceRuntime, ServiceRuntimeKind, ServiceRuntimeMode, ServiceRuntimePolicy, ServiceSpec,
};
use crate::envelope::{Card, Spec};
use crate::format;
use proptest::prelude::*;
use std::collections::BTreeMap;
#[test]
fn legacy_service_spec_without_runtime_round_trips_without_runtime_key() {
let fixture = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/legacy-service-spec-without-runtime.json"
));
let decoded: ServiceSpec = serde_json::from_str(fixture).unwrap();
assert_eq!(decoded.runtime, None);
let encoded = serde_json::to_string(&decoded).unwrap();
assert_eq!(encoded, fixture.trim());
}
#[test]
fn service_runtime_fixture_round_trips() {
let decoded: Card = format::yaml::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/service-with-runtime-policy.yaml"
)))
.unwrap();
let Spec::Service(spec) = &decoded.spec else {
panic!("expected Service spec");
};
let runtime = spec.runtime.as_ref().expect("runtime");
assert_eq!(runtime.kind, ServiceRuntimeKind::Agent);
assert_eq!(runtime.mode, Some(ServiceRuntimeMode::InProcess));
assert_eq!(runtime.strict, Some(true));
assert!(runtime.policy.as_ref().unwrap().runtime_hooks);
let encoded = format::yaml::to_string(&decoded).unwrap();
let reparsed: Card = format::yaml::from_str(&encoded).unwrap();
assert_eq!(reparsed, decoded);
}
fn runtime_strategy() -> impl Strategy<Value = ServiceRuntime> {
let kind = prop::sample::select(vec![
ServiceRuntimeKind::Api,
ServiceRuntimeKind::Mcp,
ServiceRuntimeKind::Agent,
]);
let framework = prop::option::of("[a-z][a-z0-9_-]{0,12}".prop_map(String::from));
let mode = prop::option::of(Just(ServiceRuntimeMode::InProcess));
let strict = prop::option::of(any::<bool>());
let policy = prop::option::of(
any::<bool>().prop_map(|runtime_hooks| ServiceRuntimePolicy { runtime_hooks }),
);
let config = prop_oneof![
Just(BTreeMap::new()),
Just(BTreeMap::from([(
"max_concurrent_invocations".to_string(),
NonSecretValue::Number(4.0),
)])),
Just(BTreeMap::from([(
"queue".to_string(),
NonSecretValue::Str("default".to_string()),
)])),
];
(kind, framework, mode, strict, policy, config).prop_map(
|(kind, framework, mode, strict, policy, config)| ServiceRuntime {
kind,
framework,
mode,
strict,
config,
policy,
},
)
}
proptest! {
#[test]
fn service_runtime_round_trips(runtime in runtime_strategy()) {
let encoded = serde_json::to_string(&runtime).unwrap();
let decoded: ServiceRuntime = serde_json::from_str(&encoded).unwrap();
prop_assert_eq!(decoded, runtime);
}
}
}