Skip to main content

uptrakit_web_api_types/
surfaces.rs

1use serde::{Deserialize, Serialize};
2use uptrakit_wire::surfaces::{self, SurfaceDescriptor};
3use uuid::Uuid;
4
5use crate::validation::{Validate, ValidationError};
6
7/// Query parameters for listing registered surfaces.
8#[derive(Debug, Clone, Deserialize)]
9#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
10pub struct ListSurfacesQuery {
11    /// Return only surfaces registered in this slot.
12    #[serde(default)]
13    pub slot: Option<String>,
14    /// Page alias filter (`settings`, `software`, `hosts`, `surfaces`).
15    #[serde(default)]
16    pub page: Option<String>,
17}
18
19/// Surface list item returned by `/api/v1/surfaces`.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
22pub struct SurfaceResponse {
23    /// Flattened surface descriptor (wire-defined shape; free-form in the spec).
24    #[serde(flatten)]
25    #[cfg_attr(feature = "openapi", schema(value_type = serde_json::Value))]
26    pub descriptor: SurfaceDescriptor,
27    pub provider_count: usize,
28}
29
30/// Tenant-compatibility/availability state for a targeted provider.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
33#[serde(rename_all = "snake_case")]
34pub enum SurfaceProviderAvailability {
35    Available,
36    Disconnected,
37    IncompatibleTenant,
38}
39
40/// Provider information returned for a targeted surface.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
43pub struct SurfaceProviderInfo {
44    pub provider_id: String,
45    pub display_label: String,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub service_id: Option<Uuid>,
48    pub availability: SurfaceProviderAvailability,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
51    pub encryption_metadata: Option<surfaces::ProviderEncryptionMetadata>,
52}
53
54/// Surface read payload used by frontend route rendering.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
57pub struct SurfaceReadResponse {
58    /// Surface descriptor (wire-defined shape; free-form in the spec).
59    #[cfg_attr(feature = "openapi", schema(value_type = serde_json::Value))]
60    pub descriptor: SurfaceDescriptor,
61    /// Interaction descriptors (wire-defined shape; free-form in the spec).
62    #[serde(default)]
63    #[cfg_attr(feature = "openapi", schema(value_type = Vec<serde_json::Value>))]
64    pub interactions: Vec<surfaces::InteractionDescriptor>,
65    /// Data-source descriptors (wire-defined shape; free-form in the spec).
66    #[serde(default)]
67    #[cfg_attr(feature = "openapi", schema(value_type = Vec<serde_json::Value>))]
68    pub data_sources: Vec<surfaces::DataSourceDescriptor>,
69}
70
71/// Query parameters for GET-origin surface interaction invocation.
72///
73/// Documentation-only: the handler reads raw query pairs (to support
74/// undeclared provider-defined keys) rather than deserializing through this
75/// struct directly. It exists purely to drive the OpenAPI `params(...)`
76/// declaration (ADR-0025) for the method-mapped REST route family: reserved
77/// keys (`page`/`per_page`) coerce to numbers; `target_provider_id` and
78/// `timeout_seconds` are envelope keys stripped before provider dispatch and
79/// never reach provider `params`.
80#[derive(Debug, Clone, Deserialize)]
81#[cfg_attr(
82    feature = "openapi",
83    derive(utoipa::IntoParams),
84    into_params(parameter_in = Query)
85)]
86pub struct ReadSurfaceInteractionQuery {
87    /// Explicit provider to target; required for multi-provider surfaces.
88    #[serde(default)]
89    pub target_provider_id: Option<String>,
90    /// Overrides the provider's default timeout, in seconds.
91    #[serde(default)]
92    pub timeout_seconds: Option<u16>,
93    /// Reserved typed key — coerced to a JSON number.
94    #[serde(default)]
95    pub page: Option<u64>,
96    /// Reserved typed key — coerced to a JSON number.
97    #[serde(default)]
98    pub per_page: Option<u64>,
99}
100
101/// Request body for invoking a surface interaction.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
104pub struct InvokeSurfaceInteractionRequest {
105    /// Interaction parameters (free-form JSON object).
106    #[serde(default)]
107    #[cfg_attr(feature = "openapi", schema(value_type = serde_json::Value))]
108    pub params: serde_json::Map<String, serde_json::Value>,
109    /// Sealed-box-encrypted sensitive parameters (wire-defined shape).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
112    pub encrypted_sensitive_params: Option<surfaces::EncryptedSensitiveParams>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub target_provider_id: Option<String>,
115    /// Optional idempotency key. If omitted, the server generates one.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub idempotency_key: Option<String>,
118    /// Optional timeout override for this invocation.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub timeout_seconds: Option<u16>,
121}
122
123impl Validate for InvokeSurfaceInteractionRequest {
124    fn validate(&self) -> Result<(), ValidationError> {
125        // No format/length invariants beyond field types; capability/existence checks are handler-side.
126        Ok(())
127    }
128}
129
130impl crate::validation::sealed::Sealed for InvokeSurfaceInteractionRequest {}
131
132impl crate::validation::RoutingEnvelope for InvokeSurfaceInteractionRequest {
133    fn routing_envelope(&self) -> crate::validation::InvokeRoutingEnvelope {
134        crate::validation::InvokeRoutingEnvelope {
135            target_provider_id: self.target_provider_id.clone(),
136            timeout_seconds: self.timeout_seconds,
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use uptrakit_wire::limits::MAX_SURFACE_PARAMS_LEN;
145
146    #[test]
147    fn invoke_surface_interaction_request_validate_is_ok() {
148        InvokeSurfaceInteractionRequest::default()
149            .validate()
150            .expect("InvokeSurfaceInteractionRequest::default() should validate");
151    }
152
153    #[test]
154    fn invoke_request_validate_is_unconditionally_ok_canary() {
155        // Canary (spec 2026-08-06 item 5): validate() is unconditionally Ok
156        // today, so the 403-before-semantic-400 dispatch ordering has no
157        // discriminating test. The author of the FIRST real Validate rule
158        // breaks this test and must then add that discriminating test (a
159        // well-formed body violating the rule, sent by an unauthorized
160        // caller, must 403 — see the choke-point comment in
161        // web-api routes/surfaces.rs::dispatch_surface_interaction). Each
162        // fixture below is deliberately validator-hostile (the kind of
163        // value a first real rule would plausibly reject), so this test
164        // goes red the instant such a rule lands rather than staying green
165        // by accident on uniformly well-formed input.
166        //
167        // WARNING: the first two fixtures below target `target_provider_id`,
168        // but that field (with `timeout_seconds`) is read via
169        // `Unvalidated::peek_envelope()` before this `validate()` ever runs
170        // (see ADR-0038's deferred-obligation paragraph) — a rule added here
171        // would NOT gate the pre-validation registry-lookup/audit uses in
172        // `dispatch_surface_interaction` step 1. Enforce such a rule at the
173        // peek site instead, not (only) here.
174        let base = InvokeSurfaceInteractionRequest {
175            params: serde_json::Map::from_iter([(
176                "k".to_string(),
177                serde_json::Value::String("v".to_string()),
178            )]),
179            encrypted_sensitive_params: None,
180            target_provider_id: Some("provider".to_string()),
181            idempotency_key: Some("key".to_string()),
182            timeout_seconds: Some(1),
183        };
184
185        let hostile_fixtures = [
186            InvokeSurfaceInteractionRequest {
187                target_provider_id: Some(String::new()),
188                ..base.clone()
189            },
190            InvokeSurfaceInteractionRequest {
191                target_provider_id: Some("   ".to_string()),
192                ..base.clone()
193            },
194            InvokeSurfaceInteractionRequest {
195                idempotency_key: Some(String::new()),
196                ..base.clone()
197            },
198            InvokeSurfaceInteractionRequest {
199                idempotency_key: Some("   ".to_string()),
200                ..base.clone()
201            },
202            InvokeSurfaceInteractionRequest {
203                timeout_seconds: Some(0),
204                ..base.clone()
205            },
206            InvokeSurfaceInteractionRequest {
207                // Oversized value derived from `MAX_SURFACE_PARAMS_LEN`, the
208                // limit that already governs this field's wire twin
209                // (`surfaces::SurfaceActionRequest.params`, see
210                // `wire_validate_impls.rs`).
211                params: serde_json::Map::from_iter([(
212                    String::new(),
213                    serde_json::Value::String("x".repeat(MAX_SURFACE_PARAMS_LEN + 1)),
214                )]),
215                ..base.clone()
216            },
217        ];
218
219        for fixture in hostile_fixtures {
220            fixture.validate().expect(
221                "validate() must stay unconditionally Ok until the discriminating test exists",
222            );
223        }
224    }
225
226    #[test]
227    fn routing_envelope_projects_only_the_envelope_fields() {
228        let req = InvokeSurfaceInteractionRequest {
229            target_provider_id: Some("p1".to_string()),
230            timeout_seconds: Some(30),
231            ..Default::default()
232        };
233        let env = crate::validation::RoutingEnvelope::routing_envelope(&req);
234        assert_eq!(env.target_provider_id.as_deref(), Some("p1"));
235        assert_eq!(env.timeout_seconds, Some(30));
236    }
237}