Skip to main content

net/adapter/net/behavior/
api.rs

1//! Phase 4D: Node APIs & Schemas (API-SCHEMA)
2//!
3//! This module provides runtime-discoverable API definitions for nodes:
4//! - Structured API endpoint definitions
5//! - JSON Schema-based type validation
6//! - API versioning and compatibility checking
7//! - API registry with discovery and matching
8
9use dashmap::DashMap;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
13use std::sync::Arc;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use super::metadata::NodeId;
17
18/// HTTP-like method types for API endpoints
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum ApiMethod {
21    /// Query/read operation
22    Get,
23    /// Create operation
24    Post,
25    /// Full update operation
26    Put,
27    /// Partial update operation
28    Patch,
29    /// Delete operation
30    Delete,
31    /// Streaming request
32    Stream,
33    /// Bidirectional streaming
34    BiStream,
35    /// Subscribe to events
36    Subscribe,
37    /// One-way notification
38    Notify,
39}
40
41impl ApiMethod {
42    /// Whether this method is idempotent
43    pub fn is_idempotent(&self) -> bool {
44        matches!(self, ApiMethod::Get | ApiMethod::Put | ApiMethod::Delete)
45    }
46
47    /// Whether this method involves streaming
48    pub fn is_streaming(&self) -> bool {
49        matches!(
50            self,
51            ApiMethod::Stream | ApiMethod::BiStream | ApiMethod::Subscribe
52        )
53    }
54
55    /// Whether this method is safe (no side effects)
56    pub fn is_safe(&self) -> bool {
57        matches!(self, ApiMethod::Get | ApiMethod::Subscribe)
58    }
59}
60
61/// Maximum recursion depth permitted by [`SchemaType::validate`].
62///
63/// `SchemaType` is `#[derive(Deserialize)]` and contains
64/// recursive variants (`Array { items: Box<SchemaType> }`,
65/// `Object { properties: HashMap<_, SchemaType> }`,
66/// `AnyOf { schemas: Vec<SchemaType> }`). An attacker who can
67/// ship a schema (announcements broadcast over the mesh, or any
68/// caller that parses untrusted JSON into `SchemaType`) could
69/// otherwise submit a deeply-nested schema and crash the
70/// validator (and the whole process) via stack overflow on an
71/// unbounded recursive `validate`. 128 is generous for realistic
72/// schemas (typical JSON Schemas rarely exceed depth 10) and well
73/// clear of the typical default 8 MB Linux stack.
74pub const MAX_SCHEMA_DEPTH: usize = 128;
75
76/// Scan the byte stream of a JSON document and reject if
77/// nesting depth (the deepest stack of `{` and `[` after
78/// balancing) exceeds `max_depth`.
79///
80/// This is the deserialize-side defence for [`SchemaType`]: an
81/// adversarial schema with thousands of nested `{"type":"array",
82/// "items":...}` levels would otherwise either trip `serde_json`'s
83/// internal limit (currently 128 by default but tied to a
84/// transitive dependency) or stack-overflow the typed
85/// deserialize. Pre-scanning the bytes has a single linear-time
86/// cost regardless of which deserialize path follows.
87///
88/// String literals are handled correctly: bracket characters
89/// inside a `"..."` string don't change depth, and escapes
90/// (`\"`, `\\`) are skipped so a `}` inside a string can't fool
91/// the counter.
92///
93/// Returns `Err(serde_json::Error)` with a `Custom` kind so
94/// callers can match on `*::is_data` / `*::is_eof` etc. uniformly
95/// with the standard `serde_json::from_slice` error surface.
96fn check_json_nesting_depth(data: &[u8], max_depth: usize) -> Result<(), serde_json::Error> {
97    use serde::de::Error;
98    let mut depth: usize = 0;
99    let mut max_seen: usize = 0;
100    let mut i = 0;
101    let n = data.len();
102    while i < n {
103        let b = data[i];
104        match b {
105            b'{' | b'[' => {
106                depth = depth.saturating_add(1);
107                if depth > max_seen {
108                    max_seen = depth;
109                }
110                if depth > max_depth {
111                    return Err(serde_json::Error::custom(format!(
112                        "max nesting depth exceeded ({} > {})",
113                        depth, max_depth
114                    )));
115                }
116                i += 1;
117            }
118            b'}' | b']' => {
119                depth = depth.saturating_sub(1);
120                i += 1;
121            }
122            b'"' => {
123                // Skip the rest of the string. Honor `\"` (don't
124                // exit) and `\\` (don't treat the following char
125                // as an escape). Anything else inside the string
126                // is opaque to the depth counter.
127                i += 1;
128                while i < n {
129                    match data[i] {
130                        b'\\' if i + 1 < n => i += 2,
131                        b'"' => {
132                            i += 1;
133                            break;
134                        }
135                        _ => i += 1,
136                    }
137                }
138            }
139            _ => i += 1,
140        }
141    }
142    Ok(())
143}
144
145/// JSON Schema type definitions
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147#[serde(tag = "type", rename_all = "lowercase")]
148pub enum SchemaType {
149    /// Null type
150    Null,
151    /// Boolean type
152    Boolean,
153    /// Integer type
154    Integer {
155        /// Inclusive minimum value
156        #[serde(skip_serializing_if = "Option::is_none")]
157        minimum: Option<i64>,
158        /// Inclusive maximum value
159        #[serde(skip_serializing_if = "Option::is_none")]
160        maximum: Option<i64>,
161        /// Value must be a multiple of this
162        #[serde(skip_serializing_if = "Option::is_none")]
163        multiple_of: Option<i64>,
164    },
165    /// Number (float) type
166    Number {
167        /// Inclusive minimum value
168        #[serde(skip_serializing_if = "Option::is_none")]
169        minimum: Option<f64>,
170        /// Inclusive maximum value
171        #[serde(skip_serializing_if = "Option::is_none")]
172        maximum: Option<f64>,
173    },
174    /// String type
175    String {
176        /// Minimum string length in characters
177        #[serde(skip_serializing_if = "Option::is_none")]
178        min_length: Option<usize>,
179        /// Maximum string length in characters
180        #[serde(skip_serializing_if = "Option::is_none")]
181        max_length: Option<usize>,
182        /// Regular expression pattern the string must match
183        #[serde(skip_serializing_if = "Option::is_none")]
184        pattern: Option<String>,
185        /// Semantic format of the string value
186        #[serde(skip_serializing_if = "Option::is_none")]
187        format: Option<StringFormat>,
188    },
189    /// Array type
190    Array {
191        /// Schema for each array element
192        items: Box<SchemaType>,
193        /// Minimum number of items
194        #[serde(skip_serializing_if = "Option::is_none")]
195        min_items: Option<usize>,
196        /// Maximum number of items
197        #[serde(skip_serializing_if = "Option::is_none")]
198        max_items: Option<usize>,
199        /// Whether all items must be unique
200        #[serde(default)]
201        unique_items: bool,
202    },
203    /// Object type
204    Object {
205        /// Named property schemas
206        properties: HashMap<String, SchemaType>,
207        /// Property names that must be present
208        #[serde(default)]
209        required: Vec<String>,
210        /// Whether properties not listed in `properties` are allowed
211        #[serde(default)]
212        additional_properties: bool,
213    },
214    /// Enum type (one of specific values)
215    Enum {
216        /// Allowed JSON values for this enum
217        values: Vec<serde_json::Value>,
218    },
219    /// Union type (anyOf)
220    AnyOf {
221        /// Candidate schemas, at least one of which must validate
222        schemas: Vec<SchemaType>,
223    },
224    /// Reference to another schema
225    Ref {
226        /// Name or path of the referenced schema
227        schema_ref: String,
228    },
229    /// Any type (no validation)
230    Any,
231}
232
233/// String format specifiers
234#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "kebab-case")]
236pub enum StringFormat {
237    /// Date-time format (ISO 8601)
238    DateTime,
239    /// Date format
240    Date,
241    /// Time format
242    Time,
243    /// Duration format
244    Duration,
245    /// Email address
246    Email,
247    /// URI format
248    Uri,
249    /// UUID format
250    Uuid,
251    /// IPv4 address
252    Ipv4,
253    /// IPv6 address
254    Ipv6,
255    /// Base64 encoded binary
256    Base64,
257    /// Hexadecimal string
258    Hex,
259    /// JSON string
260    Json,
261    /// Markdown text
262    Markdown,
263}
264
265impl SchemaType {
266    /// Deserialize a `SchemaType` from JSON bytes with an explicit
267    /// nesting-depth cap.
268    ///
269    /// Callers that deserialize peer-supplied / untrusted JSON
270    /// into `SchemaType` MUST use this entry point. The
271    /// derive-`Deserialize` path inherits `serde_json`'s built-in
272    /// 128-frame recursion limit, but that's tied to a transitive
273    /// dependency and may shift across versions; we pin a local
274    /// cap matching [`MAX_SCHEMA_DEPTH`] by **pre-scanning** the
275    /// input bytes for max nesting depth (cheap O(n) walk over
276    /// `{`/`[`/`}`/`]` outside of strings) and rejecting before
277    /// any deserialize work runs. This also guards against
278    /// `serde_json::from_slice::<SchemaType>(...)` callsites that
279    /// might bypass [`Self::validate`]'s post-parse cap entirely.
280    ///
281    /// Returns:
282    /// - `Err(serde_json::Error)` with kind `Custom("max nesting
283    ///   depth exceeded")` if depth > [`MAX_SCHEMA_DEPTH`].
284    /// - The standard `serde_json::Error` variants from the
285    ///   downstream `from_slice` call otherwise.
286    pub fn try_from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
287        check_json_nesting_depth(data, MAX_SCHEMA_DEPTH)?;
288        serde_json::from_slice(data)
289    }
290
291    /// Deserialize a `SchemaType` from a JSON string with an
292    /// explicit nesting-depth cap. See [`Self::try_from_slice`].
293    pub fn try_from_str(s: &str) -> Result<Self, serde_json::Error> {
294        Self::try_from_slice(s.as_bytes())
295    }
296
297    /// Create a string schema
298    pub fn string() -> Self {
299        SchemaType::String {
300            min_length: None,
301            max_length: None,
302            pattern: None,
303            format: None,
304        }
305    }
306
307    /// Create an integer schema
308    pub fn integer() -> Self {
309        SchemaType::Integer {
310            minimum: None,
311            maximum: None,
312            multiple_of: None,
313        }
314    }
315
316    /// Create a number schema
317    pub fn number() -> Self {
318        SchemaType::Number {
319            minimum: None,
320            maximum: None,
321        }
322    }
323
324    /// Create a boolean schema
325    pub fn boolean() -> Self {
326        SchemaType::Boolean
327    }
328
329    /// Create an array schema
330    pub fn array(items: SchemaType) -> Self {
331        SchemaType::Array {
332            items: Box::new(items),
333            min_items: None,
334            max_items: None,
335            unique_items: false,
336        }
337    }
338
339    /// Create an object schema
340    pub fn object() -> Self {
341        SchemaType::Object {
342            properties: HashMap::new(),
343            required: Vec::new(),
344            additional_properties: true,
345        }
346    }
347
348    /// Add a property to an object schema
349    pub fn with_property(mut self, name: impl Into<String>, schema: SchemaType) -> Self {
350        if let SchemaType::Object {
351            ref mut properties, ..
352        } = self
353        {
354            properties.insert(name.into(), schema);
355        }
356        self
357    }
358
359    /// Mark a property as required
360    pub fn with_required(mut self, name: impl Into<String>) -> Self {
361        if let SchemaType::Object {
362            ref mut required, ..
363        } = self
364        {
365            required.push(name.into());
366        }
367        self
368    }
369
370    /// Set minimum for integer
371    pub fn with_minimum(mut self, min: i64) -> Self {
372        if let SchemaType::Integer {
373            ref mut minimum, ..
374        } = self
375        {
376            *minimum = Some(min);
377        }
378        self
379    }
380
381    /// Set maximum for integer
382    pub fn with_maximum(mut self, max: i64) -> Self {
383        if let SchemaType::Integer {
384            ref mut maximum, ..
385        } = self
386        {
387            *maximum = Some(max);
388        }
389        self
390    }
391
392    /// Set max length for string
393    pub fn with_max_length(mut self, len: usize) -> Self {
394        if let SchemaType::String {
395            ref mut max_length, ..
396        } = self
397        {
398            *max_length = Some(len);
399        }
400        self
401    }
402
403    /// Set format for string
404    pub fn with_format(mut self, fmt: StringFormat) -> Self {
405        if let SchemaType::String { ref mut format, .. } = self {
406            *format = Some(fmt);
407        }
408        self
409    }
410
411    /// Validate a JSON value against this schema.
412    ///
413    /// The recursion is bounded by [`MAX_SCHEMA_DEPTH`]; exceeding
414    /// it returns [`ValidationError::RecursionLimitExceeded`]
415    /// instead of blowing the stack. Recursive variants (`Array`,
416    /// `Object`, `AnyOf`) call `validate` recursively, so an
417    /// attacker who could ship a `SchemaType` (announcements
418    /// broadcast over the mesh, or any caller that parses
419    /// untrusted JSON into `SchemaType`) could otherwise submit a
420    /// deeply-nested schema and crash the validator — and the
421    /// whole process — via stack overflow when a request got
422    /// validated against it.
423    pub fn validate(&self, value: &serde_json::Value) -> Result<(), ValidationError> {
424        self.validate_with_depth(value, 0)
425    }
426
427    /// Internal depth-bounded validate — see [`validate`].
428    fn validate_with_depth(
429        &self,
430        value: &serde_json::Value,
431        depth: usize,
432    ) -> Result<(), ValidationError> {
433        if depth >= MAX_SCHEMA_DEPTH {
434            return Err(ValidationError::RecursionLimitExceeded {
435                limit: MAX_SCHEMA_DEPTH,
436            });
437        }
438        match (self, value) {
439            (SchemaType::Null, serde_json::Value::Null) => Ok(()),
440            (SchemaType::Null, _) => Err(ValidationError::TypeMismatch {
441                expected: "null".into(),
442                got: value_type_name(value),
443            }),
444
445            (SchemaType::Boolean, serde_json::Value::Bool(_)) => Ok(()),
446            (SchemaType::Boolean, _) => Err(ValidationError::TypeMismatch {
447                expected: "boolean".into(),
448                got: value_type_name(value),
449            }),
450
451            (
452                SchemaType::Integer {
453                    minimum,
454                    maximum,
455                    multiple_of,
456                },
457                serde_json::Value::Number(n),
458            ) => {
459                let i = n.as_i64().ok_or_else(|| ValidationError::TypeMismatch {
460                    expected: "integer".into(),
461                    got: "float".into(),
462                })?;
463
464                if let Some(min) = minimum {
465                    if i < *min {
466                        return Err(ValidationError::RangeError {
467                            value: i as f64,
468                            min: Some(*min as f64),
469                            max: None,
470                        });
471                    }
472                }
473                if let Some(max) = maximum {
474                    if i > *max {
475                        return Err(ValidationError::RangeError {
476                            value: i as f64,
477                            min: None,
478                            max: Some(*max as f64),
479                        });
480                    }
481                }
482                if let Some(mult) = multiple_of {
483                    if i % mult != 0 {
484                        return Err(ValidationError::MultipleOfError {
485                            value: i,
486                            multiple_of: *mult,
487                        });
488                    }
489                }
490                Ok(())
491            }
492            (SchemaType::Integer { .. }, _) => Err(ValidationError::TypeMismatch {
493                expected: "integer".into(),
494                got: value_type_name(value),
495            }),
496
497            (SchemaType::Number { minimum, maximum }, serde_json::Value::Number(n)) => {
498                let f = n.as_f64().unwrap_or(0.0);
499
500                if let Some(min) = minimum {
501                    if f < *min {
502                        return Err(ValidationError::RangeError {
503                            value: f,
504                            min: Some(*min),
505                            max: None,
506                        });
507                    }
508                }
509                if let Some(max) = maximum {
510                    if f > *max {
511                        return Err(ValidationError::RangeError {
512                            value: f,
513                            min: None,
514                            max: Some(*max),
515                        });
516                    }
517                }
518                Ok(())
519            }
520            (SchemaType::Number { .. }, _) => Err(ValidationError::TypeMismatch {
521                expected: "number".into(),
522                got: value_type_name(value),
523            }),
524
525            (
526                SchemaType::String {
527                    min_length,
528                    max_length,
529                    pattern,
530                    format: _,
531                },
532                serde_json::Value::String(s),
533            ) => {
534                if let Some(min) = min_length {
535                    if s.len() < *min {
536                        return Err(ValidationError::LengthError {
537                            length: s.len(),
538                            min: Some(*min),
539                            max: None,
540                        });
541                    }
542                }
543                if let Some(max) = max_length {
544                    if s.len() > *max {
545                        return Err(ValidationError::LengthError {
546                            length: s.len(),
547                            min: None,
548                            max: Some(*max),
549                        });
550                    }
551                }
552                if let Some(pat) = pattern {
553                    // Simple pattern check - in production would use regex
554                    if !s.contains(pat.as_str()) {
555                        return Err(ValidationError::PatternMismatch {
556                            value: s.clone(),
557                            pattern: pat.clone(),
558                        });
559                    }
560                }
561                // Format validation would go here
562                Ok(())
563            }
564            (SchemaType::String { .. }, _) => Err(ValidationError::TypeMismatch {
565                expected: "string".into(),
566                got: value_type_name(value),
567            }),
568
569            (
570                SchemaType::Array {
571                    items,
572                    min_items,
573                    max_items,
574                    unique_items,
575                },
576                serde_json::Value::Array(arr),
577            ) => {
578                if let Some(min) = min_items {
579                    if arr.len() < *min {
580                        return Err(ValidationError::LengthError {
581                            length: arr.len(),
582                            min: Some(*min),
583                            max: None,
584                        });
585                    }
586                }
587                if let Some(max) = max_items {
588                    if arr.len() > *max {
589                        return Err(ValidationError::LengthError {
590                            length: arr.len(),
591                            min: None,
592                            max: Some(*max),
593                        });
594                    }
595                }
596                if *unique_items {
597                    let mut seen = HashSet::new();
598                    for v in arr {
599                        let s = serde_json::to_string(v).unwrap_or_default();
600                        if !seen.insert(s) {
601                            return Err(ValidationError::DuplicateItems);
602                        }
603                    }
604                }
605                for (i, v) in arr.iter().enumerate() {
606                    if let Err(e) = items.validate_with_depth(v, depth + 1) {
607                        // Surface the recursion-limit signal
608                        // unwrapped — wrapping it in
609                        // `ArrayItemError` would obscure the
610                        // anti-DoS check from callers walking
611                        // the error chain.
612                        if matches!(e, ValidationError::RecursionLimitExceeded { .. }) {
613                            return Err(e);
614                        }
615                        return Err(ValidationError::ArrayItemError {
616                            index: i,
617                            error: Box::new(e),
618                        });
619                    }
620                }
621                Ok(())
622            }
623            (SchemaType::Array { .. }, _) => Err(ValidationError::TypeMismatch {
624                expected: "array".into(),
625                got: value_type_name(value),
626            }),
627
628            (
629                SchemaType::Object {
630                    properties,
631                    required,
632                    additional_properties,
633                },
634                serde_json::Value::Object(obj),
635            ) => {
636                // Check required fields
637                for req in required {
638                    if !obj.contains_key(req) {
639                        return Err(ValidationError::MissingRequired { field: req.clone() });
640                    }
641                }
642
643                // Validate properties
644                for (key, val) in obj {
645                    if let Some(schema) = properties.get(key) {
646                        if let Err(e) = schema.validate_with_depth(val, depth + 1) {
647                            // Same as Array — surface the
648                            // recursion-limit signal unwrapped.
649                            if matches!(e, ValidationError::RecursionLimitExceeded { .. }) {
650                                return Err(e);
651                            }
652                            return Err(ValidationError::PropertyError {
653                                property: key.clone(),
654                                error: Box::new(e),
655                            });
656                        }
657                    } else if !additional_properties {
658                        return Err(ValidationError::UnknownProperty {
659                            property: key.clone(),
660                        });
661                    }
662                }
663                Ok(())
664            }
665            (SchemaType::Object { .. }, _) => Err(ValidationError::TypeMismatch {
666                expected: "object".into(),
667                got: value_type_name(value),
668            }),
669
670            (SchemaType::Enum { values }, v) => {
671                if values.contains(v) {
672                    Ok(())
673                } else {
674                    Err(ValidationError::EnumMismatch {
675                        value: v.clone(),
676                        allowed: values.clone(),
677                    })
678                }
679            }
680
681            (SchemaType::AnyOf { schemas }, v) => {
682                for schema in schemas {
683                    match schema.validate_with_depth(v, depth + 1) {
684                        Ok(()) => return Ok(()),
685                        Err(ValidationError::RecursionLimitExceeded { limit }) => {
686                            // Don't swallow the recursion-limit
687                            // signal — surface it instead of
688                            // converting to AnyOfFailed.
689                            return Err(ValidationError::RecursionLimitExceeded { limit });
690                        }
691                        Err(_) => {}
692                    }
693                }
694                Err(ValidationError::AnyOfFailed {
695                    schema_count: schemas.len(),
696                })
697            }
698
699            (SchemaType::Ref { .. }, _) => {
700                // Reference resolution would happen at registry level
701                Ok(())
702            }
703
704            (SchemaType::Any, _) => Ok(()),
705        }
706    }
707}
708
709fn value_type_name(v: &serde_json::Value) -> String {
710    match v {
711        serde_json::Value::Null => "null".into(),
712        serde_json::Value::Bool(_) => "boolean".into(),
713        serde_json::Value::Number(_) => "number".into(),
714        serde_json::Value::String(_) => "string".into(),
715        serde_json::Value::Array(_) => "array".into(),
716        serde_json::Value::Object(_) => "object".into(),
717    }
718}
719
720/// Validation errors
721#[derive(Debug, Clone, PartialEq)]
722pub enum ValidationError {
723    /// Type mismatch
724    TypeMismatch {
725        /// Expected type name
726        expected: String,
727        /// Actual type name received
728        got: String,
729    },
730    /// Value out of range
731    RangeError {
732        /// The value that failed the range check
733        value: f64,
734        /// Inclusive minimum bound, if any
735        min: Option<f64>,
736        /// Inclusive maximum bound, if any
737        max: Option<f64>,
738    },
739    /// Multiple-of constraint failed
740    MultipleOfError {
741        /// The value that failed the constraint
742        value: i64,
743        /// The required divisor
744        multiple_of: i64,
745    },
746    /// Length constraint failed
747    LengthError {
748        /// Actual length of the string or array
749        length: usize,
750        /// Minimum allowed length, if any
751        min: Option<usize>,
752        /// Maximum allowed length, if any
753        max: Option<usize>,
754    },
755    /// Pattern mismatch
756    PatternMismatch {
757        /// The string value that did not match
758        value: String,
759        /// The regex pattern that was required
760        pattern: String,
761    },
762    /// Duplicate items in array
763    DuplicateItems,
764    /// Array item validation failed
765    ArrayItemError {
766        /// Zero-based index of the failing item
767        index: usize,
768        /// Nested validation error for the item
769        error: Box<ValidationError>,
770    },
771    /// Missing required field
772    MissingRequired {
773        /// Name of the required field that was absent
774        field: String,
775    },
776    /// Unknown property
777    UnknownProperty {
778        /// Name of the disallowed additional property
779        property: String,
780    },
781    /// Property validation failed
782    PropertyError {
783        /// Name of the property that failed validation
784        property: String,
785        /// Nested validation error for the property value
786        error: Box<ValidationError>,
787    },
788    /// Enum value not in allowed list
789    EnumMismatch {
790        /// The value that was not in the allowed set
791        value: serde_json::Value,
792        /// The set of allowed values
793        allowed: Vec<serde_json::Value>,
794    },
795    /// AnyOf validation failed
796    AnyOfFailed {
797        /// Number of candidate schemas that were all tried and failed
798        schema_count: usize,
799    },
800    /// Schema recursion depth exceeded (anti-DoS guard).
801    ///
802    /// Returned by [`SchemaType::validate`] when the recursive
803    /// walk through nested `Array`/`Object`/`AnyOf` variants
804    /// exceeds [`MAX_SCHEMA_DEPTH`]. Without this cap, an
805    /// attacker who could ship a `SchemaType` (announcements
806    /// broadcast over the mesh, or any caller parsing untrusted
807    /// JSON) could submit a deeply nested schema and crash the
808    /// validator (and the process) via stack overflow.
809    RecursionLimitExceeded {
810        /// The depth limit that was exceeded.
811        limit: usize,
812    },
813}
814
815impl std::fmt::Display for ValidationError {
816    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
817        match self {
818            ValidationError::TypeMismatch { expected, got } => {
819                write!(f, "expected {}, got {}", expected, got)
820            }
821            ValidationError::RangeError { value, min, max } => {
822                write!(f, "value {} out of range [{:?}, {:?}]", value, min, max)
823            }
824            ValidationError::MultipleOfError { value, multiple_of } => {
825                write!(f, "{} is not a multiple of {}", value, multiple_of)
826            }
827            ValidationError::LengthError { length, min, max } => {
828                write!(f, "length {} out of range [{:?}, {:?}]", length, min, max)
829            }
830            ValidationError::PatternMismatch { value, pattern } => {
831                write!(f, "'{}' does not match pattern '{}'", value, pattern)
832            }
833            ValidationError::DuplicateItems => write!(f, "duplicate items in array"),
834            ValidationError::ArrayItemError { index, error } => {
835                write!(f, "item [{}]: {}", index, error)
836            }
837            ValidationError::MissingRequired { field } => {
838                write!(f, "missing required field: {}", field)
839            }
840            ValidationError::UnknownProperty { property } => {
841                write!(f, "unknown property: {}", property)
842            }
843            ValidationError::PropertyError { property, error } => {
844                write!(f, "property '{}': {}", property, error)
845            }
846            ValidationError::EnumMismatch { value, .. } => {
847                write!(f, "{:?} is not a valid enum value", value)
848            }
849            ValidationError::AnyOfFailed { schema_count } => {
850                write!(f, "value did not match any of {} schemas", schema_count)
851            }
852            ValidationError::RecursionLimitExceeded { limit } => {
853                write!(f, "schema recursion depth exceeded {}", limit)
854            }
855        }
856    }
857}
858
859impl std::error::Error for ValidationError {}
860
861/// API parameter definition
862#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
863pub struct ApiParameter {
864    /// Parameter name
865    pub name: String,
866    /// Parameter description
867    pub description: Option<String>,
868    /// Whether parameter is required
869    pub required: bool,
870    /// Parameter schema
871    pub schema: SchemaType,
872    /// Default value (if not required)
873    pub default: Option<serde_json::Value>,
874    /// Example value
875    pub example: Option<serde_json::Value>,
876}
877
878impl ApiParameter {
879    /// Create a new required parameter
880    pub fn required(name: impl Into<String>, schema: SchemaType) -> Self {
881        Self {
882            name: name.into(),
883            description: None,
884            required: true,
885            schema,
886            default: None,
887            example: None,
888        }
889    }
890
891    /// Create a new optional parameter
892    pub fn optional(name: impl Into<String>, schema: SchemaType) -> Self {
893        Self {
894            name: name.into(),
895            description: None,
896            required: false,
897            schema,
898            default: None,
899            example: None,
900        }
901    }
902
903    /// Set description
904    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
905        self.description = Some(desc.into());
906        self
907    }
908
909    /// Set default value
910    pub fn with_default(mut self, default: serde_json::Value) -> Self {
911        self.default = Some(default);
912        self
913    }
914
915    /// Set example
916    pub fn with_example(mut self, example: serde_json::Value) -> Self {
917        self.example = Some(example);
918        self
919    }
920}
921
922/// API endpoint definition
923#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
924pub struct ApiEndpoint {
925    /// Endpoint path (e.g., "/models/{model_id}/infer")
926    pub path: String,
927    /// HTTP-like method
928    pub method: ApiMethod,
929    /// Endpoint description
930    pub description: Option<String>,
931    /// Path parameters
932    pub path_params: Vec<ApiParameter>,
933    /// Query parameters
934    pub query_params: Vec<ApiParameter>,
935    /// Request body schema
936    pub request_body: Option<SchemaType>,
937    /// Response schema
938    pub response: Option<SchemaType>,
939    /// Error response schema
940    pub error_response: Option<SchemaType>,
941    /// Required capabilities to call this endpoint
942    pub required_capabilities: Vec<String>,
943    /// Tags for categorization
944    pub tags: Vec<String>,
945    /// Whether endpoint is deprecated
946    pub deprecated: bool,
947    /// Rate limit (requests per minute)
948    pub rate_limit: Option<u32>,
949    /// Timeout in milliseconds
950    pub timeout_ms: Option<u64>,
951    /// Whether authentication is required
952    pub auth_required: bool,
953}
954
955impl ApiEndpoint {
956    /// Create a new endpoint
957    pub fn new(path: impl Into<String>, method: ApiMethod) -> Self {
958        Self {
959            path: path.into(),
960            method,
961            description: None,
962            path_params: Vec::new(),
963            query_params: Vec::new(),
964            request_body: None,
965            response: None,
966            error_response: None,
967            required_capabilities: Vec::new(),
968            tags: Vec::new(),
969            deprecated: false,
970            rate_limit: None,
971            timeout_ms: None,
972            auth_required: true,
973        }
974    }
975
976    /// Set description
977    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
978        self.description = Some(desc.into());
979        self
980    }
981
982    /// Add path parameter
983    pub fn with_path_param(mut self, param: ApiParameter) -> Self {
984        self.path_params.push(param);
985        self
986    }
987
988    /// Add query parameter
989    pub fn with_query_param(mut self, param: ApiParameter) -> Self {
990        self.query_params.push(param);
991        self
992    }
993
994    /// Set request body schema
995    pub fn with_request_body(mut self, schema: SchemaType) -> Self {
996        self.request_body = Some(schema);
997        self
998    }
999
1000    /// Set response schema
1001    pub fn with_response(mut self, schema: SchemaType) -> Self {
1002        self.response = Some(schema);
1003        self
1004    }
1005
1006    /// Add required capability
1007    pub fn require_capability(mut self, cap: impl Into<String>) -> Self {
1008        self.required_capabilities.push(cap.into());
1009        self
1010    }
1011
1012    /// Add tag
1013    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
1014        self.tags.push(tag.into());
1015        self
1016    }
1017
1018    /// Set rate limit
1019    pub fn with_rate_limit(mut self, requests_per_min: u32) -> Self {
1020        self.rate_limit = Some(requests_per_min);
1021        self
1022    }
1023
1024    /// Set timeout
1025    pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
1026        self.timeout_ms = Some(timeout_ms);
1027        self
1028    }
1029
1030    /// Mark as not requiring auth
1031    pub fn no_auth(mut self) -> Self {
1032        self.auth_required = false;
1033        self
1034    }
1035
1036    /// Mark as deprecated
1037    pub fn deprecated(mut self) -> Self {
1038        self.deprecated = true;
1039        self
1040    }
1041
1042    /// Validate request parameters
1043    pub fn validate_request(
1044        &self,
1045        path_params: &HashMap<String, serde_json::Value>,
1046        query_params: &HashMap<String, serde_json::Value>,
1047        body: Option<&serde_json::Value>,
1048    ) -> Result<(), ApiValidationError> {
1049        // Validate path params
1050        for param in &self.path_params {
1051            if let Some(value) = path_params.get(&param.name) {
1052                param
1053                    .schema
1054                    .validate(value)
1055                    .map_err(|e| ApiValidationError::PathParameter {
1056                        name: param.name.clone(),
1057                        error: e,
1058                    })?;
1059            } else if param.required {
1060                return Err(ApiValidationError::MissingPathParameter {
1061                    name: param.name.clone(),
1062                });
1063            }
1064        }
1065
1066        // Validate query params
1067        for param in &self.query_params {
1068            if let Some(value) = query_params.get(&param.name) {
1069                param
1070                    .schema
1071                    .validate(value)
1072                    .map_err(|e| ApiValidationError::QueryParameter {
1073                        name: param.name.clone(),
1074                        error: e,
1075                    })?;
1076            } else if param.required {
1077                return Err(ApiValidationError::MissingQueryParameter {
1078                    name: param.name.clone(),
1079                });
1080            }
1081        }
1082
1083        // Validate body
1084        if let Some(body_schema) = &self.request_body {
1085            match body {
1086                Some(b) => {
1087                    body_schema
1088                        .validate(b)
1089                        .map_err(|e| ApiValidationError::RequestBody { error: e })?;
1090                }
1091                None => {
1092                    return Err(ApiValidationError::MissingRequestBody);
1093                }
1094            }
1095        }
1096
1097        Ok(())
1098    }
1099
1100    /// Check if endpoint matches a path
1101    pub fn matches_path(&self, path: &str) -> Option<HashMap<String, String>> {
1102        let self_parts: Vec<&str> = self.path.split('/').collect();
1103        let path_parts: Vec<&str> = path.split('/').collect();
1104
1105        if self_parts.len() != path_parts.len() {
1106            return None;
1107        }
1108
1109        let mut params = HashMap::new();
1110
1111        for (self_part, path_part) in self_parts.iter().zip(path_parts.iter()) {
1112            if self_part.starts_with('{') && self_part.ends_with('}') {
1113                // Extract parameter name
1114                let param_name = &self_part[1..self_part.len() - 1];
1115                params.insert(param_name.to_string(), path_part.to_string());
1116            } else if self_part != path_part {
1117                return None;
1118            }
1119        }
1120
1121        Some(params)
1122    }
1123
1124    /// Like [`Self::matches_path`] but only reports whether the path matches,
1125    /// without allocating the captured-parameter map (or the intermediate
1126    /// segment `Vec`s). Use this on lookup paths that discard the params
1127    /// (e.g. `ApiRegistry::find_by_endpoint`); `matches_path` re-derives the
1128    /// params when a caller actually needs them.
1129    pub fn path_matches(&self, path: &str) -> bool {
1130        let mut self_parts = self.path.split('/');
1131        let mut path_parts = path.split('/');
1132        loop {
1133            match (self_parts.next(), path_parts.next()) {
1134                (Some(sp), Some(pp)) => {
1135                    let is_param = sp.starts_with('{') && sp.ends_with('}');
1136                    if !is_param && sp != pp {
1137                        return false;
1138                    }
1139                }
1140                // Both exhausted with every segment matched.
1141                (None, None) => return true,
1142                // Differing segment counts.
1143                _ => return false,
1144            }
1145        }
1146    }
1147}
1148
1149/// API validation errors
1150#[derive(Debug, Clone, PartialEq)]
1151pub enum ApiValidationError {
1152    /// Missing path parameter
1153    MissingPathParameter {
1154        /// Name of the missing path parameter
1155        name: String,
1156    },
1157    /// Path parameter validation failed
1158    PathParameter {
1159        /// Name of the path parameter that failed
1160        name: String,
1161        /// Underlying schema validation error
1162        error: ValidationError,
1163    },
1164    /// Missing query parameter
1165    MissingQueryParameter {
1166        /// Name of the missing query parameter
1167        name: String,
1168    },
1169    /// Query parameter validation failed
1170    QueryParameter {
1171        /// Name of the query parameter that failed
1172        name: String,
1173        /// Underlying schema validation error
1174        error: ValidationError,
1175    },
1176    /// Missing request body
1177    MissingRequestBody,
1178    /// Request body validation failed
1179    RequestBody {
1180        /// Underlying schema validation error for the request body
1181        error: ValidationError,
1182    },
1183}
1184
1185impl std::fmt::Display for ApiValidationError {
1186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1187        match self {
1188            ApiValidationError::MissingPathParameter { name } => {
1189                write!(f, "missing path parameter: {}", name)
1190            }
1191            ApiValidationError::PathParameter { name, error } => {
1192                write!(f, "path parameter '{}': {}", name, error)
1193            }
1194            ApiValidationError::MissingQueryParameter { name } => {
1195                write!(f, "missing query parameter: {}", name)
1196            }
1197            ApiValidationError::QueryParameter { name, error } => {
1198                write!(f, "query parameter '{}': {}", name, error)
1199            }
1200            ApiValidationError::MissingRequestBody => write!(f, "missing request body"),
1201            ApiValidationError::RequestBody { error } => {
1202                write!(f, "request body: {}", error)
1203            }
1204        }
1205    }
1206}
1207
1208impl std::error::Error for ApiValidationError {}
1209
1210/// Semantic versioning for APIs
1211#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1212pub struct ApiVersion {
1213    /// Major version (breaking changes)
1214    pub major: u32,
1215    /// Minor version (new features, backwards compatible)
1216    pub minor: u32,
1217    /// Patch version (bug fixes)
1218    pub patch: u32,
1219}
1220
1221impl ApiVersion {
1222    /// Create a new version
1223    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
1224        Self {
1225            major,
1226            minor,
1227            patch,
1228        }
1229    }
1230
1231    /// Check if this version is compatible with a requirement
1232    pub fn is_compatible_with(&self, required: &ApiVersion) -> bool {
1233        // Major versions must match
1234        if self.major != required.major {
1235            return false;
1236        }
1237        // Our minor version must be >= required
1238        if self.minor < required.minor {
1239            return false;
1240        }
1241        // If minor versions match, patch must be >= required
1242        if self.minor == required.minor && self.patch < required.patch {
1243            return false;
1244        }
1245        true
1246    }
1247
1248    /// Parse from string "major.minor.patch"
1249    pub fn parse(s: &str) -> Option<Self> {
1250        let parts: Vec<&str> = s.split('.').collect();
1251        if parts.len() != 3 {
1252            return None;
1253        }
1254        Some(Self {
1255            major: parts[0].parse().ok()?,
1256            minor: parts[1].parse().ok()?,
1257            patch: parts[2].parse().ok()?,
1258        })
1259    }
1260}
1261
1262impl std::fmt::Display for ApiVersion {
1263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1264        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
1265    }
1266}
1267
1268impl PartialOrd for ApiVersion {
1269    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1270        Some(self.cmp(other))
1271    }
1272}
1273
1274impl Ord for ApiVersion {
1275    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1276        match self.major.cmp(&other.major) {
1277            std::cmp::Ordering::Equal => match self.minor.cmp(&other.minor) {
1278                std::cmp::Ordering::Equal => self.patch.cmp(&other.patch),
1279                ord => ord,
1280            },
1281            ord => ord,
1282        }
1283    }
1284}
1285
1286/// Complete API schema for a node
1287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1288pub struct ApiSchema {
1289    /// Schema name
1290    pub name: String,
1291    /// Schema description
1292    pub description: Option<String>,
1293    /// API version
1294    pub version: ApiVersion,
1295    /// Base path prefix
1296    pub base_path: String,
1297    /// Available endpoints
1298    pub endpoints: Vec<ApiEndpoint>,
1299    /// Shared schema definitions (for $ref)
1300    pub definitions: HashMap<String, SchemaType>,
1301    /// Global tags
1302    pub tags: Vec<String>,
1303    /// Contact information
1304    pub contact: Option<String>,
1305    /// License
1306    pub license: Option<String>,
1307}
1308
1309impl ApiSchema {
1310    /// Create a new API schema
1311    pub fn new(name: impl Into<String>, version: ApiVersion) -> Self {
1312        Self {
1313            name: name.into(),
1314            description: None,
1315            version,
1316            base_path: "/".into(),
1317            endpoints: Vec::new(),
1318            definitions: HashMap::new(),
1319            tags: Vec::new(),
1320            contact: None,
1321            license: None,
1322        }
1323    }
1324
1325    /// Set description
1326    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
1327        self.description = Some(desc.into());
1328        self
1329    }
1330
1331    /// Set base path
1332    pub fn with_base_path(mut self, path: impl Into<String>) -> Self {
1333        self.base_path = path.into();
1334        self
1335    }
1336
1337    /// Add endpoint
1338    pub fn add_endpoint(mut self, endpoint: ApiEndpoint) -> Self {
1339        self.endpoints.push(endpoint);
1340        self
1341    }
1342
1343    /// Add schema definition
1344    pub fn add_definition(mut self, name: impl Into<String>, schema: SchemaType) -> Self {
1345        self.definitions.insert(name.into(), schema);
1346        self
1347    }
1348
1349    /// Add tag
1350    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
1351        self.tags.push(tag.into());
1352        self
1353    }
1354
1355    /// Find endpoint by path and method
1356    pub fn find_endpoint(&self, path: &str, method: ApiMethod) -> Option<&ApiEndpoint> {
1357        let full_path = if path.starts_with(&self.base_path) {
1358            path.to_string()
1359        } else {
1360            format!("{}{}", self.base_path.trim_end_matches('/'), path)
1361        };
1362
1363        self.endpoints
1364            .iter()
1365            .find(|e| e.method == method && e.path_matches(&full_path))
1366    }
1367
1368    /// Get all endpoints with a specific tag
1369    pub fn endpoints_by_tag(&self, tag: &str) -> Vec<&ApiEndpoint> {
1370        self.endpoints
1371            .iter()
1372            .filter(|e| e.tags.contains(&tag.to_string()))
1373            .collect()
1374    }
1375
1376    /// Serialize to bytes
1377    pub fn to_bytes(&self) -> Vec<u8> {
1378        serde_json::to_vec(self).unwrap_or_default()
1379    }
1380
1381    /// Deserialize from bytes
1382    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
1383        serde_json::from_slice(bytes).ok()
1384    }
1385}
1386
1387/// Node API announcement
1388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1389pub struct ApiAnnouncement {
1390    /// Node ID
1391    pub node_id: NodeId,
1392    /// API schemas provided by this node
1393    pub schemas: Vec<ApiSchema>,
1394    /// Announcement version (monotonic)
1395    pub version: u64,
1396    /// Timestamp (Unix millis)
1397    pub timestamp: u64,
1398    /// TTL in seconds
1399    pub ttl_secs: u32,
1400}
1401
1402impl ApiAnnouncement {
1403    /// Create a new announcement
1404    pub fn new(node_id: NodeId, schemas: Vec<ApiSchema>) -> Self {
1405        Self {
1406            node_id,
1407            schemas,
1408            version: 1,
1409            timestamp: SystemTime::now()
1410                .duration_since(UNIX_EPOCH)
1411                .unwrap_or_default()
1412                .as_millis() as u64,
1413            ttl_secs: 300,
1414        }
1415    }
1416
1417    /// Set version
1418    pub fn with_version(mut self, version: u64) -> Self {
1419        self.version = version;
1420        self
1421    }
1422
1423    /// Set TTL
1424    pub fn with_ttl(mut self, ttl_secs: u32) -> Self {
1425        self.ttl_secs = ttl_secs;
1426        self
1427    }
1428
1429    /// Check if expired
1430    pub fn is_expired(&self) -> bool {
1431        let now = SystemTime::now()
1432            .duration_since(UNIX_EPOCH)
1433            .unwrap_or_default()
1434            .as_millis() as u64;
1435        let expiry = self.timestamp + (self.ttl_secs as u64 * 1000);
1436        now > expiry
1437    }
1438
1439    /// Serialize to bytes
1440    pub fn to_bytes(&self) -> Vec<u8> {
1441        serde_json::to_vec(self).unwrap_or_default()
1442    }
1443
1444    /// Deserialize from bytes
1445    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
1446        serde_json::from_slice(bytes).ok()
1447    }
1448}
1449
1450/// API query for finding nodes with specific APIs
1451#[derive(Debug, Clone, Default)]
1452pub struct ApiQuery {
1453    /// Required API name
1454    pub api_name: Option<String>,
1455    /// Minimum version required
1456    pub min_version: Option<ApiVersion>,
1457    /// Required endpoint path pattern
1458    pub endpoint_path: Option<String>,
1459    /// Required endpoint method
1460    pub endpoint_method: Option<ApiMethod>,
1461    /// Required tag
1462    pub tag: Option<String>,
1463    /// Must have specific capability
1464    pub capability: Option<String>,
1465}
1466
1467impl ApiQuery {
1468    /// Create a new query
1469    pub fn new() -> Self {
1470        Self::default()
1471    }
1472
1473    /// Filter by API name
1474    pub fn with_api(mut self, name: impl Into<String>) -> Self {
1475        self.api_name = Some(name.into());
1476        self
1477    }
1478
1479    /// Filter by minimum version
1480    pub fn with_min_version(mut self, version: ApiVersion) -> Self {
1481        self.min_version = Some(version);
1482        self
1483    }
1484
1485    /// Filter by endpoint path
1486    pub fn with_endpoint(mut self, path: impl Into<String>) -> Self {
1487        self.endpoint_path = Some(path.into());
1488        self
1489    }
1490
1491    /// Filter by method
1492    pub fn with_method(mut self, method: ApiMethod) -> Self {
1493        self.endpoint_method = Some(method);
1494        self
1495    }
1496
1497    /// Filter by tag
1498    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
1499        self.tag = Some(tag.into());
1500        self
1501    }
1502
1503    /// Filter by capability
1504    pub fn with_capability(mut self, cap: impl Into<String>) -> Self {
1505        self.capability = Some(cap.into());
1506        self
1507    }
1508
1509    /// Check if a schema matches this query
1510    pub fn matches_schema(&self, schema: &ApiSchema) -> bool {
1511        // Check API name
1512        if let Some(ref name) = self.api_name {
1513            if &schema.name != name {
1514                return false;
1515            }
1516        }
1517
1518        // Check version
1519        if let Some(ref min_ver) = self.min_version {
1520            if !schema.version.is_compatible_with(min_ver) {
1521                return false;
1522            }
1523        }
1524
1525        // Check endpoint
1526        if let Some(ref path) = self.endpoint_path {
1527            let method = self.endpoint_method;
1528            let found = schema.endpoints.iter().any(|e| {
1529                let path_matches = e.path_matches(path) || e.path.contains(path);
1530                let method_matches = method.is_none_or(|m| e.method == m);
1531                path_matches && method_matches
1532            });
1533            if !found {
1534                return false;
1535            }
1536        }
1537
1538        // Check tag
1539        if let Some(ref tag) = self.tag {
1540            if !schema.tags.contains(tag) {
1541                return false;
1542            }
1543        }
1544
1545        // Check capability
1546        if let Some(ref cap) = self.capability {
1547            let found = schema
1548                .endpoints
1549                .iter()
1550                .any(|e| e.required_capabilities.contains(cap));
1551            if !found {
1552                return false;
1553            }
1554        }
1555
1556        true
1557    }
1558}
1559
1560/// Registry errors
1561#[derive(Debug, Clone, PartialEq, Eq)]
1562pub enum RegistryError {
1563    /// Node not found
1564    NodeNotFound(NodeId),
1565    /// API not found
1566    ApiNotFound(String),
1567    /// Version conflict
1568    VersionConflict {
1569        /// Version that was required for the operation
1570        expected: u64,
1571        /// Version that was found in the registry
1572        actual: u64,
1573    },
1574    /// Capacity exceeded
1575    CapacityExceeded,
1576}
1577
1578impl std::fmt::Display for RegistryError {
1579    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1580        match self {
1581            RegistryError::NodeNotFound(_) => write!(f, "Node not found"),
1582            RegistryError::ApiNotFound(name) => write!(f, "API not found: {}", name),
1583            RegistryError::VersionConflict { expected, actual } => {
1584                write!(f, "Version conflict: expected {}, got {}", expected, actual)
1585            }
1586            RegistryError::CapacityExceeded => write!(f, "Registry capacity exceeded"),
1587        }
1588    }
1589}
1590
1591impl std::error::Error for RegistryError {}
1592
1593/// Indexed node API information
1594#[derive(Debug, Clone)]
1595pub struct IndexedApiNode {
1596    /// Node ID
1597    pub node_id: NodeId,
1598    /// API announcement
1599    pub announcement: Arc<ApiAnnouncement>,
1600}
1601
1602/// Registry statistics
1603#[derive(Debug, Clone, Default)]
1604pub struct ApiRegistryStats {
1605    /// Total nodes registered
1606    pub total_nodes: usize,
1607    /// Number of distinct (provider node, API name) pairs — i.e.
1608    /// `apis_by_name.values().sum()`. NOT a count of schema *objects*: a node
1609    /// that lists the same API name in two schemas counts once (the registry
1610    /// indexes API name → provider set, so a name has at most one entry per
1611    /// node). For distinct names per node these coincide.
1612    pub total_schemas: usize,
1613    /// Total endpoint *instances* across all registered schemas (a node's two
1614    /// same-named schemas contribute both their endpoint counts).
1615    pub total_endpoints: usize,
1616    /// For each API name, the number of distinct provider nodes advertising it
1617    /// (deduplicated per node).
1618    pub apis_by_name: HashMap<String, usize>,
1619    /// Query count
1620    pub queries: u64,
1621    /// Update count
1622    pub updates: u64,
1623}
1624
1625/// High-performance API registry with indexes
1626pub struct ApiRegistry {
1627    /// Primary storage: node_id -> announcement
1628    nodes: DashMap<NodeId, Arc<ApiAnnouncement>>,
1629    /// Index by API name
1630    by_api_name: DashMap<String, HashSet<NodeId>>,
1631    /// Index by tag
1632    by_tag: DashMap<String, HashSet<NodeId>>,
1633    /// Index by endpoint path pattern
1634    by_endpoint: DashMap<String, HashSet<NodeId>>,
1635    /// Query counter
1636    query_count: AtomicU64,
1637    /// Update counter
1638    update_count: AtomicU64,
1639    /// O(1) live node count and total endpoint count. `DashMap::len()` walks
1640    /// every shard (~1us) and `stats()` previously full-scanned every node and
1641    /// schema; these atomics make `len()`/`stats()`/the capacity gate O(1).
1642    /// Maintained on register / unregister / clear and in the index helpers.
1643    node_count: AtomicUsize,
1644    total_endpoints: AtomicUsize,
1645    /// Maximum capacity
1646    max_capacity: Option<usize>,
1647}
1648
1649/// Extract the leading path prefix used as the `by_endpoint` index
1650/// key. Slices up to (but not including) the second `/` so the
1651/// computation matches between `add_to_indexes` and
1652/// `remove_from_indexes` without allocating an intermediate
1653/// `Vec<&str>` for the split + join. Equivalent to the previous
1654/// `path.split('/').take(2).collect::<Vec<_>>().join("/")`.
1655fn endpoint_prefix(path: &str) -> String {
1656    match path.match_indices('/').nth(1) {
1657        Some((idx, _)) => path[..idx].to_string(),
1658        None => path.to_string(),
1659    }
1660}
1661
1662impl ApiRegistry {
1663    /// Create a new registry
1664    pub fn new() -> Self {
1665        Self {
1666            nodes: DashMap::new(),
1667            by_api_name: DashMap::new(),
1668            by_tag: DashMap::new(),
1669            by_endpoint: DashMap::new(),
1670            query_count: AtomicU64::new(0),
1671            update_count: AtomicU64::new(0),
1672            node_count: AtomicUsize::new(0),
1673            total_endpoints: AtomicUsize::new(0),
1674            max_capacity: None,
1675        }
1676    }
1677
1678    /// Create with capacity limit
1679    pub fn with_capacity(max: usize) -> Self {
1680        let mut reg = Self::new();
1681        reg.max_capacity = Some(max);
1682        reg
1683    }
1684
1685    /// Register or update a node's APIs
1686    pub fn register(&self, announcement: ApiAnnouncement) -> Result<(), RegistryError> {
1687        let node_id = announcement.node_id;
1688
1689        // Check capacity (O(1) counter, not a per-shard DashMap::len walk).
1690        if let Some(max) = self.max_capacity {
1691            if !self.nodes.contains_key(&node_id) && self.node_count.load(Ordering::Relaxed) >= max
1692            {
1693                return Err(RegistryError::CapacityExceeded);
1694            }
1695        }
1696
1697        let ann = Arc::new(announcement);
1698
1699        // Read-old / re-index / store, all inside the `nodes` entry lock so a
1700        // concurrent re-registration of the same node_id can't interleave and
1701        // drift `total_endpoints` (which would underflow on the `fetch_sub`) or
1702        // `by_api_name` (now surfaced by `stats()`). The index maps touched by
1703        // add/remove_from_indexes are distinct from `nodes`, so holding the
1704        // entry lock across them can't deadlock. Mirrors `MetadataStore::upsert`.
1705        use dashmap::mapref::entry::Entry;
1706        match self.nodes.entry(node_id) {
1707            Entry::Occupied(mut slot) => {
1708                let old = slot.get().clone();
1709                self.remove_from_indexes(&old);
1710                self.add_to_indexes(&ann);
1711                slot.insert(ann);
1712            }
1713            Entry::Vacant(slot) => {
1714                self.add_to_indexes(&ann);
1715                slot.insert(ann);
1716                self.node_count.fetch_add(1, Ordering::Relaxed);
1717            }
1718        }
1719        self.update_count.fetch_add(1, Ordering::Relaxed);
1720
1721        Ok(())
1722    }
1723
1724    /// Unregister a node
1725    pub fn unregister(&self, node_id: &NodeId) -> Option<Arc<ApiAnnouncement>> {
1726        if let Some((_, ann)) = self.nodes.remove(node_id) {
1727            self.remove_from_indexes(&ann);
1728            self.node_count.fetch_sub(1, Ordering::Relaxed);
1729            Some(ann)
1730        } else {
1731            None
1732        }
1733    }
1734
1735    /// Get a node's API announcement
1736    pub fn get(&self, node_id: &NodeId) -> Option<Arc<ApiAnnouncement>> {
1737        self.nodes.get(node_id).map(|r| Arc::clone(&r))
1738    }
1739
1740    /// Query for nodes matching criteria
1741    pub fn query(&self, query: &ApiQuery) -> Vec<IndexedApiNode> {
1742        self.query_count.fetch_add(1, Ordering::Relaxed);
1743
1744        // Use indexes for initial filtering
1745        let candidates: Vec<NodeId> = if let Some(ref api_name) = query.api_name {
1746            self.by_api_name
1747                .get(api_name)
1748                .map(|s| s.iter().copied().collect())
1749                .unwrap_or_default()
1750        } else if let Some(ref tag) = query.tag {
1751            self.by_tag
1752                .get(tag)
1753                .map(|s| s.iter().copied().collect())
1754                .unwrap_or_default()
1755        } else {
1756            // Full scan
1757            self.nodes.iter().map(|r| *r.key()).collect()
1758        };
1759
1760        // Filter and collect
1761        candidates
1762            .into_iter()
1763            .filter_map(|id| {
1764                let ann = self.nodes.get(&id)?;
1765                // Check if any schema matches
1766                let matches = ann.schemas.iter().any(|s| query.matches_schema(s));
1767                if matches && !ann.is_expired() {
1768                    Some(IndexedApiNode {
1769                        node_id: id,
1770                        announcement: Arc::clone(&ann),
1771                    })
1772                } else {
1773                    None
1774                }
1775            })
1776            .collect()
1777    }
1778
1779    /// Find nodes that provide a specific API endpoint
1780    pub fn find_by_endpoint(&self, path: &str, method: ApiMethod) -> Vec<IndexedApiNode> {
1781        self.query_count.fetch_add(1, Ordering::Relaxed);
1782
1783        self.nodes
1784            .iter()
1785            .filter_map(|entry| {
1786                let ann = entry.value();
1787                if ann.is_expired() {
1788                    return None;
1789                }
1790
1791                // Check if any schema has this endpoint. Uses the allocation-
1792                // free `path_matches` (this lookup discards the captured
1793                // params) instead of `matches_path`, which allocated two Vecs
1794                // + a HashMap + a String per endpoint per node — the dominant
1795                // cost of this scan.
1796                let has_endpoint = ann.schemas.iter().any(|schema| {
1797                    schema
1798                        .endpoints
1799                        .iter()
1800                        .any(|e| e.method == method && e.path_matches(path))
1801                });
1802
1803                if has_endpoint {
1804                    Some(IndexedApiNode {
1805                        node_id: *entry.key(),
1806                        announcement: Arc::clone(ann),
1807                    })
1808                } else {
1809                    None
1810                }
1811            })
1812            .collect()
1813    }
1814
1815    /// Find nodes with compatible API version
1816    pub fn find_compatible(&self, api_name: &str, min_version: &ApiVersion) -> Vec<IndexedApiNode> {
1817        self.query_count.fetch_add(1, Ordering::Relaxed);
1818
1819        let candidates = self
1820            .by_api_name
1821            .get(api_name)
1822            .map(|s| s.iter().copied().collect::<Vec<_>>())
1823            .unwrap_or_default();
1824
1825        candidates
1826            .into_iter()
1827            .filter_map(|id| {
1828                let ann = self.nodes.get(&id)?;
1829                if ann.is_expired() {
1830                    return None;
1831                }
1832
1833                let compatible = ann.schemas.iter().any(|schema| {
1834                    schema.name == api_name && schema.version.is_compatible_with(min_version)
1835                });
1836
1837                if compatible {
1838                    Some(IndexedApiNode {
1839                        node_id: id,
1840                        announcement: Arc::clone(&ann),
1841                    })
1842                } else {
1843                    None
1844                }
1845            })
1846            .collect()
1847    }
1848
1849    /// Get statistics
1850    ///
1851    /// `apis_by_name` is read from the `by_api_name` inverted index (providers
1852    /// per API name) rather than re-scanning every node's schemas; empty index
1853    /// buckets left by `remove_from_indexes` are skipped. `total_nodes` and
1854    /// `total_endpoints` read O(1) counters. This avoids the O(nodes × schemas)
1855    /// full scan (+ per-schema String clone) the old implementation paid on
1856    /// every call.
1857    pub fn stats(&self) -> ApiRegistryStats {
1858        let apis_by_name: HashMap<String, usize> = self
1859            .by_api_name
1860            .iter()
1861            .filter(|e| !e.value().is_empty())
1862            .map(|e| (e.key().clone(), e.value().len()))
1863            .collect();
1864
1865        ApiRegistryStats {
1866            total_nodes: self.node_count.load(Ordering::Relaxed),
1867            total_schemas: apis_by_name.values().sum(),
1868            total_endpoints: self.total_endpoints.load(Ordering::Relaxed),
1869            apis_by_name,
1870            queries: self.query_count.load(Ordering::Relaxed),
1871            updates: self.update_count.load(Ordering::Relaxed),
1872        }
1873    }
1874
1875    /// Number of registered nodes
1876    pub fn len(&self) -> usize {
1877        self.node_count.load(Ordering::Relaxed)
1878    }
1879
1880    /// Check if empty
1881    pub fn is_empty(&self) -> bool {
1882        self.node_count.load(Ordering::Relaxed) == 0
1883    }
1884
1885    /// Clear all registrations
1886    pub fn clear(&self) {
1887        // Drain per-key (not `nodes.clear()` + `store(0)`) so the O(1) counters
1888        // decrement in lock-step with the map. A bulk clear that `store(0)`s
1889        // the counters races a concurrent `register`/`unregister` whose
1890        // `fetch_add`/`fetch_sub` can land just after the store — wrapping
1891        // `node_count`/`total_endpoints` to `usize::MAX` and wedging the
1892        // capacity gate and `len()`. `nodes.remove` and the index ops are the
1893        // same chokepoints the live paths use, so the count stays exact w.r.t.
1894        // the map. Mirrors `MetadataStore::clear`.
1895        let keys: Vec<NodeId> = self.nodes.iter().map(|r| *r.key()).collect();
1896        for key in keys {
1897            if let Some((_, ann)) = self.nodes.remove(&key) {
1898                self.remove_from_indexes(&ann); // also decrements total_endpoints
1899                self.node_count.fetch_sub(1, Ordering::Relaxed);
1900            }
1901        }
1902        // Defense-in-depth: clear any index residue from a concurrent register
1903        // that landed after key collection (a happy-path no-op).
1904        self.by_api_name.clear();
1905        self.by_tag.clear();
1906        self.by_endpoint.clear();
1907    }
1908
1909    /// Remove expired entries
1910    pub fn cleanup_expired(&self) -> usize {
1911        let expired: Vec<NodeId> = self
1912            .nodes
1913            .iter()
1914            .filter(|e| e.value().is_expired())
1915            .map(|e| *e.key())
1916            .collect();
1917
1918        let count = expired.len();
1919        for id in expired {
1920            self.unregister(&id);
1921        }
1922        count
1923    }
1924
1925    // Private helper to add indexes
1926    fn add_to_indexes(&self, ann: &ApiAnnouncement) {
1927        let node_id = ann.node_id;
1928        let mut added_endpoints = 0usize;
1929
1930        for schema in &ann.schemas {
1931            // API name index
1932            self.by_api_name
1933                .entry(schema.name.clone())
1934                .or_default()
1935                .insert(node_id);
1936
1937            // Tag index
1938            for tag in &schema.tags {
1939                self.by_tag.entry(tag.clone()).or_default().insert(node_id);
1940            }
1941
1942            // Endpoint index (simplified - just uses path prefix).
1943            for endpoint in &schema.endpoints {
1944                let prefix = endpoint_prefix(&endpoint.path);
1945                self.by_endpoint.entry(prefix).or_default().insert(node_id);
1946            }
1947            added_endpoints += schema.endpoints.len();
1948        }
1949        self.total_endpoints
1950            .fetch_add(added_endpoints, Ordering::Relaxed);
1951    }
1952
1953    // Private helper to remove indexes
1954    fn remove_from_indexes(&self, ann: &ApiAnnouncement) {
1955        let node_id = ann.node_id;
1956        let mut removed_endpoints = 0usize;
1957
1958        for schema in &ann.schemas {
1959            if let Some(mut set) = self.by_api_name.get_mut(&schema.name) {
1960                set.remove(&node_id);
1961            }
1962
1963            for tag in &schema.tags {
1964                if let Some(mut set) = self.by_tag.get_mut(tag) {
1965                    set.remove(&node_id);
1966                }
1967            }
1968
1969            for endpoint in &schema.endpoints {
1970                let prefix = endpoint_prefix(&endpoint.path);
1971                if let Some(mut set) = self.by_endpoint.get_mut(&prefix) {
1972                    set.remove(&node_id);
1973                }
1974            }
1975            removed_endpoints += schema.endpoints.len();
1976        }
1977        self.total_endpoints
1978            .fetch_sub(removed_endpoints, Ordering::Relaxed);
1979    }
1980}
1981
1982impl Default for ApiRegistry {
1983    fn default() -> Self {
1984        Self::new()
1985    }
1986}
1987
1988#[cfg(test)]
1989mod tests {
1990    use super::*;
1991
1992    fn make_node_id(n: u8) -> NodeId {
1993        let mut id = [0u8; 32];
1994        id[0] = n;
1995        id
1996    }
1997
1998    #[test]
1999    fn test_schema_type_validation() {
2000        // String validation
2001        let schema = SchemaType::string().with_max_length(10);
2002        assert!(schema.validate(&serde_json::json!("hello")).is_ok());
2003        assert!(schema.validate(&serde_json::json!("hello world!")).is_err());
2004
2005        // Integer validation
2006        let schema = SchemaType::integer().with_minimum(0).with_maximum(100);
2007        assert!(schema.validate(&serde_json::json!(50)).is_ok());
2008        assert!(schema.validate(&serde_json::json!(-1)).is_err());
2009        assert!(schema.validate(&serde_json::json!(101)).is_err());
2010
2011        // Object validation
2012        let schema = SchemaType::object()
2013            .with_property("name", SchemaType::string())
2014            .with_property("age", SchemaType::integer())
2015            .with_required("name");
2016
2017        assert!(schema
2018            .validate(&serde_json::json!({"name": "Alice", "age": 30}))
2019            .is_ok());
2020        assert!(schema.validate(&serde_json::json!({"age": 30})).is_err()); // missing required
2021
2022        // Array validation
2023        let schema = SchemaType::array(SchemaType::integer());
2024        assert!(schema.validate(&serde_json::json!([1, 2, 3])).is_ok());
2025        assert!(schema.validate(&serde_json::json!([1, "two", 3])).is_err());
2026    }
2027
2028    /// Regression for BUG_AUDIT_2026_04_30_CORE.md #109: pre-fix
2029    /// `SchemaType::validate` recursed without bound through
2030    /// `Array { items }` / `Object { properties }` / `AnyOf { schemas }`.
2031    /// An attacker who could ship a `SchemaType` (announcements
2032    /// broadcast over the mesh, or any caller parsing untrusted
2033    /// JSON) could submit a deeply-nested schema and crash the
2034    /// process via stack overflow when a request was validated.
2035    /// Post-fix: depth is bounded by `MAX_SCHEMA_DEPTH`;
2036    /// exceeding it returns `RecursionLimitExceeded` instead.
2037    ///
2038    /// We pin the bound by constructing a schema deeper than the
2039    /// limit (chained Array variants — each `Array { items: ... }`
2040    /// adds one level of recursion). With a payload that walks
2041    /// through every level, validate must surface the
2042    /// recursion-limit error rather than blowing the stack.
2043    #[test]
2044    fn validate_returns_recursion_limit_error_on_deeply_nested_schema() {
2045        // Build an Array<Array<Array<...<Integer>...>>> with
2046        // depth = MAX_SCHEMA_DEPTH + 5 (well past the cap).
2047        let mut schema = SchemaType::integer();
2048        for _ in 0..MAX_SCHEMA_DEPTH + 5 {
2049            schema = SchemaType::array(schema);
2050        }
2051
2052        // Build a matching nested-array payload so each level
2053        // descends into the next.
2054        let mut value = serde_json::json!(1);
2055        for _ in 0..MAX_SCHEMA_DEPTH + 5 {
2056            value = serde_json::json!([value]);
2057        }
2058
2059        // Pre-fix: stack overflow. Post-fix: bounded error.
2060        let result = schema.validate(&value);
2061        match result {
2062            Err(ValidationError::RecursionLimitExceeded { limit }) => {
2063                assert_eq!(limit, MAX_SCHEMA_DEPTH);
2064            }
2065            other => panic!("expected RecursionLimitExceeded, got {:?}", other),
2066        }
2067    }
2068
2069    /// Sanity: a schema at exactly `MAX_SCHEMA_DEPTH` levels of
2070    /// nesting must still validate successfully (no false-positive
2071    /// recursion error).
2072    #[test]
2073    fn validate_accepts_schema_at_recursion_limit() {
2074        let mut schema = SchemaType::integer();
2075        // depth = MAX_SCHEMA_DEPTH - 1 means the validator visits
2076        // depth values 0..MAX_SCHEMA_DEPTH, all under the cap.
2077        for _ in 0..(MAX_SCHEMA_DEPTH - 1) {
2078            schema = SchemaType::array(schema);
2079        }
2080        let mut value = serde_json::json!(1);
2081        for _ in 0..(MAX_SCHEMA_DEPTH - 1) {
2082            value = serde_json::json!([value]);
2083        }
2084        assert!(
2085            schema.validate(&value).is_ok(),
2086            "schema right at the depth limit must still validate"
2087        );
2088    }
2089
2090    /// CR-9: deeply-nested input must be rejected at the deserialize
2091    /// boundary, BEFORE `validate` is called. Pre-fix the cap was
2092    /// only enforced post-parse — an adversarial schema could
2093    /// trigger the recursive `Deserialize` to allocate a deep
2094    /// `SchemaType` tree (or stack-overflow on a very deep input)
2095    /// before any validation ran.
2096    #[test]
2097    fn try_from_slice_rejects_input_over_max_schema_depth() {
2098        // Build a JSON string with MAX_SCHEMA_DEPTH + 50 nested
2099        // arrays. Even though valid JSON, it must trip the
2100        // depth-scan guard before serde_json runs.
2101        let depth = MAX_SCHEMA_DEPTH + 50;
2102        let mut s = String::new();
2103        for _ in 0..depth {
2104            s.push('[');
2105        }
2106        s.push_str("null");
2107        for _ in 0..depth {
2108            s.push(']');
2109        }
2110        let err = SchemaType::try_from_str(&s)
2111            .expect_err("deeply-nested JSON must be rejected by the depth pre-scan");
2112        let msg = format!("{}", err);
2113        assert!(
2114            msg.contains("max nesting depth exceeded"),
2115            "error message must name the depth cap; got: {}",
2116            msg
2117        );
2118    }
2119
2120    /// CR-9: the depth pre-scan must not be fooled by JSON strings
2121    /// containing brackets. A long string of `}`s inside `"..."`
2122    /// must NOT be counted as depth-out (which would let an
2123    /// attacker mask real depth).
2124    #[test]
2125    fn try_from_slice_handles_brackets_inside_strings_correctly() {
2126        // A schema with a `pattern` field containing brackets in
2127        // a string. The string brackets must be ignored by the
2128        // depth counter.
2129        let json = r#"{"type":"string","pattern":"[}{]\""}"#;
2130        let r = SchemaType::try_from_str(json);
2131        assert!(
2132            r.is_ok(),
2133            "valid schema with bracket-bearing string must parse: {:?}",
2134            r.err()
2135        );
2136    }
2137
2138    /// CR-9: a moderately-deep schema (well under both the depth
2139    /// pre-scan cap AND serde_json's internal recursion limit)
2140    /// must parse cleanly. The internal serde_json limit (128) and
2141    /// our `MAX_SCHEMA_DEPTH` (128) are intentionally aligned, but
2142    /// each `Box<SchemaType>` adds a serde call frame on top of
2143    /// the byte-counter depth, so the effective serde-side ceiling
2144    /// is a bit below `MAX_SCHEMA_DEPTH`. We pin a depth of 32
2145    /// here — comfortably representative of any real-world nested
2146    /// schema and well within both caps.
2147    #[test]
2148    fn try_from_slice_accepts_normal_depth_schema() {
2149        let depth = 32usize;
2150        let mut s = String::new();
2151        for _ in 0..depth {
2152            s.push_str(r#"{"type":"array","items":"#);
2153        }
2154        s.push_str(r#"{"type":"null"}"#);
2155        for _ in 0..depth {
2156            s.push('}');
2157        }
2158        let r = SchemaType::try_from_str(&s);
2159        assert!(
2160            r.is_ok(),
2161            "moderately-nested schema (depth {}) must parse; got: {:?}",
2162            depth,
2163            r.err()
2164        );
2165    }
2166
2167    /// CR-9: direct unit test on the depth scanner — confirms
2168    /// it counts both `{`/`}` and `[`/`]` correctly and respects
2169    /// string-literal boundaries.
2170    #[test]
2171    fn check_json_nesting_depth_unit() {
2172        assert!(check_json_nesting_depth(b"{}", 1).is_ok());
2173        assert!(check_json_nesting_depth(b"{}", 0).is_err()); // depth 1 > 0
2174        assert!(check_json_nesting_depth(b"[[[[]]]]", 4).is_ok());
2175        assert!(check_json_nesting_depth(b"[[[[]]]]", 3).is_err());
2176        // Brackets inside a string are NOT counted.
2177        assert!(check_json_nesting_depth(b"\"[[[[\"", 0).is_ok());
2178        // Escaped quote keeps us inside the string.
2179        assert!(check_json_nesting_depth(b"\"[\\\"[[\"", 0).is_ok());
2180        // Mixed nesting.
2181        assert!(check_json_nesting_depth(b"{\"a\":[1,2]}", 2).is_ok());
2182        assert!(check_json_nesting_depth(b"{\"a\":[1,2]}", 1).is_err());
2183    }
2184
2185    #[test]
2186    fn test_api_endpoint_path_matching() {
2187        let endpoint = ApiEndpoint::new("/models/{model_id}/infer", ApiMethod::Post)
2188            .with_path_param(ApiParameter::required("model_id", SchemaType::string()));
2189
2190        // Should match
2191        let params = endpoint.matches_path("/models/llama-7b/infer");
2192        assert!(params.is_some());
2193        let params = params.unwrap();
2194        assert_eq!(params.get("model_id"), Some(&"llama-7b".to_string()));
2195
2196        // Should not match (wrong path)
2197        assert!(endpoint.matches_path("/models/llama-7b/train").is_none());
2198        assert!(endpoint.matches_path("/models/infer").is_none());
2199    }
2200
2201    #[test]
2202    fn test_api_version_compatibility() {
2203        let v1_0_0 = ApiVersion::new(1, 0, 0);
2204        let v1_1_0 = ApiVersion::new(1, 1, 0);
2205        let v1_1_1 = ApiVersion::new(1, 1, 1);
2206        let v2_0_0 = ApiVersion::new(2, 0, 0);
2207
2208        // Same version is compatible
2209        assert!(v1_0_0.is_compatible_with(&v1_0_0));
2210
2211        // Higher minor version is compatible
2212        assert!(v1_1_0.is_compatible_with(&v1_0_0));
2213
2214        // Higher patch version is compatible
2215        assert!(v1_1_1.is_compatible_with(&v1_1_0));
2216
2217        // Lower minor version is not compatible
2218        assert!(!v1_0_0.is_compatible_with(&v1_1_0));
2219
2220        // Different major version is not compatible
2221        assert!(!v2_0_0.is_compatible_with(&v1_0_0));
2222        assert!(!v1_0_0.is_compatible_with(&v2_0_0));
2223    }
2224
2225    #[test]
2226    fn test_api_schema() {
2227        let schema = ApiSchema::new("inference", ApiVersion::new(1, 0, 0))
2228            .with_description("Model inference API")
2229            .with_base_path("/api/v1")
2230            .with_tag("ai")
2231            .add_endpoint(
2232                ApiEndpoint::new("/models/{model_id}/infer", ApiMethod::Post)
2233                    .with_description("Run inference on a model")
2234                    .with_tag("inference"),
2235            )
2236            .add_endpoint(
2237                ApiEndpoint::new("/models", ApiMethod::Get)
2238                    .with_description("List available models")
2239                    .with_tag("models"),
2240            );
2241
2242        assert_eq!(schema.endpoints.len(), 2);
2243        assert!(schema.tags.contains(&"ai".to_string()));
2244
2245        // Find by tag
2246        let inference_endpoints = schema.endpoints_by_tag("inference");
2247        assert_eq!(inference_endpoints.len(), 1);
2248    }
2249
2250    #[test]
2251    fn test_api_registry_basic() {
2252        let registry = ApiRegistry::new();
2253
2254        let schema = ApiSchema::new("test-api", ApiVersion::new(1, 0, 0))
2255            .with_tag("test")
2256            .add_endpoint(ApiEndpoint::new("/test", ApiMethod::Get));
2257
2258        let ann = ApiAnnouncement::new(make_node_id(1), vec![schema]);
2259        registry.register(ann).unwrap();
2260
2261        assert_eq!(registry.len(), 1);
2262
2263        let result = registry.get(&make_node_id(1));
2264        assert!(result.is_some());
2265
2266        registry.unregister(&make_node_id(1));
2267        assert_eq!(registry.len(), 0);
2268    }
2269
2270    #[test]
2271    fn test_api_registry_query() {
2272        let registry = ApiRegistry::new();
2273
2274        // Add multiple nodes with different APIs
2275        for i in 0..10 {
2276            let api_name = if i < 5 { "inference" } else { "training" };
2277            let tag = if i % 2 == 0 { "gpu" } else { "cpu" };
2278
2279            let schema = ApiSchema::new(api_name, ApiVersion::new(1, i as u32, 0))
2280                .with_tag(tag)
2281                .add_endpoint(ApiEndpoint::new("/run", ApiMethod::Post));
2282
2283            let ann = ApiAnnouncement::new(make_node_id(i), vec![schema]);
2284            registry.register(ann).unwrap();
2285        }
2286
2287        // Query by API name
2288        let results = registry.query(&ApiQuery::new().with_api("inference"));
2289        assert_eq!(results.len(), 5);
2290
2291        // Query by tag
2292        let results = registry.query(&ApiQuery::new().with_tag("gpu"));
2293        assert_eq!(results.len(), 5);
2294
2295        // Query by both
2296        let results = registry.query(&ApiQuery::new().with_api("inference").with_tag("gpu"));
2297        // inference (0-4), gpu (0,2,4,6,8) -> intersection is 0,2,4
2298        assert_eq!(results.len(), 3);
2299    }
2300
2301    #[test]
2302    fn test_api_registry_version_compatibility() {
2303        let registry = ApiRegistry::new();
2304
2305        // Add nodes with different versions
2306        for i in 0..5 {
2307            let schema = ApiSchema::new("my-api", ApiVersion::new(1, i as u32, 0));
2308            let ann = ApiAnnouncement::new(make_node_id(i), vec![schema]);
2309            registry.register(ann).unwrap();
2310        }
2311
2312        // Find nodes compatible with v1.2.0
2313        let results = registry.find_compatible("my-api", &ApiVersion::new(1, 2, 0));
2314        // v1.2.0, v1.3.0, v1.4.0 are compatible
2315        assert_eq!(results.len(), 3);
2316    }
2317
2318    #[test]
2319    fn test_request_validation() {
2320        let endpoint = ApiEndpoint::new("/users/{user_id}", ApiMethod::Get)
2321            .with_path_param(ApiParameter::required("user_id", SchemaType::string()))
2322            .with_query_param(ApiParameter::optional("limit", SchemaType::integer()));
2323
2324        // Valid request
2325        let mut path_params = HashMap::new();
2326        path_params.insert("user_id".to_string(), serde_json::json!("123"));
2327
2328        let query_params = HashMap::new();
2329
2330        let result = endpoint.validate_request(&path_params, &query_params, None);
2331        assert!(result.is_ok());
2332
2333        // Missing required path param
2334        let empty_path = HashMap::new();
2335        let result = endpoint.validate_request(&empty_path, &query_params, None);
2336        assert!(matches!(
2337            result,
2338            Err(ApiValidationError::MissingPathParameter { .. })
2339        ));
2340    }
2341
2342    #[test]
2343    fn test_api_method_properties() {
2344        assert!(ApiMethod::Get.is_idempotent());
2345        assert!(ApiMethod::Put.is_idempotent());
2346        assert!(!ApiMethod::Post.is_idempotent());
2347
2348        assert!(ApiMethod::Stream.is_streaming());
2349        assert!(ApiMethod::BiStream.is_streaming());
2350        assert!(!ApiMethod::Get.is_streaming());
2351
2352        assert!(ApiMethod::Get.is_safe());
2353        assert!(!ApiMethod::Post.is_safe());
2354    }
2355
2356    #[test]
2357    fn test_stats() {
2358        let registry = ApiRegistry::new();
2359
2360        for i in 0..5 {
2361            let schema = ApiSchema::new("api", ApiVersion::new(1, 0, 0))
2362                .add_endpoint(ApiEndpoint::new("/a", ApiMethod::Get))
2363                .add_endpoint(ApiEndpoint::new("/b", ApiMethod::Post));
2364
2365            let ann = ApiAnnouncement::new(make_node_id(i), vec![schema]);
2366            registry.register(ann).unwrap();
2367        }
2368
2369        // Run some queries
2370        registry.query(&ApiQuery::new());
2371        registry.query(&ApiQuery::new());
2372
2373        let stats = registry.stats();
2374        assert_eq!(stats.total_nodes, 5);
2375        assert_eq!(stats.total_schemas, 5);
2376        assert_eq!(stats.total_endpoints, 10);
2377        assert_eq!(stats.queries, 2);
2378        assert_eq!(stats.updates, 5);
2379    }
2380
2381    /// The O(1) node/endpoint counters backing len()/stats() (and the
2382    /// index-derived apis_by_name) must stay exact across register, in-place
2383    /// update (same node_id, different schema), unregister, and clear.
2384    #[test]
2385    fn stats_and_len_track_register_update_unregister_clear() {
2386        let registry = ApiRegistry::new();
2387        for i in 0..4u8 {
2388            let schema = ApiSchema::new("api", ApiVersion::new(1, 0, 0))
2389                .add_endpoint(ApiEndpoint::new("/a", ApiMethod::Get))
2390                .add_endpoint(ApiEndpoint::new("/b", ApiMethod::Post));
2391            registry
2392                .register(ApiAnnouncement::new(make_node_id(i), vec![schema]))
2393                .unwrap();
2394        }
2395        assert_eq!(registry.len(), 4);
2396        let s = registry.stats();
2397        assert_eq!(s.total_nodes, 4);
2398        assert_eq!(s.apis_by_name.get("api"), Some(&4));
2399        assert_eq!(s.total_schemas, 4);
2400        assert_eq!(s.total_endpoints, 8);
2401
2402        // Update node 0 in place: "api" (2 endpoints) -> "api2" (1 endpoint).
2403        let schema = ApiSchema::new("api2", ApiVersion::new(1, 0, 0))
2404            .add_endpoint(ApiEndpoint::new("/c", ApiMethod::Get));
2405        registry
2406            .register(ApiAnnouncement::new(make_node_id(0), vec![schema]))
2407            .unwrap();
2408        assert_eq!(registry.len(), 4, "update must not grow node count");
2409        let s = registry.stats();
2410        assert_eq!(s.total_nodes, 4);
2411        assert_eq!(s.apis_by_name.get("api"), Some(&3));
2412        assert_eq!(s.apis_by_name.get("api2"), Some(&1));
2413        assert_eq!(s.total_endpoints, 7); // 3*2 + 1*1
2414
2415        // Unregister.
2416        assert!(registry.unregister(&make_node_id(1)).is_some());
2417        assert_eq!(registry.len(), 3);
2418        assert_eq!(registry.stats().total_nodes, 3);
2419        assert!(registry.unregister(&make_node_id(99)).is_none());
2420        assert_eq!(registry.len(), 3);
2421
2422        // Clear resets every counter and the index-derived histogram.
2423        registry.clear();
2424        assert_eq!(registry.len(), 0);
2425        assert!(registry.is_empty());
2426        let s = registry.stats();
2427        assert_eq!(s.total_nodes, 0);
2428        assert_eq!(s.total_endpoints, 0);
2429        assert!(s.apis_by_name.is_empty());
2430    }
2431
2432    /// stats() is derived from the `by_api_name` *provider set*, so a single
2433    /// node listing the same API name in two schemas counts as ONE provider
2434    /// for that name (and one toward `total_schemas`) — while every endpoint
2435    /// instance still counts toward `total_endpoints`. Pins the deduped-per-node
2436    /// semantics documented on `ApiRegistryStats`; they differ from a naive
2437    /// schema-object count only in this degenerate duplicate-name case.
2438    #[test]
2439    fn stats_dedupes_duplicate_api_names_within_a_node() {
2440        let registry = ApiRegistry::new();
2441        let dup_a = ApiSchema::new("dup", ApiVersion::new(1, 0, 0))
2442            .add_endpoint(ApiEndpoint::new("/a", ApiMethod::Get));
2443        let dup_b = ApiSchema::new("dup", ApiVersion::new(2, 0, 0))
2444            .add_endpoint(ApiEndpoint::new("/b", ApiMethod::Get))
2445            .add_endpoint(ApiEndpoint::new("/c", ApiMethod::Post));
2446        registry
2447            .register(ApiAnnouncement::new(make_node_id(0), vec![dup_a, dup_b]))
2448            .unwrap();
2449
2450        let s = registry.stats();
2451        assert_eq!(s.total_nodes, 1);
2452        assert_eq!(
2453            s.apis_by_name.get("dup"),
2454            Some(&1),
2455            "one node = one provider of 'dup', even with two same-named schemas"
2456        );
2457        assert_eq!(
2458            s.total_schemas, 1,
2459            "total_schemas counts (node, name) pairs"
2460        );
2461        assert_eq!(
2462            s.total_endpoints, 3,
2463            "every endpoint instance still counts (1 + 2)"
2464        );
2465
2466        // A second node advertising 'dup' bumps the provider count to 2.
2467        let other = ApiSchema::new("dup", ApiVersion::new(1, 0, 0))
2468            .add_endpoint(ApiEndpoint::new("/a", ApiMethod::Get));
2469        registry
2470            .register(ApiAnnouncement::new(make_node_id(1), vec![other]))
2471            .unwrap();
2472        let s = registry.stats();
2473        assert_eq!(s.apis_by_name.get("dup"), Some(&2));
2474        assert_eq!(s.total_schemas, 2);
2475        assert_eq!(s.total_endpoints, 4);
2476    }
2477
2478    /// Concurrent re-registration of the SAME node_id must keep the O(1)
2479    /// counters exact: each `register` is an atomic remove-old + add-new under
2480    /// the `nodes` entry lock, so `total_endpoints` can't drift (or underflow
2481    /// on the `fetch_sub`) and `node_count` stays 1. Pre-fix the
2482    /// read-old/add-indexes/insert steps were separately locked and could
2483    /// interleave under contention.
2484    #[test]
2485    fn concurrent_same_node_register_keeps_counters_exact() {
2486        use std::sync::Arc as StdArc;
2487
2488        let registry = StdArc::new(ApiRegistry::new());
2489        let threads = 16;
2490        let iters = 200;
2491
2492        let mut handles = Vec::new();
2493        for _ in 0..threads {
2494            let registry = StdArc::clone(&registry);
2495            handles.push(std::thread::spawn(move || {
2496                for _ in 0..iters {
2497                    let schema = ApiSchema::new("api", ApiVersion::new(1, 0, 0))
2498                        .add_endpoint(ApiEndpoint::new("/a", ApiMethod::Get))
2499                        .add_endpoint(ApiEndpoint::new("/b", ApiMethod::Post));
2500                    registry
2501                        .register(ApiAnnouncement::new(make_node_id(0), vec![schema]))
2502                        .unwrap();
2503                }
2504            }));
2505        }
2506        for h in handles {
2507            h.join().unwrap();
2508        }
2509
2510        // Exactly one node with two endpoints, regardless of interleaving.
2511        assert_eq!(registry.len(), 1);
2512        let s = registry.stats();
2513        assert_eq!(s.total_nodes, 1);
2514        assert_eq!(
2515            s.total_endpoints, 2,
2516            "total_endpoints must not drift or underflow under concurrent re-register"
2517        );
2518        assert_eq!(s.apis_by_name.get("api"), Some(&1));
2519    }
2520
2521    /// `clear()` must not race the per-op counter updates into underflow.
2522    /// Concurrent register/unregister + clear must leave `node_count`/
2523    /// `total_endpoints` exactly tracking the map — never wrapped to a huge
2524    /// value. Pre-fix, `clear()` `store(0)`'d the counters while a concurrent
2525    /// `unregister`'s `fetch_sub(1)` could land just after, wrapping to
2526    /// `usize::MAX`.
2527    #[test]
2528    fn concurrent_clear_does_not_underflow_counters() {
2529        use std::sync::Arc as StdArc;
2530
2531        let registry = StdArc::new(ApiRegistry::new());
2532        let mut handles = Vec::new();
2533
2534        // Writers: repeatedly register then unregister their own node.
2535        for t in 0..8u8 {
2536            let registry = StdArc::clone(&registry);
2537            handles.push(std::thread::spawn(move || {
2538                for _ in 0..500 {
2539                    let schema = ApiSchema::new("api", ApiVersion::new(1, 0, 0))
2540                        .add_endpoint(ApiEndpoint::new("/a", ApiMethod::Get));
2541                    registry
2542                        .register(ApiAnnouncement::new(make_node_id(t), vec![schema]))
2543                        .unwrap();
2544                    registry.unregister(&make_node_id(t));
2545                }
2546            }));
2547        }
2548        // Clearer: hammer clear() against the writers.
2549        {
2550            let registry = StdArc::clone(&registry);
2551            handles.push(std::thread::spawn(move || {
2552                for _ in 0..500 {
2553                    registry.clear();
2554                }
2555            }));
2556        }
2557        for h in handles {
2558            h.join().unwrap();
2559        }
2560
2561        // After quiescence the counters must equal the authoritative map
2562        // exactly — a wrapped/underflowed counter would diverge wildly.
2563        assert_eq!(
2564            registry.len(),
2565            registry.nodes.len(),
2566            "node_count must track the map, not underflow"
2567        );
2568        let live_endpoints: usize = registry
2569            .nodes
2570            .iter()
2571            .map(|e| {
2572                e.value()
2573                    .schemas
2574                    .iter()
2575                    .map(|s| s.endpoints.len())
2576                    .sum::<usize>()
2577            })
2578            .sum();
2579        assert_eq!(
2580            registry.stats().total_endpoints,
2581            live_endpoints,
2582            "total_endpoints must track the map, not underflow"
2583        );
2584    }
2585
2586    /// `path_matches` (no-alloc) must agree with `matches_path(..).is_some()`
2587    /// — find_by_endpoint and friends were switched to it.
2588    #[test]
2589    fn path_matches_agrees_with_matches_path() {
2590        let e = ApiEndpoint::new("/models/{model_id}/infer", ApiMethod::Post);
2591        for p in [
2592            "/models/llama-7b/infer", // match (param)
2593            "/models/llama-7b/train", // wrong tail
2594            "/models/infer",          // too few segments
2595            "/models/a/infer/x",      // too many segments
2596        ] {
2597            assert_eq!(
2598                e.path_matches(p),
2599                e.matches_path(p).is_some(),
2600                "disagreement on {p}"
2601            );
2602        }
2603        assert!(e.path_matches("/models/llama-7b/infer"));
2604
2605        let lit = ApiEndpoint::new("/health", ApiMethod::Get);
2606        assert!(lit.path_matches("/health"));
2607        assert!(!lit.path_matches("/healthz"));
2608    }
2609
2610    /// `endpoint_prefix` replaces the previous
2611    /// `path.split('/').take(2).collect::<Vec<_>>().join("/")` with a
2612    /// `match_indices('/').nth(1)`-based slice. The replacement
2613    /// must be byte-identical for every shape we feed it — a single
2614    /// drift here would put `add_to_indexes` and
2615    /// `remove_from_indexes` out of sync and silently leak entries
2616    /// in `by_endpoint`. Each case below names the previous
2617    /// behavior explicitly so a future reviewer can see the
2618    /// equivalence at a glance.
2619    #[test]
2620    fn endpoint_prefix_matches_previous_split_join_behavior() {
2621        // Helper that runs the OLD logic for ground truth.
2622        fn old(path: &str) -> String {
2623            path.split('/').take(2).collect::<Vec<_>>().join("/")
2624        }
2625
2626        let cases: &[&str] = &[
2627            "",               // empty
2628            "/",              // a lone separator
2629            "//",             // two separators, nothing between
2630            "//a",            // empty leading segment, then content
2631            "/a",             // single leading-slash segment
2632            "/a/",            // trailing slash
2633            "a",              // no slashes at all
2634            "a/",             // single segment + trailing slash
2635            "/api",           // typical absolute root
2636            "/api/users",     // two-segment absolute
2637            "/api/users/123", // deep absolute
2638            "api/users/123",  // deep relative
2639            "/api/users/v2/list",
2640            "////",
2641        ];
2642
2643        for path in cases {
2644            assert_eq!(
2645                endpoint_prefix(path),
2646                old(path),
2647                "endpoint_prefix divergence for {path:?}",
2648            );
2649        }
2650    }
2651
2652    // ---------- Validation error-branch coverage ----------
2653    //
2654    // The existing happy-path tests cover the success arms of
2655    // `SchemaType::validate`. These exercise the negative branches
2656    // that codecov flagged as uncovered: `Number` min/max (distinct
2657    // from `Integer`), every type-mismatch arm, string length /
2658    // pattern errors, array length / uniqueness errors, object
2659    // property errors, and the `Enum` / `AnyOf` / `Ref` arms.
2660
2661    #[test]
2662    fn number_variant_range_and_type_errors() {
2663        let schema = SchemaType::Number {
2664            minimum: Some(0.0),
2665            maximum: Some(1.0),
2666        };
2667        assert!(schema.validate(&serde_json::json!(0.5)).is_ok());
2668        assert!(matches!(
2669            schema.validate(&serde_json::json!(-0.1)),
2670            Err(ValidationError::RangeError { .. })
2671        ));
2672        assert!(matches!(
2673            schema.validate(&serde_json::json!(1.5)),
2674            Err(ValidationError::RangeError { .. })
2675        ));
2676        assert!(matches!(
2677            schema.validate(&serde_json::json!("nope")),
2678            Err(ValidationError::TypeMismatch { .. })
2679        ));
2680    }
2681
2682    #[test]
2683    fn string_length_pattern_and_type_errors() {
2684        let schema = SchemaType::String {
2685            min_length: Some(2),
2686            max_length: Some(5),
2687            pattern: Some("ab".into()),
2688            format: None,
2689        };
2690        assert!(schema.validate(&serde_json::json!("xab")).is_ok());
2691        assert!(matches!(
2692            schema.validate(&serde_json::json!("a")),
2693            Err(ValidationError::LengthError { .. })
2694        ));
2695        assert!(matches!(
2696            schema.validate(&serde_json::json!("abcdef")),
2697            Err(ValidationError::LengthError { .. })
2698        ));
2699        assert!(matches!(
2700            schema.validate(&serde_json::json!("xyz")),
2701            Err(ValidationError::PatternMismatch { .. })
2702        ));
2703        assert!(matches!(
2704            schema.validate(&serde_json::json!(42)),
2705            Err(ValidationError::TypeMismatch { .. })
2706        ));
2707    }
2708
2709    #[test]
2710    fn array_length_uniqueness_and_type_errors() {
2711        let schema = SchemaType::Array {
2712            items: Box::new(SchemaType::integer()),
2713            min_items: Some(2),
2714            max_items: Some(3),
2715            unique_items: true,
2716        };
2717        assert!(schema.validate(&serde_json::json!([1, 2])).is_ok());
2718        assert!(matches!(
2719            schema.validate(&serde_json::json!([1])),
2720            Err(ValidationError::LengthError { .. })
2721        ));
2722        assert!(matches!(
2723            schema.validate(&serde_json::json!([1, 2, 3, 4])),
2724            Err(ValidationError::LengthError { .. })
2725        ));
2726        assert!(matches!(
2727            schema.validate(&serde_json::json!([1, 1, 2])),
2728            Err(ValidationError::DuplicateItems)
2729        ));
2730        assert!(matches!(
2731            schema.validate(&serde_json::json!([1, "two", 3])),
2732            Err(ValidationError::ArrayItemError { .. })
2733        ));
2734        assert!(matches!(
2735            schema.validate(&serde_json::json!("not-an-array")),
2736            Err(ValidationError::TypeMismatch { .. })
2737        ));
2738    }
2739
2740    #[test]
2741    fn object_property_unknown_and_type_errors() {
2742        let schema = SchemaType::object()
2743            .with_property("name", SchemaType::string())
2744            .with_property("age", SchemaType::integer())
2745            .with_required("name");
2746
2747        // PropertyError: known property fails its own schema.
2748        let err = schema
2749            .validate(&serde_json::json!({"name": "Alice", "age": "old"}))
2750            .unwrap_err();
2751        assert!(matches!(err, ValidationError::PropertyError { .. }));
2752
2753        // UnknownProperty: requires additional_properties=false, which
2754        // the `SchemaType::object()` builder doesn't expose — construct
2755        // directly to flip it.
2756        let strict = SchemaType::Object {
2757            properties: {
2758                let mut m = HashMap::new();
2759                m.insert("name".into(), SchemaType::string());
2760                m
2761            },
2762            required: vec!["name".into()],
2763            additional_properties: false,
2764        };
2765        let err = strict
2766            .validate(&serde_json::json!({"name": "Alice", "extra": 1}))
2767            .unwrap_err();
2768        assert!(matches!(err, ValidationError::UnknownProperty { .. }));
2769
2770        // TypeMismatch: object schema receives non-object.
2771        assert!(matches!(
2772            schema.validate(&serde_json::json!([1, 2, 3])),
2773            Err(ValidationError::TypeMismatch { .. })
2774        ));
2775    }
2776
2777    #[test]
2778    fn enum_anyof_and_ref_arms() {
2779        // Enum miss.
2780        let schema = SchemaType::Enum {
2781            values: vec![serde_json::json!("a"), serde_json::json!("b")],
2782        };
2783        assert!(schema.validate(&serde_json::json!("a")).is_ok());
2784        assert!(matches!(
2785            schema.validate(&serde_json::json!("c")),
2786            Err(ValidationError::EnumMismatch { .. })
2787        ));
2788
2789        // AnyOf success on second arm + AnyOfFailed when all reject.
2790        let any = SchemaType::AnyOf {
2791            schemas: vec![SchemaType::integer(), SchemaType::string()],
2792        };
2793        assert!(any.validate(&serde_json::json!("ok")).is_ok());
2794        assert!(any.validate(&serde_json::json!(42)).is_ok());
2795        assert!(matches!(
2796            any.validate(&serde_json::json!(true)),
2797            Err(ValidationError::AnyOfFailed { .. })
2798        ));
2799
2800        // Ref arm at validator level returns Ok — resolution
2801        // is a registry-level concern (see L699-702).
2802        let r = SchemaType::Ref {
2803            schema_ref: "#/definitions/X".into(),
2804        };
2805        assert!(r.validate(&serde_json::json!(null)).is_ok());
2806
2807        // Any matches anything.
2808        assert!(SchemaType::Any
2809            .validate(&serde_json::json!({"x":1}))
2810            .is_ok());
2811    }
2812
2813    // ---------- ApiQuery negative-branch coverage ----------
2814
2815    #[test]
2816    fn query_matches_returns_false_on_each_filter_miss() {
2817        let schema = ApiSchema::new("svc", ApiVersion::new(1, 0, 0))
2818            .with_tag("gpu")
2819            .add_endpoint(ApiEndpoint::new("/run", ApiMethod::Post));
2820        let ann = ApiAnnouncement::new(make_node_id(1), vec![schema]);
2821
2822        // Wrong api name.
2823        let q = ApiQuery::new().with_api("other");
2824        assert_eq!(registry_match_count(&ann, &q), 0);
2825
2826        // Wrong tag.
2827        let q = ApiQuery::new().with_tag("cpu");
2828        assert_eq!(registry_match_count(&ann, &q), 0);
2829
2830        // Wrong endpoint path.
2831        let q = ApiQuery::new().with_endpoint("/missing");
2832        assert_eq!(registry_match_count(&ann, &q), 0);
2833
2834        // Wrong method on existing path.
2835        let q = ApiQuery::new()
2836            .with_endpoint("/run")
2837            .with_method(ApiMethod::Get);
2838        assert_eq!(registry_match_count(&ann, &q), 0);
2839    }
2840
2841    /// Helper: register an announcement and count matches against a query.
2842    /// Keeps the test focused on the matcher, not registry plumbing.
2843    fn registry_match_count(ann: &ApiAnnouncement, q: &ApiQuery) -> usize {
2844        let r = ApiRegistry::new();
2845        r.register(ann.clone()).unwrap();
2846        r.query(q).len()
2847    }
2848
2849    // ---------- Expired-entry filtering ----------
2850
2851    #[test]
2852    fn find_by_endpoint_skips_expired_entries() {
2853        let registry = ApiRegistry::new();
2854        let schema = ApiSchema::new("svc", ApiVersion::new(1, 0, 0))
2855            .add_endpoint(ApiEndpoint::new("/run", ApiMethod::Post));
2856
2857        // Stamp `timestamp` at the Unix epoch with a short ttl so
2858        // `is_expired()` returns true regardless of wall-clock
2859        // resolution. A previous version slept 5ms past a
2860        // `with_ttl(0)` announcement — flaky on loaded CI boxes
2861        // where the wall clock can read backward between
2862        // `ApiAnnouncement::new`'s SystemTime call and the
2863        // `is_expired()` check.
2864        let mut ann = ApiAnnouncement::new(make_node_id(7), vec![schema]).with_ttl(1);
2865        ann.timestamp = 0;
2866        registry.register(ann).unwrap();
2867
2868        assert!(registry
2869            .find_by_endpoint("/run", ApiMethod::Post)
2870            .is_empty());
2871    }
2872}