mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 12:08:35 +00:00
feat: add function definition contract
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Immutable first-class Function and generated-column value model (B1a).
|
||||
//! Immutable first-class Function and generated-column value model (B1a/B1c).
|
||||
//!
|
||||
//! This module defines transport and metadata value types only. It does not
|
||||
//! provide catalogs, job execution, query planning, or generated-column runtime.
|
||||
|
||||
mod definition;
|
||||
|
||||
pub use definition::{FunctionCapability, FunctionDefinition, PythonFunctionDefinition};
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
@@ -251,6 +255,44 @@ impl FunctionSignature {
|
||||
pub fn output(&self) -> &FunctionOutput {
|
||||
&self.output
|
||||
}
|
||||
|
||||
fn to_wire(&self) -> Result<SignatureWire> {
|
||||
let parameters = self
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|parameter| {
|
||||
Ok(ParameterWire {
|
||||
name: parameter.name.clone(),
|
||||
data_type_ipc: encode_type_ipc_b64(¶meter.data_type)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(SignatureWire {
|
||||
parameters,
|
||||
output: OutputWire {
|
||||
data_type_ipc: encode_type_ipc_b64(&self.output.data_type)?,
|
||||
nullable: self.output.nullable,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn from_wire(wire: SignatureWire) -> Result<Self> {
|
||||
let parameters = wire
|
||||
.parameters
|
||||
.into_iter()
|
||||
.map(|parameter| {
|
||||
Ok(FunctionParameter::new(
|
||||
parameter.name,
|
||||
decode_type_ipc_b64(¶meter.data_type_ipc)?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let output = FunctionOutput::new(
|
||||
decode_type_ipc_b64(&wire.output.data_type_ipc)?,
|
||||
wire.output.nullable,
|
||||
);
|
||||
Self::try_new(parameters, output)
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable first-class function value (format version 1).
|
||||
@@ -308,27 +350,10 @@ struct OutputWire {
|
||||
|
||||
impl Function {
|
||||
fn to_wire(&self) -> Result<FunctionWire> {
|
||||
let parameters = self
|
||||
.signature
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|parameter| {
|
||||
Ok(ParameterWire {
|
||||
name: parameter.name.clone(),
|
||||
data_type_ipc: encode_type_ipc_b64(¶meter.data_type)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(FunctionWire {
|
||||
format_version: FORMAT_VERSION_V1,
|
||||
id: self.id.value.clone(),
|
||||
signature: SignatureWire {
|
||||
parameters,
|
||||
output: OutputWire {
|
||||
data_type_ipc: encode_type_ipc_b64(&self.signature.output.data_type)?,
|
||||
nullable: self.signature.output.nullable,
|
||||
},
|
||||
},
|
||||
signature: self.signature.to_wire()?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,22 +365,7 @@ impl Function {
|
||||
)));
|
||||
}
|
||||
let id = FunctionId::try_new(wire.id)?;
|
||||
let parameters = wire
|
||||
.signature
|
||||
.parameters
|
||||
.into_iter()
|
||||
.map(|parameter| {
|
||||
Ok(FunctionParameter::new(
|
||||
parameter.name,
|
||||
decode_type_ipc_b64(¶meter.data_type_ipc)?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let output = FunctionOutput::new(
|
||||
decode_type_ipc_b64(&wire.signature.output.data_type_ipc)?,
|
||||
wire.signature.output.nullable,
|
||||
);
|
||||
let signature = FunctionSignature::try_new(parameters, output)?;
|
||||
let signature = FunctionSignature::from_wire(wire.signature)?;
|
||||
Ok(Self { id, signature })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Immutable FunctionDefinition registration input (B1c / FF-007).
|
||||
//!
|
||||
//! These types are authoring/transport values only. They do not mint identity,
|
||||
//! store digests/artifacts, or execute Python.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::Error as DeError;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use super::{FunctionSignature, SignatureWire, invalid_input};
|
||||
use crate::Result;
|
||||
|
||||
const FORMAT_VERSION_V1: u32 = 1;
|
||||
|
||||
/// Immutable Python implementation description for a [`FunctionDefinition`].
|
||||
///
|
||||
/// The source body is carried on the trusted registration wire but is omitted
|
||||
/// from [`Debug`] output.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct PythonFunctionDefinition {
|
||||
module: String,
|
||||
callable: String,
|
||||
source: String,
|
||||
python: String,
|
||||
packages: Vec<String>,
|
||||
}
|
||||
|
||||
impl PythonFunctionDefinition {
|
||||
/// Create a Python implementation description.
|
||||
///
|
||||
/// Rejects empty `module`, `callable`, `source`, `python`, or any empty
|
||||
/// package requirement, and rejects duplicate package requirement strings.
|
||||
pub fn try_new(
|
||||
module: impl Into<String>,
|
||||
callable: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
python: impl Into<String>,
|
||||
packages: Vec<String>,
|
||||
) -> Result<Self> {
|
||||
let module = module.into();
|
||||
let callable = callable.into();
|
||||
let source = source.into();
|
||||
let python = python.into();
|
||||
validate_python_fields(&module, &callable, &source, &python, &packages)?;
|
||||
Ok(Self {
|
||||
module,
|
||||
callable,
|
||||
source,
|
||||
python,
|
||||
packages,
|
||||
})
|
||||
}
|
||||
|
||||
/// Python module name.
|
||||
pub fn module(&self) -> &str {
|
||||
&self.module
|
||||
}
|
||||
|
||||
/// Callable name within the module.
|
||||
pub fn callable(&self) -> &str {
|
||||
&self.callable
|
||||
}
|
||||
|
||||
/// Source body submitted at the trusted registration boundary.
|
||||
pub fn source(&self) -> &str {
|
||||
&self.source
|
||||
}
|
||||
|
||||
/// Requested Python runtime version string.
|
||||
pub fn python(&self) -> &str {
|
||||
&self.python
|
||||
}
|
||||
|
||||
/// Ordered package requirements.
|
||||
pub fn packages(&self) -> &[String] {
|
||||
&self.packages
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for PythonFunctionDefinition {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PythonFunctionDefinition")
|
||||
.field("module", &self.module)
|
||||
.field("callable", &self.callable)
|
||||
.field("source", &"<redacted>")
|
||||
.field("python", &self.python)
|
||||
.field("packages", &self.packages)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_python_fields(
|
||||
module: &str,
|
||||
callable: &str,
|
||||
source: &str,
|
||||
python: &str,
|
||||
packages: &[String],
|
||||
) -> Result<()> {
|
||||
if module.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"PythonFunctionDefinition module must be non-empty",
|
||||
));
|
||||
}
|
||||
if callable.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"PythonFunctionDefinition callable must be non-empty",
|
||||
));
|
||||
}
|
||||
if source.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"PythonFunctionDefinition source must be non-empty",
|
||||
));
|
||||
}
|
||||
if python.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"PythonFunctionDefinition python must be non-empty",
|
||||
));
|
||||
}
|
||||
let mut seen = HashSet::with_capacity(packages.len());
|
||||
for package in packages {
|
||||
if package.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"PythonFunctionDefinition package must be non-empty",
|
||||
));
|
||||
}
|
||||
if !seen.insert(package.as_str()) {
|
||||
return Err(invalid_input(
|
||||
"PythonFunctionDefinition packages must not contain duplicates",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Explicit capability grant attached to a [`FunctionDefinition`].
|
||||
///
|
||||
/// Secret references are carried on the trusted registration wire but are
|
||||
/// omitted from [`Debug`] output. Plaintext secret values are never part of
|
||||
/// this type.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct FunctionCapability {
|
||||
kind: FunctionCapabilityKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum FunctionCapabilityKind {
|
||||
Network {
|
||||
origin: String,
|
||||
},
|
||||
Secret {
|
||||
reference: String,
|
||||
environment_variable: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FunctionCapability {
|
||||
/// Create a network capability for a non-empty origin.
|
||||
pub fn try_network(origin: impl Into<String>) -> Result<Self> {
|
||||
let origin = origin.into();
|
||||
if origin.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"FunctionCapability network origin must be non-empty",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
kind: FunctionCapabilityKind::Network { origin },
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a secret capability for a non-empty reference and environment variable.
|
||||
///
|
||||
/// Errors name the fields and never echo the reference value.
|
||||
pub fn try_secret(
|
||||
reference: impl Into<String>,
|
||||
environment_variable: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
let reference = reference.into();
|
||||
let environment_variable = environment_variable.into();
|
||||
if reference.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"FunctionCapability secret reference must be non-empty",
|
||||
));
|
||||
}
|
||||
if environment_variable.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"FunctionCapability secret environment_variable must be non-empty",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
kind: FunctionCapabilityKind::Secret {
|
||||
reference,
|
||||
environment_variable,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Network origin when this capability is a network grant.
|
||||
pub fn origin(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
FunctionCapabilityKind::Network { origin } => Some(origin.as_str()),
|
||||
FunctionCapabilityKind::Secret { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret reference when this capability is a secret grant.
|
||||
pub fn reference(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
FunctionCapabilityKind::Secret { reference, .. } => Some(reference.as_str()),
|
||||
FunctionCapabilityKind::Network { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment variable name when this capability is a secret grant.
|
||||
pub fn environment_variable(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
FunctionCapabilityKind::Secret {
|
||||
environment_variable,
|
||||
..
|
||||
} => Some(environment_variable.as_str()),
|
||||
FunctionCapabilityKind::Network { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_wire(&self) -> CapabilityWire {
|
||||
match &self.kind {
|
||||
FunctionCapabilityKind::Network { origin } => CapabilityWire::Network {
|
||||
origin: origin.clone(),
|
||||
},
|
||||
FunctionCapabilityKind::Secret {
|
||||
reference,
|
||||
environment_variable,
|
||||
} => CapabilityWire::Secret {
|
||||
reference: reference.clone(),
|
||||
environment_variable: environment_variable.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn from_wire(wire: CapabilityWire) -> Result<Self> {
|
||||
match wire {
|
||||
CapabilityWire::Network { origin } => Self::try_network(origin),
|
||||
CapabilityWire::Secret {
|
||||
reference,
|
||||
environment_variable,
|
||||
} => Self::try_secret(reference, environment_variable),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FunctionCapability {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.kind {
|
||||
FunctionCapabilityKind::Network { origin } => f
|
||||
.debug_struct("FunctionCapability")
|
||||
.field("kind", &"network")
|
||||
.field("origin", origin)
|
||||
.finish(),
|
||||
FunctionCapabilityKind::Secret {
|
||||
environment_variable,
|
||||
..
|
||||
} => f
|
||||
.debug_struct("FunctionCapability")
|
||||
.field("kind", &"secret")
|
||||
.field("reference", &"<redacted>")
|
||||
.field("environment_variable", environment_variable)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable registration input for a first-class Function (format version 1).
|
||||
///
|
||||
/// This value has no catalog identity. Source bodies and secret references are
|
||||
/// present on the trusted serde wire but omitted from [`Debug`].
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct FunctionDefinition {
|
||||
signature: FunctionSignature,
|
||||
python_definition: PythonFunctionDefinition,
|
||||
capabilities: Vec<FunctionCapability>,
|
||||
}
|
||||
|
||||
impl FunctionDefinition {
|
||||
/// Create a definition from a signature, Python implementation, and capabilities.
|
||||
///
|
||||
/// Emptiness and package uniqueness are enforced by the child constructors.
|
||||
pub fn try_new(
|
||||
signature: FunctionSignature,
|
||||
python_definition: PythonFunctionDefinition,
|
||||
capabilities: Vec<FunctionCapability>,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
signature,
|
||||
python_definition,
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
/// Function signature.
|
||||
pub fn signature(&self) -> &FunctionSignature {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
/// Python implementation description.
|
||||
pub fn python_definition(&self) -> &PythonFunctionDefinition {
|
||||
&self.python_definition
|
||||
}
|
||||
|
||||
/// Ordered capability grants.
|
||||
pub fn capabilities(&self) -> &[FunctionCapability] {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
fn to_wire(&self) -> Result<FunctionDefinitionWire> {
|
||||
Ok(FunctionDefinitionWire {
|
||||
format_version: FORMAT_VERSION_V1,
|
||||
signature: self.signature.to_wire()?,
|
||||
implementation: ImplementationWire::Python {
|
||||
module: self.python_definition.module.clone(),
|
||||
callable: self.python_definition.callable.clone(),
|
||||
source: self.python_definition.source.clone(),
|
||||
python: self.python_definition.python.clone(),
|
||||
packages: self.python_definition.packages.clone(),
|
||||
},
|
||||
capabilities: self
|
||||
.capabilities
|
||||
.iter()
|
||||
.map(FunctionCapability::to_wire)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn from_wire(wire: FunctionDefinitionWire) -> Result<Self> {
|
||||
if wire.format_version != FORMAT_VERSION_V1 {
|
||||
return Err(invalid_input(format!(
|
||||
"unsupported FunctionDefinition format_version {}",
|
||||
wire.format_version
|
||||
)));
|
||||
}
|
||||
let signature = FunctionSignature::from_wire(wire.signature)?;
|
||||
let python_definition = match wire.implementation {
|
||||
ImplementationWire::Python {
|
||||
module,
|
||||
callable,
|
||||
source,
|
||||
python,
|
||||
packages,
|
||||
} => PythonFunctionDefinition::try_new(module, callable, source, python, packages)?,
|
||||
};
|
||||
let capabilities = wire
|
||||
.capabilities
|
||||
.into_iter()
|
||||
.map(FunctionCapability::from_wire)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Self::try_new(signature, python_definition, capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FunctionDefinition {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("FunctionDefinition")
|
||||
.field("signature", &self.signature)
|
||||
.field("python_definition", &self.python_definition)
|
||||
.field("capabilities", &self.capabilities)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// Do not derive Debug: wire payloads carry Python source and secret references.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FunctionDefinitionWire {
|
||||
format_version: u32,
|
||||
signature: SignatureWire,
|
||||
implementation: ImplementationWire,
|
||||
capabilities: Vec<CapabilityWire>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", deny_unknown_fields)]
|
||||
enum ImplementationWire {
|
||||
#[serde(rename = "python")]
|
||||
Python {
|
||||
module: String,
|
||||
callable: String,
|
||||
source: String,
|
||||
python: String,
|
||||
packages: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", deny_unknown_fields)]
|
||||
enum CapabilityWire {
|
||||
#[serde(rename = "network")]
|
||||
Network { origin: String },
|
||||
#[serde(rename = "secret")]
|
||||
Secret {
|
||||
reference: String,
|
||||
environment_variable: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Serialize for FunctionDefinition {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.to_wire()
|
||||
.map_err(serde::ser::Error::custom)?
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FunctionDefinition {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = FunctionDefinitionWire::deserialize(deserializer)?;
|
||||
Self::from_wire(wire).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Contract tests for FunctionDefinition registration input (FF-007 / B1c).
|
||||
//!
|
||||
//! These tests pin the intended public surface under [`lancedb::function`] for
|
||||
//! Python definition transport only. They intentionally fail to compile until
|
||||
//! that API exists.
|
||||
//!
|
||||
//! Rejection cases are judged by `Result` structure (`is_err` / `is_ok`), never
|
||||
//! by diagnostic message substrings.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use arrow_schema::DataType;
|
||||
use lancedb::Result;
|
||||
use lancedb::function::{
|
||||
Function, FunctionCapability, FunctionDefinition, FunctionId, FunctionOutput,
|
||||
FunctionParameter, FunctionSignature, PythonFunctionDefinition,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
fn sample_signature() -> Result<FunctionSignature> {
|
||||
FunctionSignature::try_new(
|
||||
vec![
|
||||
FunctionParameter::new("text", DataType::Utf8),
|
||||
FunctionParameter::new("limit", DataType::Int32),
|
||||
],
|
||||
FunctionOutput::new(DataType::Utf8, true),
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_source() -> &'static str {
|
||||
"def normalize(text, limit):\n return text[:limit]\n"
|
||||
}
|
||||
|
||||
fn sample_python_definition() -> Result<PythonFunctionDefinition> {
|
||||
PythonFunctionDefinition::try_new(
|
||||
"normalize_mod",
|
||||
"normalize",
|
||||
sample_source(),
|
||||
"3.12",
|
||||
vec!["Unidecode==1.3.8".to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_capabilities() -> Result<Vec<FunctionCapability>> {
|
||||
Ok(vec![
|
||||
FunctionCapability::try_network("https://api.example.com")?,
|
||||
FunctionCapability::try_secret("secret://team/api-token", "API_TOKEN")?,
|
||||
])
|
||||
}
|
||||
|
||||
fn sample_definition() -> Result<FunctionDefinition> {
|
||||
FunctionDefinition::try_new(
|
||||
sample_signature()?,
|
||||
sample_python_definition()?,
|
||||
sample_capabilities()?,
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_json_object_keys_exact(value: &Value, expected: &[&str]) {
|
||||
let object = value
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("expected JSON object, got {value}"));
|
||||
let keys: BTreeSet<&str> = object.keys().map(|k| k.as_str()).collect();
|
||||
let expected: BTreeSet<&str> = expected.iter().copied().collect();
|
||||
assert_eq!(
|
||||
keys, expected,
|
||||
"JSON object key set must match exactly (iteration order is not a contract); got {keys:?}, expected {expected:?} in {value}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_json_object_keys_subset(value: &Value, allowed: &[&str]) {
|
||||
let object = value
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("expected JSON object, got {value}"));
|
||||
for key in object.keys() {
|
||||
assert!(
|
||||
allowed.contains(&key.as_str()),
|
||||
"unexpected JSON key `{key}` in {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity / lineage / artifact / runtime fields that must not appear as public
|
||||
/// object keys on FunctionDefinition wire. Parameter object key `name` is not
|
||||
/// listed here: FunctionSignature legitimately uses it under `signature.parameters`.
|
||||
const FORBIDDEN_DEFINITION_KEYS: &[&str] = &[
|
||||
"id",
|
||||
"function_id",
|
||||
"FunctionId",
|
||||
"catalog",
|
||||
"catalog_name",
|
||||
"version",
|
||||
"function_version",
|
||||
"FunctionVersion",
|
||||
"lineage",
|
||||
"user_version",
|
||||
"idempotency_key",
|
||||
"digest",
|
||||
"artifact",
|
||||
"artifact_digest",
|
||||
"storage",
|
||||
"storage_location",
|
||||
"location",
|
||||
"deterministic",
|
||||
"null_policy",
|
||||
"nullPolicy",
|
||||
"timestamp",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"worker",
|
||||
"scheduler",
|
||||
"attempt",
|
||||
"attempt_id",
|
||||
"replica",
|
||||
"placement",
|
||||
];
|
||||
|
||||
fn assert_object_keys_not_forbidden(value: &Value, context: &str) {
|
||||
let object = value.as_object().unwrap_or_else(|| {
|
||||
panic!("expected JSON object at {context}, got {value}");
|
||||
});
|
||||
for key in object.keys() {
|
||||
assert!(
|
||||
!FORBIDDEN_DEFINITION_KEYS.contains(&key.as_str()),
|
||||
"FunctionDefinition wire must not contain forbidden key `{key}` at {context}: {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_forbidden_definition_keys_absent(value: &Value) {
|
||||
// Top-level definition key set (order-independent).
|
||||
assert_json_object_keys_exact(
|
||||
value,
|
||||
&[
|
||||
"format_version",
|
||||
"signature",
|
||||
"implementation",
|
||||
"capabilities",
|
||||
],
|
||||
);
|
||||
assert_object_keys_not_forbidden(value, "definition");
|
||||
// Catalog / function identity name is absent at definition root; parameter
|
||||
// `name` is allowed only under signature.parameters.
|
||||
assert!(
|
||||
value.get("name").is_none(),
|
||||
"top-level FunctionDefinition wire must not contain catalog/function identity key `name`: {value}"
|
||||
);
|
||||
assert!(
|
||||
value.get("catalog_name").is_none(),
|
||||
"top-level FunctionDefinition wire must not contain `catalog_name`: {value}"
|
||||
);
|
||||
|
||||
let implementation = value.get("implementation").expect("implementation object");
|
||||
assert_json_object_keys_exact(
|
||||
implementation,
|
||||
&["kind", "module", "callable", "source", "python", "packages"],
|
||||
);
|
||||
assert_object_keys_not_forbidden(implementation, "implementation");
|
||||
assert!(
|
||||
implementation.get("name").is_none(),
|
||||
"implementation must not contain catalog/function identity key `name`: {implementation}"
|
||||
);
|
||||
assert!(
|
||||
implementation.get("catalog_name").is_none(),
|
||||
"implementation must not contain `catalog_name`: {implementation}"
|
||||
);
|
||||
|
||||
let capabilities = value
|
||||
.get("capabilities")
|
||||
.and_then(Value::as_array)
|
||||
.expect("capabilities array");
|
||||
for (idx, capability) in capabilities.iter().enumerate() {
|
||||
let kind = capability
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| panic!("capabilities[{idx}] missing kind"));
|
||||
match kind {
|
||||
"network" => assert_json_object_keys_exact(capability, &["kind", "origin"]),
|
||||
"secret" => assert_json_object_keys_exact(
|
||||
capability,
|
||||
&["kind", "reference", "environment_variable"],
|
||||
),
|
||||
other => panic!("unexpected capability kind `{other}` in contract fixture"),
|
||||
}
|
||||
let context = format!("capabilities[{idx}]");
|
||||
assert_object_keys_not_forbidden(capability, &context);
|
||||
assert!(
|
||||
capability.get("name").is_none(),
|
||||
"{context} must not contain catalog/function identity key `name`: {capability}"
|
||||
);
|
||||
assert!(
|
||||
capability.get("catalog_name").is_none(),
|
||||
"{context} must not contain `catalog_name`: {capability}"
|
||||
);
|
||||
}
|
||||
|
||||
// Signature may carry parameter objects with key `name`. Still reject
|
||||
// identity/lineage/runtime keys and function-identity `name`/`catalog_name`
|
||||
// on the signature and output objects themselves.
|
||||
let signature = value.get("signature").expect("signature object");
|
||||
assert_object_keys_not_forbidden(signature, "signature");
|
||||
assert!(
|
||||
signature.get("name").is_none(),
|
||||
"signature object must not contain catalog/function identity key `name`: {signature}"
|
||||
);
|
||||
assert!(
|
||||
signature.get("catalog_name").is_none(),
|
||||
"signature object must not contain `catalog_name`: {signature}"
|
||||
);
|
||||
if let Some(parameters) = signature.get("parameters").and_then(Value::as_array) {
|
||||
for (idx, parameter) in parameters.iter().enumerate() {
|
||||
let context = format!("signature.parameters[{idx}]");
|
||||
assert_object_keys_not_forbidden(parameter, &context);
|
||||
assert!(
|
||||
parameter.get("catalog_name").is_none(),
|
||||
"{context} must not contain `catalog_name`: {parameter}"
|
||||
);
|
||||
// `name` is intentionally allowed on parameter objects.
|
||||
assert!(
|
||||
parameter.get("name").is_some(),
|
||||
"{context} must include parameter `name`"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(output) = signature.get("output") {
|
||||
assert_object_keys_not_forbidden(output, "signature.output");
|
||||
assert!(
|
||||
output.get("name").is_none(),
|
||||
"signature.output must not contain catalog/function identity key `name`: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.get("catalog_name").is_none(),
|
||||
"signature.output must not contain `catalog_name`: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_json_round_trip_pins_exact_wire_shape_and_order() -> Result<()> {
|
||||
let definition = sample_definition()?;
|
||||
|
||||
assert_eq!(definition.signature().parameters().len(), 2);
|
||||
assert_eq!(definition.signature().parameters()[0].name(), "text");
|
||||
assert_eq!(
|
||||
definition.signature().parameters()[0].data_type(),
|
||||
&DataType::Utf8
|
||||
);
|
||||
assert_eq!(definition.signature().parameters()[1].name(), "limit");
|
||||
assert_eq!(
|
||||
definition.signature().parameters()[1].data_type(),
|
||||
&DataType::Int32
|
||||
);
|
||||
assert_eq!(definition.signature().output().data_type(), &DataType::Utf8);
|
||||
assert!(definition.signature().output().nullable());
|
||||
|
||||
let python = definition.python_definition();
|
||||
assert_eq!(python.module(), "normalize_mod");
|
||||
assert_eq!(python.callable(), "normalize");
|
||||
assert_eq!(python.source(), sample_source());
|
||||
assert_eq!(python.python(), "3.12");
|
||||
assert_eq!(python.packages(), &["Unidecode==1.3.8".to_string()]);
|
||||
|
||||
let capabilities = definition.capabilities();
|
||||
assert_eq!(capabilities.len(), 2);
|
||||
assert_eq!(capabilities[0].origin(), Some("https://api.example.com"));
|
||||
assert_eq!(capabilities[0].reference(), None);
|
||||
assert_eq!(capabilities[0].environment_variable(), None);
|
||||
assert_eq!(capabilities[1].reference(), Some("secret://team/api-token"));
|
||||
assert_eq!(capabilities[1].environment_variable(), Some("API_TOKEN"));
|
||||
assert_eq!(capabilities[1].origin(), None);
|
||||
|
||||
let json = serde_json::to_value(&definition).expect("serialize FunctionDefinition");
|
||||
assert_json_object_keys_exact(
|
||||
&json,
|
||||
&[
|
||||
"format_version",
|
||||
"signature",
|
||||
"implementation",
|
||||
"capabilities",
|
||||
],
|
||||
);
|
||||
assert_eq!(json["format_version"], 1);
|
||||
|
||||
let signature = json
|
||||
.get("signature")
|
||||
.and_then(Value::as_object)
|
||||
.expect("signature object");
|
||||
assert_json_object_keys_subset(&Value::Object(signature.clone()), &["parameters", "output"]);
|
||||
let parameters = signature
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.expect("parameters array");
|
||||
assert_eq!(parameters.len(), 2);
|
||||
assert_eq!(parameters[0]["name"], Value::String("text".into()));
|
||||
assert_eq!(parameters[1]["name"], Value::String("limit".into()));
|
||||
for parameter in parameters {
|
||||
assert_json_object_keys_subset(parameter, &["name", "data_type_ipc"]);
|
||||
assert!(
|
||||
parameter
|
||||
.get("data_type_ipc")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty()),
|
||||
"parameter data_type_ipc must be non-empty base64"
|
||||
);
|
||||
}
|
||||
let output = signature
|
||||
.get("output")
|
||||
.and_then(Value::as_object)
|
||||
.expect("output object");
|
||||
assert_json_object_keys_subset(
|
||||
&Value::Object(output.clone()),
|
||||
&["data_type_ipc", "nullable"],
|
||||
);
|
||||
assert_eq!(output.get("nullable"), Some(&Value::Bool(true)));
|
||||
|
||||
let implementation = json.get("implementation").expect("implementation object");
|
||||
assert_json_object_keys_exact(
|
||||
implementation,
|
||||
&["kind", "module", "callable", "source", "python", "packages"],
|
||||
);
|
||||
assert_eq!(implementation["kind"], Value::String("python".into()));
|
||||
assert_eq!(
|
||||
implementation["module"],
|
||||
Value::String("normalize_mod".into())
|
||||
);
|
||||
assert_eq!(
|
||||
implementation["callable"],
|
||||
Value::String("normalize".into())
|
||||
);
|
||||
assert_eq!(
|
||||
implementation["source"],
|
||||
Value::String(sample_source().into())
|
||||
);
|
||||
assert_eq!(implementation["python"], Value::String("3.12".into()));
|
||||
assert_eq!(
|
||||
implementation["packages"],
|
||||
Value::Array(vec![Value::String("Unidecode==1.3.8".into())])
|
||||
);
|
||||
|
||||
let capabilities_json = json
|
||||
.get("capabilities")
|
||||
.and_then(Value::as_array)
|
||||
.expect("capabilities array");
|
||||
assert_eq!(capabilities_json.len(), 2);
|
||||
assert_json_object_keys_exact(&capabilities_json[0], &["kind", "origin"]);
|
||||
assert_eq!(
|
||||
capabilities_json[0]["kind"],
|
||||
Value::String("network".into())
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities_json[0]["origin"],
|
||||
Value::String("https://api.example.com".into())
|
||||
);
|
||||
assert_json_object_keys_exact(
|
||||
&capabilities_json[1],
|
||||
&["kind", "reference", "environment_variable"],
|
||||
);
|
||||
assert_eq!(capabilities_json[1]["kind"], Value::String("secret".into()));
|
||||
assert_eq!(
|
||||
capabilities_json[1]["reference"],
|
||||
Value::String("secret://team/api-token".into())
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities_json[1]["environment_variable"],
|
||||
Value::String("API_TOKEN".into())
|
||||
);
|
||||
|
||||
// Same ordered signature IPC representation as Function handle transport.
|
||||
let function = Function::new(FunctionId::try_new("fn.wire.compare")?, sample_signature()?);
|
||||
let function_json = serde_json::to_value(&function).expect("serialize Function");
|
||||
assert_eq!(json["signature"], function_json["signature"]);
|
||||
|
||||
let restored: FunctionDefinition =
|
||||
serde_json::from_value(json.clone()).expect("deserialize FunctionDefinition");
|
||||
assert_eq!(
|
||||
restored.signature().parameters()[0].name(),
|
||||
definition.signature().parameters()[0].name()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.signature().parameters()[0].data_type(),
|
||||
definition.signature().parameters()[0].data_type()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.signature().parameters()[1].name(),
|
||||
definition.signature().parameters()[1].name()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.signature().parameters()[1].data_type(),
|
||||
definition.signature().parameters()[1].data_type()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.signature().output().data_type(),
|
||||
definition.signature().output().data_type()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.signature().output().nullable(),
|
||||
definition.signature().output().nullable()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.python_definition().module(),
|
||||
definition.python_definition().module()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.python_definition().callable(),
|
||||
definition.python_definition().callable()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.python_definition().source(),
|
||||
definition.python_definition().source()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.python_definition().python(),
|
||||
definition.python_definition().python()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.python_definition().packages(),
|
||||
definition.python_definition().packages()
|
||||
);
|
||||
assert_eq!(restored.capabilities().len(), 2);
|
||||
assert_eq!(
|
||||
restored.capabilities()[0].origin(),
|
||||
definition.capabilities()[0].origin()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.capabilities()[1].reference(),
|
||||
definition.capabilities()[1].reference()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.capabilities()[1].environment_variable(),
|
||||
definition.capabilities()[1].environment_variable()
|
||||
);
|
||||
|
||||
// Package and capability order are part of the structural wire.
|
||||
let multi_pkg = FunctionDefinition::try_new(
|
||||
sample_signature()?,
|
||||
PythonFunctionDefinition::try_new(
|
||||
"normalize_mod",
|
||||
"normalize",
|
||||
sample_source(),
|
||||
"3.12",
|
||||
vec![
|
||||
"Unidecode==1.3.8".to_string(),
|
||||
"requests==2.32.3".to_string(),
|
||||
],
|
||||
)?,
|
||||
vec![
|
||||
FunctionCapability::try_secret("secret://team/api-token", "API_TOKEN")?,
|
||||
FunctionCapability::try_network("https://api.example.com")?,
|
||||
FunctionCapability::try_network("https://other.example.com")?,
|
||||
],
|
||||
)?;
|
||||
let multi_json = serde_json::to_value(&multi_pkg).expect("serialize multi-order definition");
|
||||
assert_eq!(
|
||||
multi_json["implementation"]["packages"],
|
||||
Value::Array(vec![
|
||||
Value::String("Unidecode==1.3.8".into()),
|
||||
Value::String("requests==2.32.3".into()),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
multi_json["capabilities"][0]["kind"],
|
||||
Value::String("secret".into())
|
||||
);
|
||||
assert_eq!(
|
||||
multi_json["capabilities"][1]["origin"],
|
||||
Value::String("https://api.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
multi_json["capabilities"][2]["origin"],
|
||||
Value::String("https://other.example.com".into())
|
||||
);
|
||||
let multi_restored: FunctionDefinition =
|
||||
serde_json::from_value(multi_json.clone()).expect("deserialize multi-order definition");
|
||||
assert_eq!(
|
||||
multi_restored.python_definition().packages(),
|
||||
&[
|
||||
"Unidecode==1.3.8".to_string(),
|
||||
"requests==2.32.3".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
multi_restored.capabilities()[0].reference(),
|
||||
Some("secret://team/api-token")
|
||||
);
|
||||
assert_eq!(
|
||||
multi_restored.capabilities()[1].origin(),
|
||||
Some("https://api.example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
multi_restored.capabilities()[2].origin(),
|
||||
Some("https://other.example.com")
|
||||
);
|
||||
|
||||
// Byte-for-byte repeated serde_json encoding for the same value.
|
||||
let encoded_a = serde_json::to_string(&definition).expect("encode a");
|
||||
let encoded_b = serde_json::to_string(&definition).expect("encode b");
|
||||
assert_eq!(encoded_a, encoded_b);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&restored).expect("re-serialize restored"),
|
||||
json
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&multi_restored).expect("re-encode multi"),
|
||||
serde_json::to_string(&multi_pkg).expect("encode multi")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_wire_excludes_identity_lineage_artifact_and_runtime_fields() -> Result<()> {
|
||||
let definition = sample_definition()?;
|
||||
let json = serde_json::to_value(&definition).expect("serialize FunctionDefinition");
|
||||
// Forbidden public fields are object keys at structural levels only.
|
||||
// Do not substring-scan encoded JSON: user source/reference/package text
|
||||
// may legitimately contain those tokens.
|
||||
assert_forbidden_definition_keys_absent(&json);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_decode_fails_closed_for_unknown_version_fields_and_kinds() -> Result<()> {
|
||||
let definition = sample_definition()?;
|
||||
let json = serde_json::to_value(&definition).expect("serialize FunctionDefinition");
|
||||
|
||||
let mut unknown_version = json.clone();
|
||||
unknown_version["format_version"] = Value::from(2);
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(unknown_version).is_err(),
|
||||
"format_version other than 1 must fail closed"
|
||||
);
|
||||
|
||||
let mut unknown_outer = json.clone();
|
||||
unknown_outer
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("unexpected_field".into(), Value::Bool(true));
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(unknown_outer).is_err(),
|
||||
"unknown outer field must fail closed"
|
||||
);
|
||||
|
||||
let mut unknown_implementation_field = json.clone();
|
||||
unknown_implementation_field["implementation"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("entrypoint".into(), Value::String("main".into()));
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(unknown_implementation_field).is_err(),
|
||||
"unknown nested implementation field must fail closed"
|
||||
);
|
||||
|
||||
let mut unknown_capability_field = json.clone();
|
||||
unknown_capability_field["capabilities"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("headers".into(), Value::Object(Default::default()));
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(unknown_capability_field).is_err(),
|
||||
"unknown nested capability field must fail closed"
|
||||
);
|
||||
|
||||
let mut unknown_implementation_kind = json.clone();
|
||||
unknown_implementation_kind["implementation"]["kind"] = Value::String("builtin".into());
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(unknown_implementation_kind).is_err(),
|
||||
"unknown implementation kind must fail closed"
|
||||
);
|
||||
|
||||
let mut unknown_capability_kind = json.clone();
|
||||
unknown_capability_kind["capabilities"][0]["kind"] = Value::String("filesystem".into());
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(unknown_capability_kind).is_err(),
|
||||
"unknown capability kind must fail closed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructors_and_decode_reject_empty_fields_and_duplicate_packages() -> Result<()> {
|
||||
let signature = sample_signature()?;
|
||||
let packages = vec!["Unidecode==1.3.8".to_string()];
|
||||
let capabilities = sample_capabilities()?;
|
||||
|
||||
assert!(
|
||||
PythonFunctionDefinition::try_new(
|
||||
"",
|
||||
"normalize",
|
||||
sample_source(),
|
||||
"3.12",
|
||||
packages.clone()
|
||||
)
|
||||
.is_err(),
|
||||
"empty module must be rejected"
|
||||
);
|
||||
assert!(
|
||||
PythonFunctionDefinition::try_new(
|
||||
"normalize_mod",
|
||||
"",
|
||||
sample_source(),
|
||||
"3.12",
|
||||
packages.clone()
|
||||
)
|
||||
.is_err(),
|
||||
"empty callable must be rejected"
|
||||
);
|
||||
assert!(
|
||||
PythonFunctionDefinition::try_new(
|
||||
"normalize_mod",
|
||||
"normalize",
|
||||
"",
|
||||
"3.12",
|
||||
packages.clone()
|
||||
)
|
||||
.is_err(),
|
||||
"empty source must be rejected"
|
||||
);
|
||||
assert!(
|
||||
PythonFunctionDefinition::try_new(
|
||||
"normalize_mod",
|
||||
"normalize",
|
||||
sample_source(),
|
||||
"",
|
||||
packages.clone()
|
||||
)
|
||||
.is_err(),
|
||||
"empty python runtime request must be rejected"
|
||||
);
|
||||
assert!(
|
||||
PythonFunctionDefinition::try_new(
|
||||
"normalize_mod",
|
||||
"normalize",
|
||||
sample_source(),
|
||||
"3.12",
|
||||
vec!["".to_string()],
|
||||
)
|
||||
.is_err(),
|
||||
"empty package requirement must be rejected"
|
||||
);
|
||||
assert!(
|
||||
PythonFunctionDefinition::try_new(
|
||||
"normalize_mod",
|
||||
"normalize",
|
||||
sample_source(),
|
||||
"3.12",
|
||||
vec![
|
||||
"Unidecode==1.3.8".to_string(),
|
||||
"Unidecode==1.3.8".to_string(),
|
||||
],
|
||||
)
|
||||
.is_err(),
|
||||
"duplicate package requirements must be rejected"
|
||||
);
|
||||
|
||||
assert!(
|
||||
FunctionCapability::try_network("").is_err(),
|
||||
"empty network origin must be rejected"
|
||||
);
|
||||
assert!(
|
||||
FunctionCapability::try_secret("", "API_TOKEN").is_err(),
|
||||
"empty secret reference must be rejected"
|
||||
);
|
||||
assert!(
|
||||
FunctionCapability::try_secret("secret://team/api-token", "").is_err(),
|
||||
"empty secret environment variable must be rejected"
|
||||
);
|
||||
|
||||
// Decode path must enforce the same emptiness / uniqueness rules.
|
||||
let definition = FunctionDefinition::try_new(
|
||||
signature.clone(),
|
||||
sample_python_definition()?,
|
||||
capabilities.clone(),
|
||||
)?;
|
||||
let json = serde_json::to_value(&definition).expect("serialize FunctionDefinition");
|
||||
|
||||
for (pointer, empty) in [
|
||||
("/implementation/module", ""),
|
||||
("/implementation/callable", ""),
|
||||
("/implementation/source", ""),
|
||||
("/implementation/python", ""),
|
||||
("/capabilities/0/origin", ""),
|
||||
("/capabilities/1/reference", ""),
|
||||
("/capabilities/1/environment_variable", ""),
|
||||
] {
|
||||
let mut invalid = json.clone();
|
||||
let target = invalid
|
||||
.pointer_mut(pointer)
|
||||
.unwrap_or_else(|| panic!("missing pointer {pointer}"));
|
||||
*target = Value::String(empty.into());
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(invalid).is_err(),
|
||||
"decode must reject empty value at {pointer}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut empty_package = json.clone();
|
||||
empty_package["implementation"]["packages"] = Value::Array(vec![Value::String("".into())]);
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(empty_package).is_err(),
|
||||
"decode must reject empty package requirement"
|
||||
);
|
||||
|
||||
let mut duplicate_packages = json.clone();
|
||||
duplicate_packages["implementation"]["packages"] = Value::Array(vec![
|
||||
Value::String("Unidecode==1.3.8".into()),
|
||||
Value::String("Unidecode==1.3.8".into()),
|
||||
]);
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(duplicate_packages).is_err(),
|
||||
"decode must reject duplicate package requirements"
|
||||
);
|
||||
|
||||
// Keep the constructor path for FunctionDefinition itself structurally valid
|
||||
// when children are valid; emptiness is owned by child constructors above.
|
||||
assert!(
|
||||
FunctionDefinition::try_new(signature, sample_python_definition()?, capabilities).is_ok()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_capability_wire_rejects_plaintext_value_fields() -> Result<()> {
|
||||
let definition = sample_definition()?;
|
||||
let json = serde_json::to_value(&definition).expect("serialize FunctionDefinition");
|
||||
let secret = &json["capabilities"][1];
|
||||
assert_json_object_keys_exact(secret, &["kind", "reference", "environment_variable"]);
|
||||
assert!(secret.get("value").is_none());
|
||||
assert!(secret.get("plaintext_secret").is_none());
|
||||
|
||||
let mut with_value = json.clone();
|
||||
with_value["capabilities"][1]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("value".into(), Value::String("super-secret".into()));
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(with_value).is_err(),
|
||||
"secret capability must reject `value`"
|
||||
);
|
||||
|
||||
let mut with_plaintext = json.clone();
|
||||
with_plaintext["capabilities"][1]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(
|
||||
"plaintext_secret".into(),
|
||||
Value::String("super-secret".into()),
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<FunctionDefinition>(with_plaintext).is_err(),
|
||||
"secret capability must reject `plaintext_secret`"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_source_and_secret_reference_while_getters_remain_exact() -> Result<()> {
|
||||
let python = sample_python_definition()?;
|
||||
let source = sample_source();
|
||||
assert_eq!(python.source(), source);
|
||||
let python_debug = format!("{python:?}");
|
||||
assert!(
|
||||
!python_debug.contains(source),
|
||||
"PythonFunctionDefinition Debug must not contain source body: {python_debug}"
|
||||
);
|
||||
assert!(
|
||||
!python_debug.contains("return text[:limit]"),
|
||||
"PythonFunctionDefinition Debug must not leak source fragments: {python_debug}"
|
||||
);
|
||||
|
||||
let secret = FunctionCapability::try_secret("secret://team/api-token", "API_TOKEN")?;
|
||||
assert_eq!(secret.reference(), Some("secret://team/api-token"));
|
||||
assert_eq!(secret.environment_variable(), Some("API_TOKEN"));
|
||||
let secret_debug = format!("{secret:?}");
|
||||
assert!(
|
||||
!secret_debug.contains("secret://team/api-token"),
|
||||
"FunctionCapability secret Debug must not contain reference: {secret_debug}"
|
||||
);
|
||||
|
||||
let definition = sample_definition()?;
|
||||
assert_eq!(definition.python_definition().source(), source);
|
||||
assert_eq!(
|
||||
definition.capabilities()[1].reference(),
|
||||
Some("secret://team/api-token")
|
||||
);
|
||||
let definition_debug = format!("{definition:?}");
|
||||
assert!(
|
||||
!definition_debug.contains(source),
|
||||
"FunctionDefinition Debug must not contain source body: {definition_debug}"
|
||||
);
|
||||
assert!(
|
||||
!definition_debug.contains("secret://team/api-token"),
|
||||
"FunctionDefinition Debug must not contain secret reference: {definition_debug}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_has_no_identity_before_registration_and_is_not_a_function_handle() -> Result<()> {
|
||||
let definition = sample_definition()?;
|
||||
let json = serde_json::to_value(&definition).expect("serialize FunctionDefinition");
|
||||
|
||||
assert!(json.get("id").is_none());
|
||||
assert!(json.get("function_id").is_none());
|
||||
assert_json_object_keys_exact(
|
||||
&json,
|
||||
&[
|
||||
"format_version",
|
||||
"signature",
|
||||
"implementation",
|
||||
"capabilities",
|
||||
],
|
||||
);
|
||||
|
||||
// Identity exists only on the immutable Function handle after registration.
|
||||
// Definition remains a separate authoring value and does not borrow or mint an ID.
|
||||
let registered = Function::new(
|
||||
FunctionId::try_new("fn.published.after.registration")?,
|
||||
definition.signature().clone(),
|
||||
);
|
||||
assert_eq!(registered.id().as_str(), "fn.published.after.registration");
|
||||
assert_eq!(
|
||||
registered.signature().parameters().len(),
|
||||
definition.signature().parameters().len()
|
||||
);
|
||||
|
||||
let definition_again = serde_json::to_value(&definition).expect("re-serialize definition");
|
||||
assert!(definition_again.get("id").is_none());
|
||||
assert!(definition_again.get("function_id").is_none());
|
||||
assert_ne!(
|
||||
serde_json::to_value(®istered).expect("serialize Function"),
|
||||
definition_again,
|
||||
"Function handle wire must remain distinct from FunctionDefinition wire"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user