1use crate::{
2 FeedbackConfig, FeedbackService, FeedbackStore, FeedbackStoreService, MemoryFeedbackStore,
3 TranscriptionService, feedback_request_body_budget, feedback_router,
4};
5use async_trait::async_trait;
6#[cfg(any(feature = "postgres", feature = "sqlite"))]
7use minco_core::MigrationSet;
8use minco_core::{
9 CapabilityProvision, CapabilityRequirement, ConfigurationField, ConfigurationValueKind,
10 DataClass, HealthCheckDescriptor, IdleCostClass, OperationDescriptor, Plugin, PluginContext,
11 PluginDescriptor, PluginError, PluginId, PluginStability, ResourceIntent, ResourceKind,
12};
13use minco_http::{HttpHeaderPolicy, HttpModule};
14use minco_plugin_audit::AuditService;
15use minco_plugin_events::EventServices;
16use minco_plugin_health::{HealthCheck, HealthResult};
17use minco_plugin_notifications::NotificationService;
18use minco_plugin_object_storage::ObjectStoreService;
19use semver::{Version, VersionReq};
20use std::sync::Arc;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum FeedbackStorageProfile {
24 Memory,
25 Custom,
26 #[cfg(feature = "postgres")]
27 Postgres,
28 #[cfg(feature = "sqlite")]
29 Sqlite,
30}
31
32#[derive(Clone)]
33pub struct FeedbackPlugin {
34 store: FeedbackStoreService,
35 storage_profile: FeedbackStorageProfile,
36 transcription: Option<TranscriptionService>,
37}
38
39impl std::fmt::Debug for FeedbackPlugin {
40 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 formatter
42 .debug_struct("FeedbackPlugin")
43 .field("storage_profile", &self.storage_profile)
44 .field("transcription_configured", &self.transcription.is_some())
45 .finish_non_exhaustive()
46 }
47}
48
49impl FeedbackPlugin {
50 #[must_use]
51 pub fn new(store: Arc<dyn FeedbackStore>) -> Self {
52 Self {
53 store: FeedbackStoreService::new(store),
54 storage_profile: FeedbackStorageProfile::Custom,
55 transcription: None,
56 }
57 }
58
59 #[must_use]
60 pub fn memory() -> Self {
61 Self {
62 store: FeedbackStoreService::new(Arc::new(MemoryFeedbackStore::default())),
63 storage_profile: FeedbackStorageProfile::Memory,
64 transcription: None,
65 }
66 }
67
68 #[must_use]
69 pub fn with_transcription(mut self, transcription: TranscriptionService) -> Self {
70 self.transcription = Some(transcription);
71 self
72 }
73
74 #[cfg(feature = "postgres")]
75 #[must_use]
76 pub fn postgres(pool: sqlx::PgPool) -> Self {
77 Self {
78 store: FeedbackStoreService::new(Arc::new(crate::PostgresFeedbackStore::new(pool))),
79 storage_profile: FeedbackStorageProfile::Postgres,
80 transcription: None,
81 }
82 }
83
84 #[cfg(feature = "sqlite")]
85 #[must_use]
86 pub fn sqlite(pool: sqlx::SqlitePool) -> Self {
87 Self {
88 store: FeedbackStoreService::new(Arc::new(crate::SqliteFeedbackStore::new(pool))),
89 storage_profile: FeedbackStorageProfile::Sqlite,
90 transcription: None,
91 }
92 }
93}
94
95impl Default for FeedbackPlugin {
96 fn default() -> Self {
97 Self::memory()
98 }
99}
100
101impl Plugin for FeedbackPlugin {
102 fn descriptor(&self) -> PluginDescriptor {
103 let mut descriptor = PluginDescriptor::new(
104 PluginId::new("feedback").expect("static plugin ID"),
105 Version::new(0, 1, 0),
106 "Fast client feedback loops with screenshots, voice, discussion, and AI-ready context",
107 );
108 descriptor.core_compatibility =
109 VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
110 descriptor.stability = PluginStability::Stable;
111 descriptor.default_enabled = false;
112 descriptor.documentation = Some("https://docs.rs/minco-plugin-feedback".into());
113 descriptor.data_classes.extend([
114 DataClass::CustomerProvided,
115 DataClass::Personal,
116 DataClass::Confidential,
117 ]);
118 descriptor.plugin_dependencies.extend(
119 [
120 "health",
121 "identity",
122 "object-storage",
123 "notifications",
124 "audit",
125 "events",
126 ]
127 .into_iter()
128 .map(|id| PluginId::new(id).expect("static plugin ID")),
129 );
130 descriptor.requires.extend([
131 requirement("health.registry"),
132 requirement("identity.resolve"),
133 requirement("authorization.permissions"),
134 requirement("storage.object"),
135 requirement("notifications.send"),
136 requirement("audit.append"),
137 requirement("events.publish"),
138 requirement("events.outbox"),
139 ]);
140 descriptor.provides.extend([
141 provision("feedback.submit"),
142 provision("feedback.conversation"),
143 provision("feedback.manage"),
144 provision("feedback.ai-context"),
145 provision("feedback.widget"),
146 ]);
147 if self.transcription.is_some() {
148 descriptor
149 .provides
150 .push(provision("feedback.transcription"));
151 }
152 descriptor.operations.extend(feedback_operations());
153 match self.storage_profile {
154 FeedbackStorageProfile::Memory => {}
155 FeedbackStorageProfile::Custom => descriptor.resources.push(ResourceIntent {
156 id: "feedback-custom-store".into(),
157 kind: ResourceKind::Custom("feedback-store".into()),
158 idle_cost: IdleCostClass::ProviderManaged,
159 wake_sources: Vec::new(),
160 dependencies: Vec::new(),
161 }),
162 #[cfg(feature = "postgres")]
163 FeedbackStorageProfile::Postgres => descriptor.migrations.push(MigrationSet {
164 id: "feedback-postgres-v1".into(),
165 database: "postgres".into(),
166 path: "migrations/postgres".into(),
167 }),
168 #[cfg(feature = "sqlite")]
169 FeedbackStorageProfile::Sqlite => descriptor.migrations.push(MigrationSet {
170 id: "feedback-sqlite-v1".into(),
171 database: "sqlite".into(),
172 path: "migrations/sqlite".into(),
173 }),
174 }
175 descriptor.health_checks.push(HealthCheckDescriptor {
176 id: "feedback-store".into(),
177 critical: true,
178 });
179 descriptor.configuration.extend(configuration_fields());
180 descriptor
181 }
182
183 fn configure_descriptor(
184 &self,
185 descriptor: &mut PluginDescriptor,
186 configuration: Option<&serde_json::Value>,
187 ) -> Result<(), PluginError> {
188 let configuration = configuration
189 .cloned()
190 .unwrap_or_else(|| serde_json::json!({}));
191 let configuration =
192 serde_json::from_value::<FeedbackConfig>(configuration).map_err(|source| {
193 PluginError::InvalidConfiguration {
194 plugin: descriptor.id.clone(),
195 source,
196 }
197 })?;
198 if !configuration.transcription_enabled {
199 descriptor
200 .provides
201 .retain(|capability| capability.name != "feedback.transcription");
202 }
203 Ok(())
204 }
205
206 fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
207 let config = context.configuration::<FeedbackConfig>()?;
208 let (objects, notifications, audit, events) = {
209 let services = context.services();
210 (
211 (*services.get::<ObjectStoreService>()?).clone(),
212 (*services.get::<NotificationService>()?).clone(),
213 (*services.get::<AuditService>()?).clone(),
214 (*services.get::<EventServices>()?).clone(),
215 )
216 };
217 let service = FeedbackService::new(
218 self.store.clone(),
219 objects,
220 notifications,
221 audit,
222 events,
223 self.transcription.clone(),
224 config,
225 )
226 .map_err(|error| PluginError::Installation(error.to_string()))?;
227
228 context.services().insert(Arc::new(self.store.clone()))?;
229 context.services().insert(Arc::new(service.clone()))?;
230 context
231 .contributions()
232 .push_shared::<dyn HealthCheck>(Arc::new(FeedbackHealthCheck(service.clone())));
233 let request_body_budget = feedback_request_body_budget(service.config());
234 let mut header_policy = HttpHeaderPolicy::empty();
235 for name in ["x-minco-feedback-token", "x-minco-feedback-project-key"] {
236 header_policy
237 .allow_request_header_name(name)
238 .and_then(|()| header_policy.mark_request_header_name_sensitive(name))
239 .map_err(|error| PluginError::Installation(error.to_string()))?;
240 }
241 HttpModule::new(context.plugin_id().clone(), feedback_router(service))
242 .with_operations(
243 feedback_operations()
244 .into_iter()
245 .map(|operation| operation.operation_id),
246 )
247 .with_max_request_body_bytes(request_body_budget)
248 .with_header_policy(header_policy)
249 .contribute(context);
250 Ok(())
251 }
252}
253
254#[derive(Debug, Clone)]
255struct FeedbackHealthCheck(FeedbackService);
256
257#[async_trait]
258impl HealthCheck for FeedbackHealthCheck {
259 fn id(&self) -> &'static str {
260 "feedback-store"
261 }
262
263 async fn check(&self) -> HealthResult {
264 match self.0.ready().await {
265 Ok(()) => HealthResult {
266 id: self.id().into(),
267 ready: true,
268 critical: true,
269 detail: None,
270 },
271 Err(error) => HealthResult {
272 id: self.id().into(),
273 ready: false,
274 critical: true,
275 detail: Some(error.to_string()),
276 },
277 }
278 }
279}
280
281fn provision(name: &str) -> CapabilityProvision {
282 CapabilityProvision {
283 name: name.into(),
284 version: Version::new(1, 0, 0),
285 }
286}
287
288fn requirement(name: &str) -> CapabilityRequirement {
289 CapabilityRequirement {
290 name: name.into(),
291 version: VersionReq::parse("^1").expect("static requirement"),
292 }
293}
294
295fn feedback_operations() -> Vec<OperationDescriptor> {
296 [
297 ("feedbackWidget", "GET", "/_minco/feedback/widget.js", true),
298 (
299 "getFeedbackWidgetConfig",
300 "GET",
301 "/_minco/feedback/widget-config",
302 true,
303 ),
304 ("createFeedback", "POST", "/_minco/feedback/threads", true),
305 (
306 "getClientFeedback",
307 "GET",
308 "/_minco/feedback/threads/{id}",
309 true,
310 ),
311 (
312 "replyToFeedback",
313 "POST",
314 "/_minco/feedback/threads/{id}/messages",
315 true,
316 ),
317 (
318 "getClientFeedbackAttachment",
319 "GET",
320 "/_minco/feedback/threads/{id}/attachments/{attachmentId}",
321 true,
322 ),
323 (
324 "transcribeFeedbackAudio",
325 "POST",
326 "/_minco/feedback/transcriptions",
327 true,
328 ),
329 (
330 "listDeveloperFeedback",
331 "GET",
332 "/_minco/feedback/developer/threads",
333 false,
334 ),
335 (
336 "getDeveloperFeedback",
337 "GET",
338 "/_minco/feedback/developer/threads/{id}",
339 false,
340 ),
341 (
342 "developerReplyToFeedback",
343 "POST",
344 "/_minco/feedback/developer/threads/{id}/messages",
345 false,
346 ),
347 (
348 "transitionFeedback",
349 "PATCH",
350 "/_minco/feedback/developer/threads/{id}/status",
351 false,
352 ),
353 (
354 "getFeedbackAiContext",
355 "GET",
356 "/_minco/feedback/developer/threads/{id}/ai-context",
357 false,
358 ),
359 (
360 "getDeveloperFeedbackAttachment",
361 "GET",
362 "/_minco/feedback/developer/threads/{id}/attachments/{attachmentId}",
363 false,
364 ),
365 ]
366 .into_iter()
367 .map(|(operation_id, method, path, public)| OperationDescriptor {
368 operation_id: operation_id.into(),
369 method: method.into(),
370 path: path.into(),
371 public,
372 idempotent: false,
373 })
374 .collect()
375}
376
377fn configuration_fields() -> Vec<ConfigurationField> {
378 vec![
379 field(
380 "project_id",
381 ConfigurationValueKind::String,
382 true,
383 false,
384 None,
385 "Stable product or application identifier",
386 ),
387 field(
388 "widget_label",
389 ConfigurationValueKind::String,
390 false,
391 false,
392 Some(serde_json::json!("Share feedback")),
393 "Accessible label shown on the feedback action",
394 ),
395 field(
396 "widget_position",
397 ConfigurationValueKind::String,
398 false,
399 false,
400 Some(serde_json::json!("bottom_right")),
401 "FAB position: top_left, top_right, bottom_left, or bottom_right",
402 ),
403 field(
404 "offset_x_px",
405 ConfigurationValueKind::Integer,
406 false,
407 false,
408 Some(serde_json::json!(24)),
409 "Horizontal viewport offset in CSS pixels",
410 ),
411 field(
412 "offset_y_px",
413 ConfigurationValueKind::Integer,
414 false,
415 false,
416 Some(serde_json::json!(24)),
417 "Vertical viewport offset in CSS pixels",
418 ),
419 field(
420 "theme",
421 ConfigurationValueKind::String,
422 false,
423 false,
424 Some(serde_json::json!("auto")),
425 "Widget theme: light, dark, or auto",
426 ),
427 field(
428 "token_storage",
429 ConfigurationValueKind::String,
430 false,
431 false,
432 Some(serde_json::json!("session")),
433 "Opaque client-token storage: session (default) or local",
434 ),
435 field(
436 "max_http_body_bytes",
437 ConfigurationValueKind::Integer,
438 false,
439 false,
440 Some(serde_json::json!(7 * 1024 * 1024)),
441 "Maximum complete multipart request size for the default serverless HTTP path",
442 ),
443 field(
444 "max_screenshot_bytes",
445 ConfigurationValueKind::Integer,
446 false,
447 false,
448 Some(serde_json::json!(4 * 1024 * 1024)),
449 "Maximum screenshot upload size",
450 ),
451 field(
452 "max_audio_bytes",
453 ConfigurationValueKind::Integer,
454 false,
455 false,
456 Some(serde_json::json!(5 * 1024 * 1024)),
457 "Maximum voice recording upload size",
458 ),
459 field(
460 "max_file_bytes",
461 ConfigurationValueKind::Integer,
462 false,
463 false,
464 Some(serde_json::json!(5 * 1024 * 1024)),
465 "Maximum general attachment upload size",
466 ),
467 field(
468 "max_attachments",
469 ConfigurationValueKind::Integer,
470 false,
471 false,
472 Some(serde_json::json!(3)),
473 "Maximum screenshot, audio, and file attachments per submission; zero disables all attachments",
474 ),
475 field(
476 "allow_anonymous",
477 ConfigurationValueKind::Boolean,
478 false,
479 false,
480 Some(serde_json::json!(false)),
481 "Explicitly allow unauthenticated feedback when neither identity nor a project key is available",
482 ),
483 field(
484 "project_key",
485 ConfigurationValueKind::String,
486 false,
487 false,
488 None,
489 "Optional browser-visible submission key used for basic abuse controls",
490 ),
491 field(
492 "developer_token",
493 ConfigurationValueKind::String,
494 false,
495 true,
496 None,
497 "Fallback bearer token for local/operator access; prefer an identity principal with feedback.manage",
498 ),
499 field(
500 "developer_recipient",
501 ConfigurationValueKind::String,
502 false,
503 false,
504 Some(serde_json::json!("developers")),
505 "Recipient understood by the configured notification sink",
506 ),
507 field(
508 "developer_link_base",
509 ConfigurationValueKind::String,
510 false,
511 false,
512 None,
513 "Optional base URL included in developer notifications",
514 ),
515 field(
516 "notify_client_updates",
517 ConfigurationValueKind::Boolean,
518 false,
519 false,
520 Some(serde_json::json!(true)),
521 "Send in-app notifications for developer replies and status changes",
522 ),
523 field(
524 "publish_events_inline",
525 ConfigurationValueKind::Boolean,
526 false,
527 false,
528 Some(serde_json::json!(false)),
529 "Publish outbox events on the request path instead of leaving them for a worker",
530 ),
531 field(
532 "screenshot_enabled",
533 ConfigurationValueKind::Boolean,
534 false,
535 false,
536 Some(serde_json::json!(true)),
537 "Allow browser screen capture and image attachments",
538 ),
539 field(
540 "voice_enabled",
541 ConfigurationValueKind::Boolean,
542 false,
543 false,
544 Some(serde_json::json!(false)),
545 "Allow microphone recording when the browser supports MediaRecorder",
546 ),
547 field(
548 "max_recording_seconds",
549 ConfigurationValueKind::Integer,
550 false,
551 false,
552 Some(serde_json::json!(90)),
553 "Maximum browser voice-note recording duration",
554 ),
555 field(
556 "include_url_query",
557 ConfigurationValueKind::Boolean,
558 false,
559 false,
560 Some(serde_json::json!(false)),
561 "Include URL query parameters in captured context after redaction",
562 ),
563 field(
564 "redact_query_parameters",
565 ConfigurationValueKind::StringList,
566 false,
567 false,
568 Some(serde_json::json!([
569 "access_token",
570 "api_key",
571 "code",
572 "key",
573 "password",
574 "secret",
575 "signature",
576 "token"
577 ])),
578 "Case-insensitive query parameter names replaced with [REDACTED]",
579 ),
580 field(
581 "transcription_enabled",
582 ConfigurationValueKind::Boolean,
583 false,
584 false,
585 Some(serde_json::json!(false)),
586 "Expose voice transcription for authenticated feedback.create principals when a TranscriptionService is configured",
587 ),
588 field(
589 "auto_transcribe_audio",
590 ConfigurationValueKind::Boolean,
591 false,
592 false,
593 Some(serde_json::json!(false)),
594 "Transcribe uploaded voice recordings automatically",
595 ),
596 field(
597 "poll_interval_ms",
598 ConfigurationValueKind::Integer,
599 false,
600 false,
601 Some(serde_json::json!(15_000)),
602 "Client discussion refresh interval in milliseconds",
603 ),
604 field(
605 "privacy_notice",
606 ConfigurationValueKind::String,
607 false,
608 false,
609 None,
610 "Optional client-visible privacy and retention notice",
611 ),
612 ]
613}
614
615fn field(
616 key: &str,
617 kind: ConfigurationValueKind,
618 required: bool,
619 secret: bool,
620 default: Option<serde_json::Value>,
621 description: &str,
622) -> ConfigurationField {
623 ConfigurationField {
624 key: key.into(),
625 kind,
626 required,
627 secret,
628 description: description.into(),
629 default,
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use crate::{DisabledTranscriber, TranscriptionService};
637 use minco_core::{PluginManager, PluginSelection};
638 use minco_plugin_audit::AuditPlugin;
639 use minco_plugin_events::EventsPlugin;
640 use minco_plugin_health::HealthPlugin;
641 use minco_plugin_identity::IdentityPlugin;
642 use minco_plugin_notifications::NotificationsPlugin;
643 use minco_plugin_object_storage::ObjectStoragePlugin;
644
645 #[test]
646 fn feedback_declares_every_foundational_dependency() {
647 let descriptor = FeedbackPlugin::default().descriptor();
648 assert_eq!(descriptor.stability, PluginStability::Stable);
649 let dependencies = descriptor
650 .plugin_dependencies
651 .iter()
652 .map(PluginId::as_str)
653 .collect::<Vec<_>>();
654 assert_eq!(
655 dependencies,
656 [
657 "health",
658 "identity",
659 "object-storage",
660 "notifications",
661 "audit",
662 "events"
663 ]
664 );
665 assert!(
666 descriptor
667 .operations
668 .iter()
669 .any(|operation| operation.operation_id == "createFeedback")
670 );
671 assert!(
672 descriptor
673 .data_classes
674 .contains(&DataClass::CustomerProvided)
675 );
676 }
677
678 #[test]
679 fn feedback_plugin_composes_with_explicit_foundational_dependencies() {
680 let mut manager = PluginManager::default();
681 manager.register(HealthPlugin).unwrap();
682 manager.register(IdentityPlugin::default()).unwrap();
683 manager.register(ObjectStoragePlugin::memory()).unwrap();
684 manager.register(NotificationsPlugin::memory().0).unwrap();
685 manager.register(AuditPlugin::memory().0).unwrap();
686 manager.register(EventsPlugin::memory().0).unwrap();
687 manager.register(FeedbackPlugin::memory()).unwrap();
688
689 let mut selection = PluginSelection::default();
690 let feedback_id = PluginId::new("feedback").unwrap();
691 selection.enabled.insert(feedback_id.clone());
692 selection
693 .set_configuration(
694 feedback_id,
695 &FeedbackConfig {
696 project_id: "example".into(),
697 developer_token: Some("developer-token-with-enough-entropy".into()),
698 ..FeedbackConfig::default()
699 },
700 )
701 .unwrap();
702 let application = manager.compose(&selection).unwrap();
703 assert!(application.services.get::<FeedbackService>().is_ok());
704 assert_eq!(application.contributions.get::<HttpModule>().len(), 1);
705 let module = application
706 .contributions
707 .get::<HttpModule>()
708 .into_iter()
709 .next()
710 .unwrap();
711 assert_eq!(
712 module
713 .header_policy
714 .allowed_request_headers()
715 .iter()
716 .map(http::HeaderName::as_str)
717 .collect::<Vec<_>>(),
718 ["x-minco-feedback-project-key", "x-minco-feedback-token"]
719 );
720 assert_eq!(
721 application
722 .contributions
723 .get_shared::<dyn HealthCheck>()
724 .len(),
725 1
726 );
727 }
728 #[test]
729 fn memory_feedback_does_not_claim_database_migrations_or_transcription() {
730 let descriptor = FeedbackPlugin::memory().descriptor();
731 assert!(descriptor.migrations.is_empty());
732 assert!(descriptor.resources.is_empty());
733 assert!(
734 descriptor
735 .provides
736 .iter()
737 .all(|capability| capability.name != "feedback.transcription")
738 );
739 }
740
741 #[test]
742 fn transcription_capability_requires_both_provider_and_enabled_configuration() {
743 fn manager_with_feedback(plugin: FeedbackPlugin) -> PluginManager {
744 let mut manager = PluginManager::default();
745 manager.register(HealthPlugin).unwrap();
746 manager.register(IdentityPlugin::default()).unwrap();
747 manager.register(ObjectStoragePlugin::memory()).unwrap();
748 manager.register(NotificationsPlugin::memory().0).unwrap();
749 manager.register(AuditPlugin::memory().0).unwrap();
750 manager.register(EventsPlugin::memory().0).unwrap();
751 manager.register(plugin).unwrap();
752 manager
753 }
754
755 let plugin = FeedbackPlugin::memory()
756 .with_transcription(TranscriptionService::new(Arc::new(DisabledTranscriber)));
757 let manager = manager_with_feedback(plugin);
758 let feedback_id = PluginId::new("feedback").unwrap();
759
760 let mut disabled = PluginSelection::default();
761 disabled.enabled.insert(feedback_id.clone());
762 disabled
763 .set_configuration(
764 feedback_id.clone(),
765 &FeedbackConfig {
766 project_id: "example".into(),
767 transcription_enabled: false,
768 ..FeedbackConfig::default()
769 },
770 )
771 .unwrap();
772 let disabled_graph = manager.compose(&disabled).unwrap().graph;
773 assert!(
774 !disabled_graph
775 .capabilities
776 .contains_key("feedback.transcription")
777 );
778
779 let mut enabled = PluginSelection::default();
780 enabled.enabled.insert(feedback_id.clone());
781 enabled
782 .set_configuration(
783 feedback_id,
784 &FeedbackConfig {
785 project_id: "example".into(),
786 transcription_enabled: true,
787 ..FeedbackConfig::default()
788 },
789 )
790 .unwrap();
791 let enabled_graph = manager.compose(&enabled).unwrap().graph;
792 assert!(
793 enabled_graph
794 .capabilities
795 .contains_key("feedback.transcription")
796 );
797 }
798
799 #[test]
800 fn openapi_contract_obeys_minco_policy_and_matches_the_plugin_operation_inventory() {
801 let path =
802 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi/feedback.openapi.yaml");
803 let report = minco_contract::load_contract(path).unwrap();
804 assert!(report.is_valid(), "{:?}", report.findings);
805
806 let mut contract = report
807 .document
808 .operations
809 .into_iter()
810 .map(|operation| {
811 (
812 operation.operation_id,
813 operation.method.as_str().to_owned(),
814 operation.path,
815 !operation.authenticated,
816 )
817 })
818 .collect::<Vec<_>>();
819 let mut descriptor = FeedbackPlugin::memory()
820 .descriptor()
821 .operations
822 .into_iter()
823 .map(|operation| {
824 (
825 operation.operation_id,
826 operation.method,
827 operation.path,
828 operation.public,
829 )
830 })
831 .collect::<Vec<_>>();
832 contract.sort();
833 descriptor.sort();
834 assert_eq!(contract, descriptor);
835 }
836}