From 88ecb5fb8a4baae8236b8735beedd1b323362f11 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Sun, 2 Aug 2026 00:30:46 +0800 Subject: [PATCH] feat: rust resource type --- .../src/differ_meta.rs | 22 +- rust/crates/adc-differ/src/differ_v4.rs | 24 +- .../{adc-sdk => adc-differ}/src/field_meta.rs | 16 +- rust/crates/adc-differ/src/lib.rs | 2 + rust/crates/adc-sdk/src/event.rs | 10 +- rust/crates/adc-sdk/src/lib.rs | 40 ++- rust/crates/adc-sdk/src/resource.rs | 2 +- rust/crates/adc-sdk/src/resources/common.rs | 36 ++ rust/crates/adc-sdk/src/resources/consumer.rs | 59 ++++ rust/crates/adc-sdk/src/resources/mod.rs | 90 +++++ rust/crates/adc-sdk/src/resources/route.rs | 87 +++++ rust/crates/adc-sdk/src/resources/service.rs | 133 ++++++++ rust/crates/adc-sdk/src/resources/ssl.rs | 67 ++++ rust/crates/adc-sdk/src/resources/upstream.rs | 308 ++++++++++++++++++ rust/crates/adc-sdk/src/utils.rs | 2 +- rust/crates/adc-sdk/src/value_diff.rs | 29 +- .../adc-sdk/tests/resources_from_fixtures.rs | 96 ++++++ 17 files changed, 947 insertions(+), 76 deletions(-) rename rust/crates/{adc-sdk => adc-differ}/src/differ_meta.rs (92%) rename rust/crates/{adc-sdk => adc-differ}/src/field_meta.rs (52%) create mode 100644 rust/crates/adc-sdk/src/resources/common.rs create mode 100644 rust/crates/adc-sdk/src/resources/consumer.rs create mode 100644 rust/crates/adc-sdk/src/resources/mod.rs create mode 100644 rust/crates/adc-sdk/src/resources/route.rs create mode 100644 rust/crates/adc-sdk/src/resources/service.rs create mode 100644 rust/crates/adc-sdk/src/resources/ssl.rs create mode 100644 rust/crates/adc-sdk/src/resources/upstream.rs create mode 100644 rust/crates/adc-sdk/tests/resources_from_fixtures.rs diff --git a/rust/crates/adc-sdk/src/differ_meta.rs b/rust/crates/adc-differ/src/differ_meta.rs similarity index 92% rename from rust/crates/adc-sdk/src/differ_meta.rs rename to rust/crates/adc-differ/src/differ_meta.rs index 2ea2acb9..d8343ce3 100644 --- a/rust/crates/adc-sdk/src/differ_meta.rs +++ b/rust/crates/adc-differ/src/differ_meta.rs @@ -1,18 +1,17 @@ -//! Mirrors `libs/sdk/src/core/differ.ts`'s `RESOURCE_DIFFER_META` table. +//! Per-resource-type metadata the differ needs: which `InternalConfiguration` +//! field holds the resource's collection, how to derive an id/name for a +//! local (not-yet-synced) item, and each field's merge/diff strategy. //! -//! In TS, the per-field entries (`fields`) are derived at runtime from Zod -//! schema `.meta()` annotations via `readFieldMeta()`. This Rust port skips -//! reflecting over a schema layer (see the crate-level "staged build" note) -//! and instead hand-transcribes the same annotations directly from -//! `libs/sdk/src/core/schema.ts`, which remains the single source of truth — -//! if that file's `withDifferMeta(...)` calls change, this table must be -//! updated to match. +//! The per-field entries (`fields`) are hand-transcribed here rather than +//! derived by reflecting over `adc_sdk::resources`' struct definitions at +//! runtime — if a resource's shape changes there, this table must be updated +//! to match by hand. +use adc_sdk::ResourceType; +use adc_sdk::utils::generate_id; use serde_json::Value; use crate::field_meta::FieldMeta; -use crate::resource::ResourceType; -use crate::utils::generate_id; fn str_field<'a>(item: &'a Value, key: &str) -> &'a str { item.get(key).and_then(Value::as_str).unwrap_or_default() @@ -43,7 +42,8 @@ pub struct ResourceDifferMeta { /// Resolve which `ResourceType` to use for default-value lookup. /// Only needed for `Service`, which may be a stream service. pub resolve_default_type: Option ResourceType>, - /// Per-field merge strategies, hand-transcribed from `schema.ts` (see module docs). + /// Per-field merge strategies, hand-transcribed independently of + /// `adc_sdk::resources`' struct definitions (see module docs). pub fields: &'static [(&'static str, FieldMeta)], } diff --git a/rust/crates/adc-differ/src/differ_v4.rs b/rust/crates/adc-differ/src/differ_v4.rs index 28e8ff8d..ea350b7e 100644 --- a/rust/crates/adc-differ/src/differ_v4.rs +++ b/rust/crates/adc-differ/src/differ_v4.rs @@ -1,16 +1,16 @@ -//! Port of `libs/differ/src/differv4.ts`'s `DifferV4`. Only v4 is ported; -//! the TS side also has an older v3 algorithm, gated behind an env var and -//! kept only as a fallback, which this crate does not implement. +//! The resource-diffing algorithm: compares local (desired) and remote +//! (actual) configuration and produces an ordered list of create/update/ +//! delete events describing how to reconcile them. use std::collections::{HashMap, HashSet}; -use adc_sdk::{ - CollectionKind, DefaultValue, Event, EventType, FieldMeta, InternalConfiguration, - ResourceDifferMeta, ResourceType, diff_value, differ_meta, utils::generate_id, -}; +use adc_sdk::{DefaultValue, Event, EventType, InternalConfiguration, ResourceType, diff_value, utils::generate_id}; use serde_json::{Map, Value, json}; -/// (name, id, item) — mirrors differv4.ts's `ResourceTuple`. +use crate::differ_meta::{CollectionKind, ResourceDifferMeta, differ_meta}; +use crate::field_meta::FieldMeta; + +/// (name, id, item) extracted from one resource's raw JSON representation. type ResourceTuple = (String, String, Value); pub struct DifferV4 { @@ -37,8 +37,8 @@ impl DifferV4 { result.extend(differ.diff_resource(resource_type, &meta, local_tuples, remote_tuples)); } - // Unwrap one level of subEvents (mirrors differv4.ts's post-loop flatten) and - // drop ONLY_SUB_EVENTS placeholder events, which exist only to carry subEvents up. + // Unwrap one level of subEvents and drop ONLY_SUB_EVENTS placeholder + // events, which exist only to carry subEvents up to this point. let mut unwrapped: Vec = Vec::new(); for mut event in result { let subs = std::mem::take(&mut event.sub_events); @@ -443,8 +443,8 @@ fn set_key(v: &mut Value, key: &str, value: Value) { } /// Event ordering table: deletions precede creates, SSL creates precede routes -/// (SSL may be referenced by upstream mTLS and must exist first). -/// Mirrors differv4.ts's `order` table. Missing combos sort last (JS `?? Infinity`). +/// (SSL may be referenced by upstream mTLS and must exist first). Combos not +/// listed here sort last. fn order_priority(resource_type: ResourceType, event_type: EventType) -> u32 { use EventType::*; use ResourceType::*; diff --git a/rust/crates/adc-sdk/src/field_meta.rs b/rust/crates/adc-differ/src/field_meta.rs similarity index 52% rename from rust/crates/adc-sdk/src/field_meta.rs rename to rust/crates/adc-differ/src/field_meta.rs index 0bc9f0a0..f711c1f9 100644 --- a/rust/crates/adc-sdk/src/field_meta.rs +++ b/rust/crates/adc-differ/src/field_meta.rs @@ -1,11 +1,9 @@ -/// Mirrors the `FieldMeta` union in `libs/sdk/src/core/field-registry.ts`. +/// Per-field merge/diff strategy: how the differ should compare and combine +/// a given resource field when computing an update event. /// -/// In TS this is attached to a Zod schema field via `.meta()`/`withDifferMeta()` -/// and later read back with `readFieldMeta()`. Since this Rust port skips the -/// full Zod-equivalent schema layer (see adc-sdk crate docs), the per-resource -/// field tables are hand-written directly in `differ_meta.rs` instead of being -/// derived from a schema at runtime — the source of truth (schema.ts) is still -/// the same, just read manually rather than reflected. +/// These strategies are hand-written into each resource's metadata table in +/// `differ_meta.rs`, independently of the field definitions in `adc_sdk::resources` +/// — the two must be kept in sync by hand when a resource's shape changes. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FieldMeta { Map { @@ -17,8 +15,10 @@ pub enum FieldMeta { config_key: Option<&'static str>, }, ObjectMap, + // No resource currently declares an Atomic field — kept for completeness + // of the merge-strategy vocabulary, not because anything constructs it. + #[allow(dead_code)] Atomic { - #[allow(dead_code)] strip: bool, }, Array { diff --git a/rust/crates/adc-differ/src/lib.rs b/rust/crates/adc-differ/src/lib.rs index 933cec18..1ef72501 100644 --- a/rust/crates/adc-differ/src/lib.rs +++ b/rust/crates/adc-differ/src/lib.rs @@ -1,3 +1,5 @@ +mod differ_meta; pub mod differ_v4; +mod field_meta; pub use differ_v4::DifferV4; diff --git a/rust/crates/adc-sdk/src/event.rs b/rust/crates/adc-sdk/src/event.rs index 42b66293..5bf6a5d7 100644 --- a/rust/crates/adc-sdk/src/event.rs +++ b/rust/crates/adc-sdk/src/event.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::resource::ResourceType; use crate::value_diff::ValueDiff; -/// Mirrors `EventType` in `libs/sdk/src/core/differ.ts`. +/// The kind of change a differ event represents. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum EventType { @@ -15,12 +15,12 @@ pub enum EventType { OnlySubEvents, } -/// Mirrors `Event` in `libs/sdk/src/core/differ.ts`. +/// A single detected change between local and remote configuration for one resource. /// /// `sub_events` is populated while the differ is building nested events and is -/// always cleared (mirroring TS's `unset(event, 'subEvents')`) before the final -/// flattened list is returned from `DifferV4::diff`, so it is not part of the -/// crate's public "result" contract even though it stays on the struct. +/// always cleared before the final flattened list is returned from +/// `DifferV4::diff`, so it is not part of the crate's public "result" contract +/// even though it stays on the struct. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Event { pub resource_type: ResourceType, diff --git a/rust/crates/adc-sdk/src/lib.rs b/rust/crates/adc-sdk/src/lib.rs index 064f148f..3eba5fc2 100644 --- a/rust/crates/adc-sdk/src/lib.rs +++ b/rust/crates/adc-sdk/src/lib.rs @@ -1,40 +1,38 @@ -//! Minimal Rust port of `@api7/adc-sdk`, scoped to exactly what `adc-differ` -//! needs: resource/event type definitions, the differ field-metadata table, -//! and a generic JSON value-diff utility (replacing the TS side's -//! `datum-diff` dependency). +//! ADC's core data model: resource type definitions, the typed resource +//! layer for parsing declarative configuration (`resources` module), differ +//! event types shared with backend/CLI consumers, and a generic JSON +//! value-diff utility. //! -//! What's deliberately not here: input validation (a `validator`-crate -//! equivalent of `libs/sdk/src/core/schema.ts`'s Zod refinements), a -//! `Backend` trait, and `schemars` JSON Schema export. Resource bodies are -//! plain `serde_json::Value` rather than per-resource typed structs — see -//! the note on `InternalConfiguration` below for why. +//! Not yet here: semantic validation (cross-field rules, regex, min/max) on +//! top of the `resources` types, a `Backend` trait, and JSON Schema export. +//! The differ's own field-metadata table lives in `adc-differ` instead of +//! here, since nothing outside the differ consumes it. -pub mod differ_meta; pub mod event; -pub mod field_meta; pub mod resource; +pub mod resources; pub mod utils; pub mod value_diff; -pub use differ_meta::{CollectionKind, ResourceDifferMeta, differ_meta}; pub use event::{Event, EventType}; -pub use field_meta::FieldMeta; pub use resource::{FieldListType, ResourceType}; pub use value_diff::{DiffPath, PathSegment, ValueDiff, diff_value}; use serde_json::{Map, Value}; -/// Mirrors `InternalConfiguration` in `libs/sdk/src/core/schema.ts`. +/// The differ's working representation of a full configuration: a plain +/// `Map` keyed by config field name (`services`, `routes`, +/// `global_rules`, ...). The differ algorithm treats resource bodies as +/// opaque structural values rather than strongly-typed ones, since diffing +/// is a generic structural operation independent of any one resource's shape. /// -/// Unlike the TS side, this is a plain `Map` keyed by config -/// field name (`services`, `routes`, `global_rules`, ...) rather than a -/// strongly-typed struct — see the crate-level docs for why: the differ -/// algorithm itself treats resource bodies as opaque structural values, so a -/// generic `Value`-based representation is both the lowest-risk and most -/// faithful translation of `differv4.ts`'s actual (dynamically-typed) behavior. +/// Distinct from `resources::InternalConfiguration`, the typed counterpart +/// used to parse and validate declarative configuration. pub type InternalConfiguration = Map; -/// Mirrors `DefaultValue` in `libs/sdk/src/core/differ.ts`. +/// Per-resource-type and per-plugin default values, merged into local +/// configuration before diffing so that a value matching the backend's +/// default doesn't show up as a spurious change. #[derive(Debug, Clone, Default)] pub struct DefaultValue { pub core: std::collections::HashMap, diff --git a/rust/crates/adc-sdk/src/resource.rs b/rust/crates/adc-sdk/src/resource.rs index 6ccb479b..5e1515a5 100644 --- a/rust/crates/adc-sdk/src/resource.rs +++ b/rust/crates/adc-sdk/src/resource.rs @@ -1,4 +1,4 @@ -/// Mirrors `libs/sdk/src/core/resource.ts`. +/// The kinds of resources ADC manages. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum ResourceType { Route, diff --git a/rust/crates/adc-sdk/src/resources/common.rs b/rust/crates/adc-sdk/src/resources/common.rs new file mode 100644 index 00000000..a8b760f2 --- /dev/null +++ b/rust/crates/adc-sdk/src/resources/common.rs @@ -0,0 +1,36 @@ +//! Types shared across multiple resource definitions. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// A resource's labels: each value is a single string or a list of strings. +pub type Labels = HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum LabelValue { + Single(String), + Multiple(Vec), +} + +/// An arbitrary, unvalidated plugin configuration object — its shape depends +/// entirely on which plugin it configures, so it's kept structurally open +/// (no `deny_unknown_fields`) rather than typed field-by-field. +pub type Plugin = serde_json::Map; + +/// A plugin name to configuration map. +pub type Plugins = serde_json::Map; + +/// An APISIX condition expression: an arbitrary nested array structure +/// evaluated by the gateway at request time. +pub type Expr = Vec; + +/// Connect/send/read timeouts in seconds, shared by upstream and route configs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Timeout { + pub connect: f64, + pub send: f64, + pub read: f64, +} diff --git a/rust/crates/adc-sdk/src/resources/consumer.rs b/rust/crates/adc-sdk/src/resources/consumer.rs new file mode 100644 index 00000000..2ad4f985 --- /dev/null +++ b/rust/crates/adc-sdk/src/resources/consumer.rs @@ -0,0 +1,59 @@ +//! The `Consumer`, `ConsumerCredential` and `ConsumerGroup` resources. + +use serde::{Deserialize, Serialize}; + +use super::common::{Labels, Plugin, Plugins}; + +/// A credential attached to a consumer (e.g. an API key or JWT secret). +/// `type` is kept as a plain string rather than a closed enum: the 4-value +/// restriction ("key-auth"/"basic-auth"/"jwt-auth"/"hmac-auth") is a semantic +/// rule for the validation layer, not encoded here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsumerCredential { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(rename = "type")] + pub r#type: String, + pub config: Plugin, +} + +/// A consumer, identified by `username` rather than `name`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Consumer { + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub credentials: Option>, +} + +/// A named group of consumers sharing plugin configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsumerGroup { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub consumers: Option>, +} diff --git a/rust/crates/adc-sdk/src/resources/mod.rs b/rust/crates/adc-sdk/src/resources/mod.rs new file mode 100644 index 00000000..d03347b1 --- /dev/null +++ b/rust/crates/adc-sdk/src/resources/mod.rs @@ -0,0 +1,90 @@ +//! The typed ADC resource model. This *is* the resource definition — the +//! shape the CLI parses local YAML/JSON declarative configuration into. +//! +//! Fields with a default value deserialize with that default already filled +//! in (see the doc comment on `upstream.rs` for the fields this applies to) +//! — parsing is active, not a passive shape description: a field that's +//! missing from input but has a default is still populated on the result. +//! +//! Not yet covered: semantic validation (cross-field rules, regex, min/max), +//! which is a separate, later pass on top of this same model. +//! +//! Naming note: distinct from `crate::resource` (the differ's `ResourceType`/ +//! `FieldListType` metadata enums) and from `crate::InternalConfiguration` +//! (the `Map` alias `adc-differ` operates on) — different +//! modules, different concerns, no relation beyond sharing this crate. + +pub mod common; +pub mod consumer; +pub mod route; +pub mod service; +pub mod ssl; +pub mod upstream; + +pub use common::{Expr, Labels, LabelValue, Plugin, Plugins, Timeout}; +pub use consumer::{Consumer, ConsumerCredential, ConsumerGroup}; +pub use route::{HttpMethod, Route, StreamRoute}; +pub use service::{Service, ServiceRoutes}; +pub use ssl::{SSL, SSLCertificate, SslClient, SslProtocol, SslType}; +pub use upstream::{ + Upstream, UpstreamBalancer, UpstreamHealthCheck, UpstreamHealthCheckActive, + UpstreamHealthCheckActiveHealthy, UpstreamHealthCheckActiveUnhealthy, UpstreamHealthCheckPassive, + UpstreamHealthCheckPassiveHealthy, UpstreamHealthCheckPassiveUnhealthy, UpstreamHealthCheckType, + UpstreamKeepalivePool, UpstreamNode, UpstreamPassHost, UpstreamScheme, UpstreamTls, +}; + +use serde::{Deserialize, Serialize}; + +/// A global rule is just a plugin config map applied gateway-wide. +pub type GlobalRule = Plugins; +/// Metadata (shared config) for a plugin, keyed by plugin name. +pub type PluginMetadata = Plugins; + +/// The external, user-facing declarative config file shape: nested +/// sub-resources embedded under their parent, no top-level +/// routes/upstreams/consumer_credentials. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Configuration { + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub consumers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub consumer_groups: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub global_rules: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_metadata: Option, +} + +/// The flattened internal representation: adds top-level +/// routes/stream_routes/consumer_credentials/upstreams alongside the nested +/// sub-resources, so every resource is also reachable directly by its own +/// collection field. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InternalConfiguration { + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub consumers: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub global_rules: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_metadata: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub routes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_routes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub consumer_credentials: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub upstreams: Option>, +} diff --git a/rust/crates/adc-sdk/src/resources/route.rs b/rust/crates/adc-sdk/src/resources/route.rs new file mode 100644 index 00000000..85cf6bbe --- /dev/null +++ b/rust/crates/adc-sdk/src/resources/route.rs @@ -0,0 +1,87 @@ +//! The `Route` (HTTP) and `StreamRoute` (TCP/UDP) resources. + +use serde::{Deserialize, Serialize}; + +use super::common::{Expr, Labels, Plugins, Timeout}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HttpMethod { + #[serde(rename = "GET")] + Get, + #[serde(rename = "POST")] + Post, + #[serde(rename = "PUT")] + Put, + #[serde(rename = "DELETE")] + Delete, + #[serde(rename = "PATCH")] + Patch, + #[serde(rename = "HEAD")] + Head, + #[serde(rename = "OPTIONS")] + Options, + #[serde(rename = "CONNECT")] + Connect, + #[serde(rename = "TRACE")] + Trace, + #[serde(rename = "PURGE")] + Purge, +} + +/// An HTTP route: matches requests by URI/host/method and applies plugins to them. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Route { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub hosts: Option>, + pub uris: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vars: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub methods: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_websocket: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_addrs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filter_func: Option, +} + +/// A stream (TCP/UDP/TLS) route: matches connections by address/SNI rather than URI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StreamRoute { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_addr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_addr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sni: Option, +} diff --git a/rust/crates/adc-sdk/src/resources/service.rs b/rust/crates/adc-sdk/src/resources/service.rs new file mode 100644 index 00000000..72b3586c --- /dev/null +++ b/rust/crates/adc-sdk/src/resources/service.rs @@ -0,0 +1,133 @@ +//! The `Service` resource. Two of its cross-field rules (path_prefix must +//! start with "/", upstream required when upstreams is set) are semantic +//! validation, not structural — left for the validation layer. A third +//! ("HTTP routes and Stream routes are mutually exclusive") is enforced here +//! instead, via `ServiceRoutes` — see its doc comment for why and how. + +use serde::{Deserialize, Serialize}; + +use super::common::{Labels, Plugins}; +use super::route::{Route, StreamRoute}; +use super::upstream::Upstream; + +/// `Service.routes`/`Service.stream_routes` are two sibling JSON keys, at +/// most one of which may be present — a service either proxies HTTP or a +/// stream (TCP/UDP), never both. This enum makes the other combination +/// structurally unrepresentable rather than checking for it separately. +/// +/// This can't come from a plain `#[derive(Deserialize)]` + `#[serde(untagged, +/// flatten)]`: that combination doesn't actually reject "both keys present" +/// (flatten buffers unmatched keys and untagged just accepts the first +/// variant that parses, silently ignoring the other key). Enforcing the +/// exclusion instead happens via `Service`'s `#[serde(try_from = "ServiceRaw")]` +/// — `ServiceRaw` derives normally with both fields as plain +/// `Option>`, and `TryFrom` is where the two are reconciled into this +/// enum (or rejected). +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(untagged)] +pub enum ServiceRoutes { + Http { routes: Vec }, + Stream { stream_routes: Vec }, +} + +impl ServiceRoutes { + pub fn http(&self) -> Option<&[Route]> { + match self { + ServiceRoutes::Http { routes } => Some(routes), + ServiceRoutes::Stream { .. } => None, + } + } + + pub fn stream(&self) -> Option<&[StreamRoute]> { + match self { + ServiceRoutes::Stream { stream_routes } => Some(stream_routes), + ServiceRoutes::Http { .. } => None, + } + } + + fn from_raw( + routes: Option>, + stream_routes: Option>, + ) -> Result, String> { + match (routes, stream_routes) { + (Some(routes), None) => Ok(Some(ServiceRoutes::Http { routes })), + (None, Some(stream_routes)) => Ok(Some(ServiceRoutes::Stream { stream_routes })), + (None, None) => Ok(None), + (Some(_), Some(_)) => { + Err("routes and stream_routes are mutually exclusive".to_string()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "ServiceRaw")] +pub struct Service { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub upstreams: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path_prefix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub strip_path_prefix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hosts: Option>, + + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub routes: Option, +} + +/// Plain-shape deserialization target for `Service` — see `ServiceRoutes`'s +/// doc comment for why this indirection exists. Every field here must stay +/// in sync with `Service`'s (routes/stream_routes excepted). +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ServiceRaw { + id: Option, + name: String, + description: Option, + labels: Option, + + upstream: Option, + upstreams: Option>, + plugins: Option, + path_prefix: Option, + strip_path_prefix: Option, + hosts: Option>, + + #[serde(default)] + routes: Option>, + #[serde(default)] + stream_routes: Option>, +} + +impl TryFrom for Service { + type Error = String; + + fn try_from(raw: ServiceRaw) -> Result { + Ok(Service { + id: raw.id, + name: raw.name, + description: raw.description, + labels: raw.labels, + upstream: raw.upstream, + upstreams: raw.upstreams, + plugins: raw.plugins, + path_prefix: raw.path_prefix, + strip_path_prefix: raw.strip_path_prefix, + hosts: raw.hosts, + routes: ServiceRoutes::from_raw(raw.routes, raw.stream_routes)?, + }) + } +} diff --git a/rust/crates/adc-sdk/src/resources/ssl.rs b/rust/crates/adc-sdk/src/resources/ssl.rs new file mode 100644 index 00000000..6ed4f532 --- /dev/null +++ b/rust/crates/adc-sdk/src/resources/ssl.rs @@ -0,0 +1,67 @@ +//! The `SSL` (certificate) resource. + +use serde::{Deserialize, Serialize}; + +use super::common::Labels; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum SslType { + #[default] + #[serde(rename = "server")] + Server, + #[serde(rename = "client")] + Client, +} + +fn default_client_depth() -> u32 { + 1 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SslProtocol { + #[serde(rename = "TLSv1.1")] + Tlsv1_1, + #[serde(rename = "TLSv1.2")] + Tlsv1_2, + #[serde(rename = "TLSv1.3")] + Tlsv1_3, +} + +/// A certificate/key pair. Either may also be a `$secret://`/`$env://` +/// reference string instead of inline PEM content — that's a semantic +/// (regex-union) check, not a structural one, so both fields are plain +/// `String` here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SSLCertificate { + pub certificate: String, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SslClient { + pub ca: String, + #[serde(default = "default_client_depth")] + pub depth: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_mtls_uri_regex: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SSL { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(rename = "type", default)] + pub r#type: SslType, + pub snis: Vec, + pub certificates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub client: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl_protocols: Option>, +} diff --git a/rust/crates/adc-sdk/src/resources/upstream.rs b/rust/crates/adc-sdk/src/resources/upstream.rs new file mode 100644 index 00000000..bf6005cb --- /dev/null +++ b/rust/crates/adc-sdk/src/resources/upstream.rs @@ -0,0 +1,308 @@ +//! The `Upstream` resource and its nested health-check/tls/keepalive-pool shapes. +//! +//! Fields with a declared default are deserialized as their bare type (not +//! `Option`), with the default filled in via `#[serde(default = ...)]` when +//! the key is missing — parsing actively applies defaults rather than just +//! describing an optional shape. Fields that are merely optional with no +//! default stay `Option` and are genuinely absent when not provided. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::common::{Labels, Timeout}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum UpstreamBalancer { + #[default] + #[serde(rename = "roundrobin")] + RoundRobin, + #[serde(rename = "chash")] + Chash, + #[serde(rename = "least_conn")] + LeastConn, + #[serde(rename = "ewma")] + Ewma, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum UpstreamScheme { + #[serde(rename = "grpc")] + Grpc, + #[serde(rename = "grpcs")] + Grpcs, + #[default] + #[serde(rename = "http")] + Http, + #[serde(rename = "https")] + Https, + #[serde(rename = "tcp")] + Tcp, + #[serde(rename = "tls")] + Tls, + #[serde(rename = "udp")] + Udp, + #[serde(rename = "kafka")] + Kafka, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum UpstreamPassHost { + #[default] + #[serde(rename = "pass")] + Pass, + #[serde(rename = "node")] + Node, + #[serde(rename = "rewrite")] + Rewrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum UpstreamHealthCheckType { + #[default] + #[serde(rename = "http")] + Http, + #[serde(rename = "https")] + Https, + #[serde(rename = "tcp")] + Tcp, +} + +fn default_healthy_http_statuses() -> Vec { + vec![200, 302] +} +fn default_successes() -> u32 { + 2 +} +fn default_unhealthy_http_statuses() -> Vec { + vec![429, 404, 500, 501, 502, 503, 504, 505] +} +fn default_http_failures() -> u32 { + 5 +} +fn default_tcp_failures() -> u32 { + 2 +} +fn default_timeouts() -> u32 { + 3 +} +fn default_interval() -> u32 { + 1 +} +fn default_active_timeout() -> f64 { + 1.0 +} +fn default_concurrency() -> f64 { + 10.0 +} +fn default_http_path() -> String { + "/".to_string() +} +fn default_keepalive_pool_size() -> u32 { + 320 +} +fn default_keepalive_idle_timeout() -> f64 { + 60.0 +} +fn default_keepalive_requests() -> u32 { + 1000 +} + +/// A single upstream target: host, port, and load-balancing weight. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamNode { + pub host: String, + pub port: u32, + pub weight: i64, + #[serde(default)] + pub priority: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// Passive-health-check thresholds for marking a target healthy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHealthCheckPassiveHealthy { + #[serde(default = "default_healthy_http_statuses")] + pub http_statuses: Vec, + #[serde(default = "default_successes")] + pub successes: u32, +} + +/// Passive-health-check thresholds for marking a target unhealthy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHealthCheckPassiveUnhealthy { + #[serde(default = "default_unhealthy_http_statuses")] + pub http_statuses: Vec, + #[serde(default = "default_http_failures")] + pub http_failures: u32, + #[serde(default = "default_tcp_failures")] + pub tcp_failures: u32, + #[serde(default = "default_timeouts")] + pub timeouts: u32, +} + +/// Active-health-check thresholds for marking a target healthy, plus the +/// polling interval (seconds, default 1) active checks need that passive +/// checks don't. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHealthCheckActiveHealthy { + #[serde(default = "default_healthy_http_statuses")] + pub http_statuses: Vec, + #[serde(default = "default_successes")] + pub successes: u32, + #[serde(default = "default_interval")] + pub interval: u32, +} + +/// Active-health-check thresholds for marking a target unhealthy, plus the +/// polling interval (seconds, default 1) active checks need that passive +/// checks don't. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHealthCheckActiveUnhealthy { + #[serde(default = "default_unhealthy_http_statuses")] + pub http_statuses: Vec, + #[serde(default = "default_http_failures")] + pub http_failures: u32, + #[serde(default = "default_tcp_failures")] + pub tcp_failures: u32, + #[serde(default = "default_timeouts")] + pub timeouts: u32, + #[serde(default = "default_interval")] + pub interval: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHealthCheckActive { + #[serde(rename = "type", default)] + pub r#type: UpstreamHealthCheckType, + #[serde(default = "default_active_timeout")] + pub timeout: f64, + #[serde(default = "default_concurrency")] + pub concurrency: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(default = "default_http_path")] + pub http_path: String, + #[serde(default = "default_true")] + pub https_verify_certificate: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub http_request_headers: Option>, + // The wrapping object itself is only `.optional()` (no `.default()`), so + // absence stays `None` — only fields *inside* it (once present) default. + #[serde(skip_serializing_if = "Option::is_none")] + pub healthy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unhealthy: Option, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHealthCheckPassive { + #[serde(rename = "type", default)] + pub r#type: UpstreamHealthCheckType, + #[serde(skip_serializing_if = "Option::is_none")] + pub healthy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unhealthy: Option, +} + +/// Health-check configuration: `active` (the gateway polls targets) is +/// required whenever `checks` is set at all; `passive` (inferred from live +/// traffic) is optional on top of it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHealthCheck { + pub active: UpstreamHealthCheckActive, + #[serde(skip_serializing_if = "Option::is_none")] + pub passive: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamKeepalivePool { + #[serde(default = "default_keepalive_pool_size")] + pub size: u32, + #[serde(default = "default_keepalive_idle_timeout")] + pub idle_timeout: f64, + #[serde(default = "default_keepalive_requests")] + pub requests: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamTls { + #[serde(skip_serializing_if = "Option::is_none")] + pub client_cert: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_cert_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub verify: Option, +} + +/// An upstream target group. `id` is always present on the struct as an +/// `Option`, but whether it's actually required depends on where the upstream +/// appears: a service's own default `upstream` doesn't need one, while a +/// named entry in `upstreams[]` does. That's a semantic rule for the +/// validation layer, not a structural one. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Upstream { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(rename = "type", default)] + pub r#type: UpstreamBalancer, + #[serde(skip_serializing_if = "Option::is_none")] + pub hash_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub checks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nodes: Option>, + #[serde(default)] + pub scheme: UpstreamScheme, + #[serde(skip_serializing_if = "Option::is_none")] + pub retries: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, + // The wrapping object is only `.optional()` (no `.default()` on it as a + // whole), so absence stays `None`; once present, its fields default. + #[serde(skip_serializing_if = "Option::is_none")] + pub keepalive_pool: Option, + #[serde(default)] + pub pass_host: UpstreamPassHost, + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_host: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub discovery_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub discovery_args: Option>, +} diff --git a/rust/crates/adc-sdk/src/utils.rs b/rust/crates/adc-sdk/src/utils.rs index 615605ad..a79277a5 100644 --- a/rust/crates/adc-sdk/src/utils.rs +++ b/rust/crates/adc-sdk/src/utils.rs @@ -1,6 +1,6 @@ use sha1::{Digest, Sha1}; -/// Mirrors `utils.generateId` in `libs/sdk/src/utils.ts` (`sha1` hex digest, not sha256). +/// Deterministic resource id: the sha1 (not sha256) hex digest of `name`. pub fn generate_id(name: &str) -> String { let mut hasher = Sha1::new(); hasher.update(name.as_bytes()); diff --git a/rust/crates/adc-sdk/src/value_diff.rs b/rust/crates/adc-sdk/src/value_diff.rs index d7aab124..70c1c7f4 100644 --- a/rust/crates/adc-sdk/src/value_diff.rs +++ b/rust/crates/adc-sdk/src/value_diff.rs @@ -1,20 +1,15 @@ -//! Self-researched replacement for the TS side's third-party `datum-diff` (a -//! `deep-diff` fork) dependency — see `libs/differ/src/differv4.ts`'s use of -//! `objectDiff` from `datum-diff`. +//! A structural diff between two JSON values: walks both sides depth-first +//! and emits one entry per changed leaf/key/array-slot. Not content-addressed +//! or order-independent — object keys are compared in `lhs`-then-`rhs` +//! insertion order, and arrays are compared positionally, with any length +//! difference collapsed onto the tail. //! -//! `datum-diff`'s `diff(lhs, rhs)` is a plain recursive structural diff over -//! JS values: it walks both sides depth-first and emits one entry per changed -//! leaf/key/array-slot. It is *not* content-addressed or order-independent — -//! object keys are compared in `lhs`-then-`rhs` insertion order, and arrays -//! are compared positionally (with any length difference collapsed onto the -//! tail). ADC configs are always plain trees from deserialized JSON/YAML -//! (no shared/cyclic references), so the reference-cycle bookkeeping -//! `datum-diff` does (its `stack` parameter) is intentionally not ported — -//! it can never trigger here. +//! ADC configs are always plain trees deserialized from JSON/YAML, with no +//! shared or cyclic references, so there's no reference-cycle bookkeeping here. //! //! `serde_json`'s `preserve_order` feature (enabled workspace-wide) is //! required for `Value::Object`'s key order to match the source document, -//! which is what makes `path` order below match the TS/JS output byte-for-byte. +//! which is what keeps `path` order in diff output stable and predictable. use serde::Serialize; use serde_json::Value; @@ -29,7 +24,8 @@ pub enum PathSegment { pub type DiffPath = Vec; -/// Mirrors `datum-diff`'s `Diff` union (kinds `N`/`D`/`E`/`A`). +/// A single field-level change, tagged by kind: new, deleted, edited, or an +/// array-tail change. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum ValueDiff { @@ -62,9 +58,8 @@ fn real_type_of(v: &Value) -> &'static str { } } -/// Structural diff of two JSON values, mirroring `datum-diff`'s `diff(lhs, rhs)`. -/// Returns `None` when there is no difference (matching `datum-diff`'s -/// `changes.length ? changes : undefined` behavior, which callers rely on). +/// Structural diff of two JSON values. Returns `None` when there is no +/// difference — callers rely on this rather than checking for an empty `Vec`. pub fn diff_value(lhs: &Value, rhs: &Value) -> Option> { let mut changes = Vec::new(); deep_diff(Some(lhs), Some(rhs), &[], &mut changes); diff --git a/rust/crates/adc-sdk/tests/resources_from_fixtures.rs b/rust/crates/adc-sdk/tests/resources_from_fixtures.rs new file mode 100644 index 00000000..88ea82cd --- /dev/null +++ b/rust/crates/adc-sdk/tests/resources_from_fixtures.rs @@ -0,0 +1,96 @@ +//! Deserializes real resource bodies taken from `fixtures/differ/*.json` +//! (the TS/Rust differ parity fixtures) into the typed `resources::*` structs, +//! to confirm field names/optionality/nesting match what real ADC configs +//! actually look like. Not a test of semantic validation (`.refine()` rules +//! aren't implemented yet) — only that well-formed resource bodies parse. + +use std::path::PathBuf; + +use adc_sdk::resources::{Consumer, InternalConfiguration, Route, Service, ServiceRoutes, UpstreamHealthCheck, SSL}; +use serde_json::Value; + +fn fixture(name: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../fixtures/differ").join(name); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_slice(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +#[test] +fn deserializes_service_with_nested_routes_and_upstream() { + let f = fixture("upstream.creates_and_updates_ssl_before_upstream.json"); + let services = f["local"]["services"].clone(); + let services: Vec = serde_json::from_value(services).expect("deserialize services"); + assert_eq!(services.len(), 1); + + let service = &services[0]; + assert_eq!(service.name, "test"); + assert_eq!(service.routes.as_ref().and_then(ServiceRoutes::http).map(<[_]>::len), Some(0)); + assert!(service.upstream.is_some()); + let upstreams = service.upstreams.as_ref().expect("upstreams"); + assert_eq!(upstreams.len(), 1); + assert!(upstreams[0].tls.is_some()); +} + +#[test] +fn deserializes_ssl_with_certificates() { + let f = fixture("upstream.creates_and_updates_ssl_before_upstream.json"); + let ssls: Vec = serde_json::from_value(f["local"]["ssls"].clone()).expect("deserialize ssls"); + assert_eq!(ssls.len(), 2); + assert_eq!(ssls[0].snis, vec!["test1.com", "test2.com"]); + assert_eq!(ssls[0].certificates.len(), 1); + assert_eq!(ssls[0].certificates[0].key, "KEY"); +} + +#[test] +fn deserializes_consumer_with_credentials() { + let f = fixture("consumer.creates_updates_deletes_consumer_credentials.json"); + let consumers: Vec = + serde_json::from_value(f["local"]["consumers"].clone()).expect("deserialize consumers"); + assert_eq!(consumers.len(), 1); + let credentials = consumers[0].credentials.as_ref().expect("credentials"); + assert_eq!(credentials.len(), 2); + assert_eq!(credentials[0].r#type, "key-auth"); +} + +#[test] +fn deserializes_route_with_plugins_and_methods() { + let f = fixture("usecase.renames_service_with_nested_routes.json"); + let routes = f["local"]["services"][0]["routes"].clone(); + let routes: Vec = serde_json::from_value(routes).expect("deserialize routes"); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].name, "Anything"); + assert_eq!(routes[0].uris, vec!["/anything"]); +} + +/// The default-value patch in this fixture happens to contain a fully +/// populated, realistic `checks` block (active+passive, healthy+unhealthy on +/// both) — good deep coverage for the health-check nesting. Deserializing it +/// as a standalone `UpstreamHealthCheck` (rather than the whole `Upstream`) +/// is deliberate: default-value patches are partial merge patches (this same +/// fixture's `nodes: [{ "priority": 0 }]` sibling field has no host/port/ +/// weight), so they can't satisfy a full `Upstream`'s required fields. +#[test] +fn deserializes_full_health_check_block() { + let f = fixture("usecase.selectively_merges_objects_in_default_values_on_a_service.json"); + let checks_patch = f["defaultValue"]["core"]["service"]["upstream"]["checks"].clone(); + let checks: UpstreamHealthCheck = serde_json::from_value(checks_patch).expect("deserialize checks"); + + assert_eq!(checks.active.concurrency, 10.0); + let active_healthy = checks.active.healthy.expect("active.healthy"); + assert_eq!(active_healthy.successes, 2); + let passive = checks.passive.expect("passive"); + let passive_healthy = passive.healthy.expect("passive.healthy"); + assert_eq!(passive_healthy.successes, 5); +} + +#[test] +fn deserializes_whole_internal_configuration() { + let f = fixture("upstream.creates_and_updates_ssl_before_upstream.json"); + let local: InternalConfiguration = serde_json::from_value(f["local"].clone()).expect("deserialize local"); + assert_eq!(local.services.map(|s| s.len()), Some(1)); + assert_eq!(local.ssls.map(|s| s.len()), Some(2)); + + let remote: InternalConfiguration = serde_json::from_value(f["remote"].clone()).expect("deserialize remote"); + assert_eq!(remote.services.map(|s| s.len()), Some(1)); + assert_eq!(remote.ssls.map(|s| s.len()), Some(1)); +}