1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use super::{
7 ConnectorCapabilities, ProviderConnectorManifest, ProviderOAuthManifest,
8 ResolvedProviderConnectorKind,
9};
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct ProviderManifestEntry {
13 pub id: harn_vm::ProviderId,
14 pub connector: ProviderConnectorManifest,
15 #[serde(default)]
16 pub oauth: Option<ProviderOAuthManifest>,
17 #[serde(default)]
18 pub setup: Option<ProviderSetupManifest>,
19 #[serde(default)]
20 pub service: Option<ConnectorServiceManifest>,
21 #[serde(default)]
22 pub capabilities: ConnectorCapabilities,
23}
24
25#[derive(Debug, Clone)]
26pub struct ResolvedProviderConnectorConfig {
27 pub id: harn_vm::ProviderId,
28 pub manifest_dir: PathBuf,
29 pub connector: ResolvedProviderConnectorKind,
30 pub oauth: Option<ProviderOAuthManifest>,
31 pub setup: Option<ProviderSetupManifest>,
32 pub service: Option<ConnectorServiceManifest>,
33 pub connector_contract_version: u32,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ConnectorServiceManifest {
44 pub name: String,
45 pub description: String,
46 #[serde(default)]
47 pub operations: Vec<ConnectorOperationManifest>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct ConnectorOperationManifest {
53 pub id: String,
54 pub capability: String,
55 pub purpose: String,
57 pub effect: ConnectorOperationEffect,
58 #[serde(default)]
59 pub environments: Vec<ConnectorEnvironment>,
60 #[serde(default)]
61 pub evidence: Vec<ConnectorEvidenceRequirement>,
62 #[serde(default)]
63 pub protected_profile: ConnectorProtectedProfileManifest,
64 #[serde(default)]
65 pub test_profile: ConnectorTestProfile,
66 #[serde(default)]
67 pub external_spend: ConnectorExternalSpend,
68 #[serde(default)]
69 pub reconciliation: ConnectorReconciliation,
70 #[serde(default)]
71 pub redaction: Vec<ConnectorRedactionTarget>,
72 #[serde(default)]
82 pub parameters: Vec<ConnectorParameterManifest>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ConnectorParameterManifest {
100 pub name: String,
101 pub description: String,
103 #[serde(rename = "type")]
104 pub value_type: ConnectorParameterType,
105 #[serde(default)]
106 pub required: bool,
107 #[serde(default)]
110 pub allowed_values: Vec<String>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ConnectorParameterType {
116 String,
117 Integer,
118 Number,
119 Boolean,
120 Object,
121 Array,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum ConnectorOperationEffect {
127 Read,
128 Consequential,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum ConnectorEnvironment {
134 Mock,
135 Test,
136 Live,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ConnectorEvidenceRequirement {
142 Citation,
143 CurrentProviderState,
144 FreshQuote,
145 UserConfirmation,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ProtectedProfileFieldClass {
151 LegalIdentity,
152 BirthDate,
153 ContactDetails,
154 AccessibilityNeeds,
155 LoyaltyAccounts,
156 TravelDocuments,
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct ConnectorProtectedProfileManifest {
162 #[serde(default)]
163 pub required: Vec<ProtectedProfileFieldClass>,
164 #[serde(default)]
165 pub optional: Vec<ProtectedProfileFieldClass>,
166 #[serde(default)]
167 pub conditional: Vec<ConnectorConditionalProfileRequirement>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct ConnectorConditionalProfileRequirement {
173 pub condition: String,
175 pub field_classes: Vec<ProtectedProfileFieldClass>,
176}
177
178#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum ConnectorExternalSpend {
181 #[default]
182 None,
183 Estimate,
184 Commit,
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum ConnectorReconciliation {
190 #[default]
191 None,
192 Supported,
193 Required,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum ConnectorRedactionTarget {
199 RequestBody,
200 ResponseBody,
201 ErrorBody,
202 Headers,
203 Query,
204}
205
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum ConnectorTestProfile {
209 #[default]
210 None,
211 FictionalRequired,
212}
213
214pub fn connector_service_issues(service: &ConnectorServiceManifest) -> Vec<String> {
216 let mut issues = Vec::new();
217 if service.name.trim().is_empty() {
218 issues.push("service.name is required".to_string());
219 }
220 if service.description.trim().is_empty() {
221 issues.push("service.description is required".to_string());
222 }
223 if service.operations.is_empty() {
224 issues.push("service.operations must include at least one operation".to_string());
225 }
226
227 let mut operation_ids = std::collections::BTreeSet::new();
228 for operation in &service.operations {
229 let label = if operation.id.trim().is_empty() {
230 "<missing>"
231 } else {
232 operation.id.as_str()
233 };
234 if !portable_operation_id(&operation.id) {
235 issues.push(format!(
236 "service operation '{label}' id must use ASCII letters, digits, '.', '_', or '-'"
237 ));
238 } else if !operation_ids.insert(operation.id.as_str()) {
239 issues.push(format!(
240 "service operation id '{}' is repeated",
241 operation.id
242 ));
243 }
244 if !portable_id(&operation.capability) {
245 issues.push(format!(
246 "service operation '{label}' capability must use lowercase letters, digits, '.', '_', or '-'"
247 ));
248 }
249 if operation.purpose.trim().is_empty() {
250 issues.push(format!("service operation '{label}' purpose is required"));
251 }
252 if operation.environments.is_empty() {
253 issues.push(format!(
254 "service operation '{label}' environments must not be empty"
255 ));
256 }
257 if operation.effect == ConnectorOperationEffect::Read
258 && operation.external_spend != ConnectorExternalSpend::None
259 {
260 issues.push(format!(
261 "read operation '{label}' cannot declare external spend"
262 ));
263 }
264 if operation.effect == ConnectorOperationEffect::Read
265 && operation.reconciliation == ConnectorReconciliation::Required
266 {
267 issues.push(format!(
268 "read operation '{label}' cannot require reconciliation"
269 ));
270 }
271
272 let mut parameter_names = std::collections::BTreeSet::new();
273 for parameter in &operation.parameters {
274 let parameter_label = if parameter.name.trim().is_empty() {
275 "<missing>"
276 } else {
277 parameter.name.as_str()
278 };
279 if !portable_operation_id(¶meter.name) {
280 issues.push(format!(
281 "service operation '{label}' parameter '{parameter_label}' name must use ASCII letters, digits, '.', '_', or '-'"
282 ));
283 } else if !parameter_names.insert(parameter.name.as_str()) {
284 issues.push(format!(
285 "service operation '{label}' repeats parameter '{parameter_label}'"
286 ));
287 }
288 if parameter.description.trim().is_empty() {
292 issues.push(format!(
293 "service operation '{label}' parameter '{parameter_label}' description is required"
294 ));
295 }
296 if !parameter.allowed_values.is_empty()
297 && parameter.value_type != ConnectorParameterType::String
298 {
299 issues.push(format!(
300 "service operation '{label}' parameter '{parameter_label}' declares allowed values, which only apply to a string parameter"
301 ));
302 }
303 let mut allowed = std::collections::BTreeSet::new();
304 for value in ¶meter.allowed_values {
305 if !allowed.insert(value.as_str()) {
306 issues.push(format!(
307 "service operation '{label}' parameter '{parameter_label}' repeats allowed value '{value}'"
308 ));
309 }
310 }
311 }
312
313 let profile = &operation.protected_profile;
314 let mut declared_classes = std::collections::BTreeSet::new();
315 for class in profile.required.iter().chain(profile.optional.iter()) {
316 if !declared_classes.insert(*class as u8) {
317 issues.push(format!(
318 "service operation '{label}' repeats protected profile class '{class:?}'"
319 ));
320 }
321 }
322 for requirement in &profile.conditional {
323 if !portable_id(&requirement.condition) {
324 issues.push(format!(
325 "service operation '{label}' conditional profile id '{}' is invalid",
326 requirement.condition
327 ));
328 }
329 if requirement.field_classes.is_empty() {
330 issues.push(format!(
331 "service operation '{label}' conditional profile '{}' has no field classes",
332 requirement.condition
333 ));
334 }
335 }
336
337 let has_profile = !profile.required.is_empty()
338 || !profile.optional.is_empty()
339 || profile
340 .conditional
341 .iter()
342 .any(|requirement| !requirement.field_classes.is_empty());
343 if has_profile
344 && operation.environments.contains(&ConnectorEnvironment::Test)
345 && operation.test_profile != ConnectorTestProfile::FictionalRequired
346 {
347 issues.push(format!(
348 "service operation '{label}' uses protected profile fields in test mode but does not require a fictional fixture"
349 ));
350 }
351 if has_profile {
352 for required_target in [
353 ConnectorRedactionTarget::RequestBody,
354 ConnectorRedactionTarget::ResponseBody,
355 ConnectorRedactionTarget::ErrorBody,
356 ] {
357 if !operation.redaction.contains(&required_target) {
358 issues.push(format!(
359 "service operation '{label}' with protected profile fields must redact {required_target:?}"
360 ));
361 }
362 }
363 }
364 }
365 issues
366}
367
368fn portable_id(value: &str) -> bool {
369 !value.is_empty()
370 && value.bytes().all(|byte| {
371 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
372 })
373}
374
375fn portable_operation_id(value: &str) -> bool {
376 !value.is_empty()
377 && value.bytes().all(|byte| {
378 byte.is_ascii_alphabetic()
379 || byte.is_ascii_digit()
380 || matches!(byte, b'.' | b'_' | b'-')
381 })
382}
383
384#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
385pub struct ProviderSetupManifest {
386 #[serde(default, alias = "auth-type")]
387 pub auth_type: Option<String>,
388 #[serde(default)]
389 pub flow: Option<String>,
390 #[serde(default, alias = "required-scopes", alias = "scopes")]
391 pub required_scopes: Vec<String>,
392 #[serde(default, alias = "required-secrets")]
393 pub required_secrets: Vec<String>,
394 #[serde(default, alias = "credential-environment")]
395 pub credential_environment: Vec<ConnectorCredentialEnvironmentManifest>,
396 #[serde(default, alias = "configuration-environment")]
397 pub configuration_environment: Vec<ConnectorConfigurationEnvironmentManifest>,
398 #[serde(default, alias = "setup-command")]
399 pub setup_command: Vec<String>,
400 #[serde(default, alias = "validation-command")]
401 pub validation_command: Vec<String>,
402 #[serde(default, alias = "health-checks")]
403 pub health_checks: Vec<ConnectorHealthCheckManifest>,
404 #[serde(default)]
405 pub recovery: ConnectorRecoveryCopy,
406 #[serde(flatten, default)]
407 pub extra: BTreeMap<String, toml::Value>,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
414pub enum ConnectorSetupConfigurationField {
415 #[serde(rename = "oauth_client_id")]
416 OAuthClientId,
417}
418
419impl ConnectorSetupConfigurationField {
420 pub const WIRE_VALUES: &'static [&'static str] = &["oauth_client_id"];
421
422 pub const fn as_str(self) -> &'static str {
423 match self {
424 Self::OAuthClientId => "oauth_client_id",
425 }
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct ConnectorConfigurationEnvironmentManifest {
431 pub field: ConnectorSetupConfigurationField,
432 #[serde(default, alias = "environment-names")]
433 pub environment_names: Vec<String>,
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
442pub struct ConnectorCredentialEnvironmentManifest {
443 pub secret: String,
444 #[serde(default, alias = "environment-names")]
445 pub environment_names: Vec<String>,
446}
447
448#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
449pub struct ConnectorHealthCheckManifest {
450 pub id: String,
451 pub kind: String,
452 #[serde(default)]
453 pub command: Vec<String>,
454 #[serde(default)]
455 pub secret: Option<String>,
456 #[serde(default)]
457 pub url: Option<String>,
458}
459
460#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
461pub struct ConnectorRecoveryCopy {
462 #[serde(default, alias = "missing-install")]
463 pub missing_install: Option<String>,
464 #[serde(default, alias = "missing-auth")]
465 pub missing_auth: Option<String>,
466 #[serde(default, alias = "expired-credentials")]
467 pub expired_credentials: Option<String>,
468 #[serde(default, alias = "revoked-credentials")]
469 pub revoked_credentials: Option<String>,
470 #[serde(default, alias = "missing-scopes")]
471 pub missing_scopes: Option<String>,
472 #[serde(default, alias = "inaccessible-resource")]
473 pub inaccessible_resource: Option<String>,
474 #[serde(default, alias = "transient-provider-outage")]
475 pub transient_provider_outage: Option<String>,
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn protected_profile_contract_is_typed_and_complete() {
484 let service: ConnectorServiceManifest = toml::from_str(
485 r#"
486name = "Duffel"
487description = "Searches flights and creates governed test orders."
488
489[[operations]]
490id = "orders.create"
491capability = "travel.booking"
492purpose = "Create the exact reviewed flight order."
493effect = "consequential"
494environments = ["test"]
495evidence = ["fresh_quote", "user_confirmation"]
496external_spend = "commit"
497reconciliation = "required"
498redaction = ["request_body", "response_body", "error_body"]
499test_profile = "fictional_required"
500
501[operations.protected_profile]
502required = ["legal_identity", "birth_date"]
503optional = ["contact_details"]
504
505[[operations.protected_profile.conditional]]
506condition = "international_itinerary"
507field_classes = ["travel_documents"]
508"#,
509 )
510 .expect("typed service manifest");
511
512 assert!(connector_service_issues(&service).is_empty());
513 assert_eq!(
514 service.operations[0].protected_profile.required,
515 [
516 ProtectedProfileFieldClass::LegalIdentity,
517 ProtectedProfileFieldClass::BirthDate,
518 ]
519 );
520 }
521
522 #[test]
523 fn protected_profile_test_actions_require_fictional_fixture_and_redaction() {
524 let service = ConnectorServiceManifest {
525 name: "Duffel".to_string(),
526 description: "Travel".to_string(),
527 operations: vec![ConnectorOperationManifest {
528 id: "orders.create".to_string(),
529 capability: "travel.booking".to_string(),
530 purpose: "Create an order".to_string(),
531 effect: ConnectorOperationEffect::Consequential,
532 environments: vec![ConnectorEnvironment::Test],
533 evidence: Vec::new(),
534 protected_profile: ConnectorProtectedProfileManifest {
535 required: vec![ProtectedProfileFieldClass::LegalIdentity],
536 ..ConnectorProtectedProfileManifest::default()
537 },
538 test_profile: ConnectorTestProfile::None,
539 external_spend: ConnectorExternalSpend::Commit,
540 reconciliation: ConnectorReconciliation::Required,
541 redaction: vec![ConnectorRedactionTarget::ErrorBody],
542 parameters: Vec::new(),
543 }],
544 };
545
546 let issues = connector_service_issues(&service).join("\n");
547 assert!(issues.contains("does not require a fictional fixture"));
548 assert!(issues.contains("RequestBody"));
549 assert!(issues.contains("ResponseBody"));
550 }
551
552 #[test]
553 fn operation_parameters_are_typed_and_optional() {
554 let service: ConnectorServiceManifest = toml::from_str(
555 r#"
556name = "Duffel"
557description = "Searches flights."
558
559[[operations]]
560id = "offers.list"
561capability = "flights.research"
562purpose = "List offers for a completed offer request."
563effect = "read"
564environments = ["test"]
565
566[[operations.parameters]]
567name = "offer_request_id"
568description = "The offer request to list offers for."
569type = "string"
570required = true
571
572[[operations.parameters]]
573name = "limit"
574description = "How many offers to return."
575type = "integer"
576
577[[operations.parameters]]
578name = "sort"
579description = "Ordering applied to the returned offers."
580type = "string"
581allowed_values = ["total_amount", "total_duration"]
582
583[[operations]]
584id = "places.list"
585capability = "flights.research"
586purpose = "Search airports and cities."
587effect = "read"
588environments = ["test"]
589"#,
590 )
591 .expect("typed parameters");
592
593 assert!(connector_service_issues(&service).is_empty());
594
595 let listed = &service.operations[0].parameters;
596 assert_eq!(listed.len(), 3);
597 assert_eq!(listed[0].name, "offer_request_id");
598 assert_eq!(listed[0].value_type, ConnectorParameterType::String);
599 assert!(listed[0].required);
600 assert!(!listed[1].required);
602 assert_eq!(listed[1].value_type, ConnectorParameterType::Integer);
603 assert_eq!(listed[2].allowed_values, ["total_amount", "total_duration"]);
604
605 assert!(service.operations[1].parameters.is_empty());
609 }
610
611 #[test]
612 fn operation_parameters_must_be_named_described_and_distinct() {
613 let service: ConnectorServiceManifest = toml::from_str(
614 r#"
615name = "Duffel"
616description = "Searches flights."
617
618[[operations]]
619id = "offers.list"
620capability = "flights.research"
621purpose = "List offers."
622effect = "read"
623environments = ["test"]
624
625[[operations.parameters]]
626name = "limit"
627description = "How many offers to return."
628type = "integer"
629
630[[operations.parameters]]
631name = "limit"
632description = "A repeat of the same argument."
633type = "integer"
634
635[[operations.parameters]]
636name = "sort by"
637description = "Name is not portable."
638type = "string"
639
640[[operations.parameters]]
641name = "cursor"
642description = " "
643type = "string"
644
645[[operations.parameters]]
646name = "page"
647description = "Allowed values only apply to a string."
648type = "integer"
649allowed_values = ["1", "2"]
650"#,
651 )
652 .expect("parses; the issues are semantic");
653
654 let issues = connector_service_issues(&service).join("\n");
655 assert!(issues.contains("repeats parameter 'limit'"), "{issues}");
656 assert!(
657 issues.contains("parameter 'sort by' name must use"),
658 "{issues}"
659 );
660 assert!(
661 issues.contains("parameter 'cursor' description is required"),
662 "{issues}"
663 );
664 assert!(
665 issues.contains("parameter 'page' declares allowed values"),
666 "{issues}"
667 );
668 }
669
670 #[test]
674 fn operation_parameters_reject_unknown_fields() {
675 let error = toml::from_str::<ConnectorServiceManifest>(
676 r#"
677name = "Duffel"
678description = "Searches flights."
679
680[[operations]]
681id = "offers.list"
682capability = "flights.research"
683purpose = "List offers."
684effect = "read"
685environments = ["test"]
686
687[[operations.parameters]]
688name = "limit"
689description = "How many offers to return."
690type = "integer"
691defualt = 10
692"#,
693 )
694 .expect_err("unknown parameter keys must fail closed");
695
696 assert!(error.to_string().contains("defualt"), "{error}");
697 }
698
699 #[test]
700 fn service_manifest_rejects_unknown_policy_fields() {
701 let error = toml::from_str::<ConnectorServiceManifest>(
702 r#"
703name = "Echo"
704description = "Echoes messages."
705automatic_approval = true
706"#,
707 )
708 .expect_err("unknown policy keys must fail closed");
709 assert!(error.to_string().contains("unknown field"));
710 }
711}