Add Phase 1 frxd implementation with conformance test suite
This commit is contained in:
+184
@@ -0,0 +1,184 @@
|
||||
use crate::crypto::{Keypair, now_ts, random_nonce, signing_bytes, verify_envelope};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const TYPE_QUERY: &str = "query";
|
||||
pub const TYPE_RESPONSE: &str = "response";
|
||||
pub const TYPE_AGGREGATE: &str = "aggregate";
|
||||
|
||||
pub const EXPOSURE_METADATA: &str = "metadata";
|
||||
pub const EXPOSURE_FULL: &str = "full";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
#[serde(rename = "type")]
|
||||
pub msg_type: String,
|
||||
pub from: String,
|
||||
pub ts: u64,
|
||||
pub nonce: String,
|
||||
pub body: Value,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
pub fn new(key: &Keypair, msg_type: &str, body: Value) -> Self {
|
||||
let mut envelope = Self {
|
||||
msg_type: msg_type.to_string(),
|
||||
from: key.public_hex(),
|
||||
ts: now_ts(),
|
||||
nonce: random_nonce(),
|
||||
body,
|
||||
sig: String::new(),
|
||||
};
|
||||
envelope.sig = key.sign(&signing_bytes(&envelope));
|
||||
envelope
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
verify_envelope(self)
|
||||
}
|
||||
|
||||
pub fn parse_body<T: DeserializeOwned>(&self) -> Result<T> {
|
||||
serde_json::from_value(self.body.clone()).context("malformed body")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Budget {
|
||||
pub max_results: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryBody {
|
||||
pub qid: String,
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub entities: Vec<String>,
|
||||
pub budget: Budget,
|
||||
}
|
||||
|
||||
impl QueryBody {
|
||||
pub fn new(text: &str, max_results: usize) -> Self {
|
||||
Self {
|
||||
qid: crate::crypto::random_id(),
|
||||
text: text.to_string(),
|
||||
entities: Vec::new(),
|
||||
budget: Budget {
|
||||
max_results: max_results.clamp(1, 1000),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResponseItem {
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub published: String,
|
||||
pub exposure: String,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResponseBody {
|
||||
pub qid: String,
|
||||
pub results: Vec<ResponseItem>,
|
||||
pub truncated: bool,
|
||||
pub more_available: u64,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_response(
|
||||
qid: &str,
|
||||
hits: Vec<ResponseItem>,
|
||||
total: u64,
|
||||
max_results: usize,
|
||||
) -> ResponseBody {
|
||||
let max_results = max_results.max(1);
|
||||
let mut results = hits;
|
||||
results.truncate(max_results);
|
||||
let more_available = total.saturating_sub(results.len() as u64);
|
||||
ResponseBody {
|
||||
qid: qid.to_string(),
|
||||
results,
|
||||
truncated: more_available > 0,
|
||||
more_available,
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AggregateBody {
|
||||
pub period: String,
|
||||
pub sent: u64,
|
||||
pub passed: u64,
|
||||
pub cited: u64,
|
||||
}
|
||||
|
||||
pub fn require_type(envelope: &Envelope, expected: &str) -> Result<()> {
|
||||
if envelope.msg_type != expected {
|
||||
return Err(anyhow!(
|
||||
"expected message type {expected}, got {}",
|
||||
envelope.msg_type
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn item(n: usize) -> ResponseItem {
|
||||
ResponseItem {
|
||||
url: format!("file:///doc{n}.txt"),
|
||||
title: format!("doc {n}"),
|
||||
summary: String::new(),
|
||||
published: String::new(),
|
||||
exposure: EXPOSURE_METADATA.to_string(),
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_results_is_respected() {
|
||||
let response = build_response("q1", vec![item(1), item(2), item(3)], 10, 2);
|
||||
assert_eq!(response.results.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_honest() {
|
||||
let response = build_response("q1", vec![item(1), item(2), item(3)], 10, 2);
|
||||
assert!(response.truncated);
|
||||
assert_eq!(response.more_available, 8);
|
||||
|
||||
let response = build_response("q2", vec![item(1), item(2)], 2, 5);
|
||||
assert!(!response.truncated);
|
||||
assert_eq!(response.more_available, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_carries_no_scores() {
|
||||
let response = build_response("q1", vec![item(1)], 1, 5);
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(!json.contains("score"));
|
||||
assert!(!json.contains("relevance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_budget_is_clamped() {
|
||||
let query = QueryBody::new("anything", 0);
|
||||
assert_eq!(query.budget.max_results, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_wrong_type() {
|
||||
let key = Keypair::generate();
|
||||
let envelope = Envelope::new(&key, TYPE_RESPONSE, json!({}));
|
||||
assert!(require_type(&envelope, TYPE_QUERY).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user