1use bytes::Bytes;
4
5use crate::{
6 client::{
7 KrabkaClient, MockMessage, MockQueueDelivery, MockQueueDisposition, MockQueueMessageState,
8 MockQueueSession,
9 },
10 error::KrabkaError,
11 pb,
12};
13
14const SUPPORTED_QUEUE_LOCK_DURATION_MS: u64 = 30_000;
15
16#[derive(Debug, Clone)]
18pub struct QueuesClient {
19 client: KrabkaClient,
20}
21
22impl QueuesClient {
23 pub(crate) fn new(client: KrabkaClient) -> Self {
24 Self { client }
25 }
26
27 pub async fn acquire(
34 &self,
35 topic: impl Into<String>,
36 group: impl Into<String>,
37 options: AcquireOptions,
38 ) -> Result<QueueAcquireResult, KrabkaError> {
39 self.acquire_with_session(topic, group, options, "").await
40 }
41
42 pub async fn acquire_with_session(
49 &self,
50 topic: impl Into<String>,
51 group: impl Into<String>,
52 options: AcquireOptions,
53 session_id: impl Into<String>,
54 ) -> Result<QueueAcquireResult, KrabkaError> {
55 let topic = topic.into();
56 let group = group.into();
57 if group.is_empty() {
58 return Err(KrabkaError::InvalidArgument(
59 "queue group is required".into(),
60 ));
61 }
62 if topic.is_empty() {
63 return Err(KrabkaError::InvalidArgument(
64 "queue topic is required".into(),
65 ));
66 }
67 if options.lock_duration_ms != SUPPORTED_QUEUE_LOCK_DURATION_MS {
68 return Err(KrabkaError::InvalidArgument(format!(
69 "queue lock_duration_ms must be {SUPPORTED_QUEUE_LOCK_DURATION_MS}; per-acquire lock durations are not supported"
70 )));
71 }
72 let session_id = session_id.into();
73
74 if self.client.is_mock() {
75 return self.acquire_mock(&topic, &group, options, &session_id);
76 }
77 if self.client.is_unreachable() {
78 return Err(KrabkaError::Transport("endpoint unreachable".into()));
79 }
80
81 let response: pb::QueueAcquireResponse = self
82 .client
83 .inner
84 .connect
85 .unary(
86 "/krabka.gateway.v1.Gateway/QueueAcquire",
87 &pb::QueueAcquireRequest {
88 group_id: group,
89 topics: vec![topic],
90 max_messages: options.max,
91 wait_ms: options.wait_ms,
92 session_id,
93 lock_duration_ms: options.lock_duration_ms,
94 },
95 )
96 .await?;
97 Ok(QueueAcquireResult {
98 session_id: response.session_id,
99 messages: response
100 .messages
101 .into_iter()
102 .map(QueueMessage::from)
103 .collect(),
104 })
105 }
106
107 pub async fn acknowledge(
114 &self,
115 session_id: impl Into<String>,
116 entries: Vec<QueueAckEntry>,
117 ) -> Result<Vec<QueueAckResult>, KrabkaError> {
118 let session_id = session_id.into();
119 if self.client.is_mock() {
120 return self.acknowledge_mock(&session_id, entries);
121 }
122 if self.client.is_unreachable() {
123 return Err(KrabkaError::Transport("endpoint unreachable".into()));
124 }
125
126 let response: pb::QueueAcknowledgeResponse = self
127 .client
128 .inner
129 .connect
130 .unary(
131 "/krabka.gateway.v1.Gateway/QueueAcknowledge",
132 &pb::QueueAcknowledgeRequest {
133 session_id,
134 entries: entries.into_iter().map(pb::QueueAckEntry::from).collect(),
135 },
136 )
137 .await?;
138 Ok(response
139 .results
140 .into_iter()
141 .map(QueueAckResult::from)
142 .collect())
143 }
144
145 pub async fn renew(
152 &self,
153 session_id: impl Into<String>,
154 entries: Vec<QueueRenewEntry>,
155 ) -> Result<Vec<QueueAckResult>, KrabkaError> {
156 let session_id = session_id.into();
157 if self.client.is_mock() {
158 return self.renew_mock(&session_id, entries);
159 }
160 if self.client.is_unreachable() {
161 return Err(KrabkaError::Transport("endpoint unreachable".into()));
162 }
163
164 let response: pb::QueueRenewResponse = self
165 .client
166 .inner
167 .connect
168 .unary(
169 "/krabka.gateway.v1.Gateway/QueueRenew",
170 &pb::QueueRenewRequest {
171 session_id,
172 entries: entries.into_iter().map(pb::QueueAckEntry::from).collect(),
173 },
174 )
175 .await?;
176 Ok(response
177 .results
178 .into_iter()
179 .map(QueueAckResult::from)
180 .collect())
181 }
182
183 fn acquire_mock(
184 &self,
185 topic: &str,
186 group: &str,
187 options: AcquireOptions,
188 requested_session_id: &str,
189 ) -> Result<QueueAcquireResult, KrabkaError> {
190 let mut state = self
191 .client
192 .inner
193 .mock
194 .lock()
195 .expect("mock state lock not poisoned");
196 let effective_max = options.max.clamp(1, 500);
197 let session_id = mock_session_id(
198 &mut state.queue_sessions,
199 requested_session_id,
200 topic,
201 group,
202 options.max,
203 effective_max,
204 )?;
205 let delivered = state
206 .messages
207 .iter()
208 .filter(|message| message.topic == topic)
209 .filter(|message| mock_message_is_available(&state.queue_states, group, message))
210 .take(usize::try_from(effective_max).unwrap_or(usize::MAX))
211 .cloned()
212 .collect::<Vec<_>>();
213
214 let messages = delivered
215 .into_iter()
216 .map(|message| {
217 let delivery = MockQueueDelivery::from(&message);
218 let queue_state = mock_queue_state_mut(&mut state.queue_states, group, delivery);
219 queue_state.disposition = MockQueueDisposition::Acquired(session_id.clone());
220 queue_state.delivery_count = queue_state.delivery_count.saturating_add(1);
221 queue_message_from_mock(message, queue_state.delivery_count)
222 })
223 .collect();
224
225 Ok(QueueAcquireResult {
226 session_id,
227 messages,
228 })
229 }
230
231 fn acknowledge_mock(
232 &self,
233 session_id: &str,
234 entries: Vec<QueueAckEntry>,
235 ) -> Result<Vec<QueueAckResult>, KrabkaError> {
236 let mut state = self
237 .client
238 .inner
239 .mock
240 .lock()
241 .expect("mock state lock not poisoned");
242 let Some(session) = state
243 .queue_sessions
244 .iter()
245 .find(|session| session.id == session_id)
246 .cloned()
247 else {
248 return Err(KrabkaError::InvalidArgument(
249 "queue session expired; re-acquire".into(),
250 ));
251 };
252
253 let mut results = Vec::with_capacity(entries.len());
254 for entry in entries {
255 let delivery = MockQueueDelivery::from(&entry);
256 let Some(queue_state) = state.queue_states.iter_mut().find(|queue_state| {
257 queue_state.group == session.group
258 && queue_state.delivery == delivery
259 && matches!(
260 &queue_state.disposition,
261 MockQueueDisposition::Acquired(owner) if owner == session_id
262 )
263 }) else {
264 results.push(queue_result_error(
265 entry.into(),
266 "queue message is not acquired",
267 ));
268 continue;
269 };
270
271 queue_state.disposition = match entry.ack_type {
272 QueueAckType::Accept => MockQueueDisposition::Accepted,
273 QueueAckType::Release => MockQueueDisposition::Available,
274 QueueAckType::Reject => MockQueueDisposition::Rejected,
275 };
276 results.push(QueueAckResult {
277 entry: entry.into(),
278 error: None,
279 });
280 }
281 Ok(results)
282 }
283
284 fn renew_mock(
285 &self,
286 session_id: &str,
287 entries: Vec<QueueRenewEntry>,
288 ) -> Result<Vec<QueueAckResult>, KrabkaError> {
289 let state = self
290 .client
291 .inner
292 .mock
293 .lock()
294 .expect("mock state lock not poisoned");
295 let Some(session) = state
296 .queue_sessions
297 .iter()
298 .find(|session| session.id == session_id)
299 else {
300 return Err(KrabkaError::InvalidArgument(
301 "queue session expired; re-acquire".into(),
302 ));
303 };
304
305 Ok(entries
306 .into_iter()
307 .map(|entry| {
308 let delivery = MockQueueDelivery::from(&entry);
309 if state.queue_states.iter().any(|queue_state| {
310 queue_state.group == session.group
311 && queue_state.delivery == delivery
312 && matches!(
313 &queue_state.disposition,
314 MockQueueDisposition::Acquired(owner) if owner == session_id
315 )
316 }) {
317 return QueueAckResult { entry, error: None };
318 }
319 queue_result_error(entry, "queue message is not acquired")
320 })
321 .collect())
322 }
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub struct AcquireOptions {
328 pub max: u32,
330 pub lock_duration_ms: u64,
332 pub wait_ms: u32,
334}
335
336impl Default for AcquireOptions {
337 fn default() -> Self {
338 Self {
339 max: 1,
340 lock_duration_ms: 30_000,
341 wait_ms: 1_000,
342 }
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct QueueAcquireResult {
349 pub session_id: String,
351 pub messages: Vec<QueueMessage>,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct QueueMessage {
358 pub topic: String,
360 pub partition: i32,
362 pub offset: i64,
364 pub value: Option<Bytes>,
366 pub headers: Vec<(String, Option<Bytes>)>,
368 pub delivery_count: i32,
370}
371
372impl QueueMessage {
373 #[must_use]
375 pub fn message_id(&self) -> String {
376 format!("{}:{}:{}", self.topic, self.partition, self.offset)
377 }
378}
379
380impl From<pb::QueuedMessage> for QueueMessage {
381 fn from(value: pb::QueuedMessage) -> Self {
382 Self {
383 topic: value.topic,
384 partition: value.partition,
385 offset: value.offset,
386 value: value.value.map(Bytes::from),
387 headers: value
388 .headers
389 .into_iter()
390 .map(|header| (header.key, header.value.map(Bytes::from)))
391 .collect(),
392 delivery_count: value.delivery_count,
393 }
394 }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum QueueAckType {
400 Accept,
402 Release,
404 Reject,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct QueueAckEntry {
411 pub topic: String,
413 pub partition: i32,
415 pub offset: i64,
417 pub ack_type: QueueAckType,
419}
420
421impl From<QueueAckEntry> for pb::QueueAckEntry {
422 fn from(value: QueueAckEntry) -> Self {
423 Self {
424 topic: value.topic,
425 partition: value.partition,
426 offset: value.offset,
427 r#type: match value.ack_type {
428 QueueAckType::Accept => pb::QueueAckType::Accept,
429 QueueAckType::Release => pb::QueueAckType::Release,
430 QueueAckType::Reject => pb::QueueAckType::Reject,
431 } as i32,
432 }
433 }
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
438pub struct QueueRenewEntry {
439 pub topic: String,
441 pub partition: i32,
443 pub offset: i64,
445}
446
447impl From<QueueRenewEntry> for pb::QueueAckEntry {
448 fn from(value: QueueRenewEntry) -> Self {
449 Self {
450 topic: value.topic,
451 partition: value.partition,
452 offset: value.offset,
453 r#type: pb::QueueAckType::Unspecified as i32,
454 }
455 }
456}
457
458impl From<&QueueAckEntry> for MockQueueDelivery {
459 fn from(value: &QueueAckEntry) -> Self {
460 Self {
461 topic: value.topic.clone(),
462 partition: value.partition,
463 offset: value.offset,
464 }
465 }
466}
467
468impl From<&QueueRenewEntry> for MockQueueDelivery {
469 fn from(value: &QueueRenewEntry) -> Self {
470 Self {
471 topic: value.topic.clone(),
472 partition: value.partition,
473 offset: value.offset,
474 }
475 }
476}
477
478impl From<&MockMessage> for MockQueueDelivery {
479 fn from(value: &MockMessage) -> Self {
480 Self {
481 topic: value.topic.clone(),
482 partition: value.partition,
483 offset: value.offset,
484 }
485 }
486}
487
488impl From<QueueAckEntry> for QueueRenewEntry {
489 fn from(value: QueueAckEntry) -> Self {
490 Self {
491 topic: value.topic,
492 partition: value.partition,
493 offset: value.offset,
494 }
495 }
496}
497
498#[derive(Debug, PartialEq, Eq)]
500pub struct QueueAckResult {
501 pub entry: QueueRenewEntry,
503 pub error: Option<KrabkaError>,
505}
506
507impl From<pb::QueueAckResult> for QueueAckResult {
508 fn from(value: pb::QueueAckResult) -> Self {
509 let entry = value.entry.unwrap_or_default();
510 Self {
511 entry: QueueRenewEntry {
512 topic: entry.topic,
513 partition: entry.partition,
514 offset: entry.offset,
515 },
516 error: value.error.map(error_info_to_krabka),
517 }
518 }
519}
520
521fn error_info_to_krabka(error: pb::ErrorInfo) -> KrabkaError {
522 let message = error.message;
523 if error.retriable {
524 return KrabkaError::Transport(message);
525 }
526 match error.code {
527 3 | 9 => KrabkaError::InvalidArgument(message),
528 16 => KrabkaError::Unauthenticated(message),
529 5 => KrabkaError::NotFound(message),
530 _ => KrabkaError::ServerError(message),
531 }
532}
533
534fn mock_session_id(
535 sessions: &mut Vec<MockQueueSession>,
536 requested_session_id: &str,
537 topic: &str,
538 group: &str,
539 requested_max: u32,
540 effective_max: u32,
541) -> Result<String, KrabkaError> {
542 if !requested_session_id.is_empty() {
543 let Some(session) = sessions
544 .iter()
545 .find(|session| session.id == requested_session_id)
546 else {
547 return Err(KrabkaError::InvalidArgument(
548 "queue session expired; re-acquire".into(),
549 ));
550 };
551 if session.topic != topic || session.group != group {
552 return Err(KrabkaError::InvalidArgument(
553 "group_id and topics are fixed when a queue session is created".into(),
554 ));
555 }
556 if requested_max != 0 && effective_max != session.max_messages {
557 return Err(KrabkaError::InvalidArgument(
558 "max_messages is fixed when a queue session is created".into(),
559 ));
560 }
561 return Ok(requested_session_id.to_string());
562 }
563
564 let session_id = format!("mock-queue-session-{}", sessions.len() + 1);
565 sessions.push(MockQueueSession {
566 id: session_id.clone(),
567 topic: topic.to_string(),
568 group: group.to_string(),
569 max_messages: effective_max,
570 });
571 Ok(session_id)
572}
573
574fn mock_message_is_available(
575 queue_states: &[MockQueueMessageState],
576 group: &str,
577 message: &MockMessage,
578) -> bool {
579 let delivery = MockQueueDelivery::from(message);
580 queue_states
581 .iter()
582 .find(|queue_state| queue_state.group == group && queue_state.delivery == delivery)
583 .is_none_or(|queue_state| queue_state.disposition == MockQueueDisposition::Available)
584}
585
586fn mock_queue_state_mut<'a>(
587 queue_states: &'a mut Vec<MockQueueMessageState>,
588 group: &str,
589 delivery: MockQueueDelivery,
590) -> &'a mut MockQueueMessageState {
591 if let Some(index) = queue_states
592 .iter()
593 .position(|queue_state| queue_state.group == group && queue_state.delivery == delivery)
594 {
595 return &mut queue_states[index];
596 }
597 queue_states.push(MockQueueMessageState {
598 group: group.to_string(),
599 delivery,
600 disposition: MockQueueDisposition::Available,
601 delivery_count: 0,
602 });
603 queue_states
604 .last_mut()
605 .expect("queue state was just inserted")
606}
607
608fn queue_message_from_mock(message: MockMessage, delivery_count: i32) -> QueueMessage {
609 QueueMessage {
610 topic: message.topic,
611 partition: message.partition,
612 offset: message.offset,
613 value: Some(message.value),
614 headers: message.headers,
615 delivery_count,
616 }
617}
618
619fn queue_result_error(entry: QueueRenewEntry, message: impl Into<String>) -> QueueAckResult {
620 QueueAckResult {
621 entry,
622 error: Some(KrabkaError::InvalidArgument(message.into())),
623 }
624}
625
626#[derive(Debug, Clone, Copy, Default)]
628pub struct DatabaseClient;
629
630impl DatabaseClient {
631 pub(crate) fn new() -> Self {
632 Self
633 }
634
635 pub fn connect(&self, _name: &str) -> Result<(), KrabkaError> {
642 Err(KrabkaError::Unimplemented {
643 module: "database",
644 gated_on: "chapter-f-control-plane",
645 })
646 }
647}
648
649#[derive(Debug, Clone, Default)]
651pub struct AuthClient {
652 bearer: Option<String>,
653}
654
655impl AuthClient {
656 pub(crate) fn new(bearer: Option<String>) -> Self {
657 Self { bearer }
658 }
659
660 #[must_use]
662 pub fn bearer_token(&self) -> Option<&str> {
663 self.bearer.as_deref()
664 }
665
666 pub fn sign_in(&self, _username: &str, _password: &str) -> Result<(), KrabkaError> {
673 Err(KrabkaError::Unauthenticated(
674 "identity APIs are not part of contract v1".into(),
675 ))
676 }
677}
678
679#[derive(Debug, Clone, Copy, Default)]
681pub struct BlobClient;
682
683impl BlobClient {
684 pub(crate) fn new() -> Self {
685 Self
686 }
687
688 pub fn put(&self, _key: &str, _value: Bytes) -> Result<(), KrabkaError> {
695 Err(KrabkaError::Unimplemented {
696 module: "blob",
697 gated_on: "chapter-b-blob-api",
698 })
699 }
700
701 pub fn get(&self, _key: &str) -> Result<Bytes, KrabkaError> {
708 Err(KrabkaError::Unimplemented {
709 module: "blob",
710 gated_on: "chapter-b-blob-api",
711 })
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use crate::KrabkaClient;
719
720 #[tokio::test]
721 async fn mock_queues_acquire_published_records() {
722 let client = KrabkaClient::new("mock://gateway").unwrap();
723 client
724 .messaging()
725 .publish(
726 "work",
727 Bytes::from_static(b"job"),
728 crate::PublishOptions::default(),
729 )
730 .await
731 .expect("publish to mock");
732
733 let acquired = client
734 .queues()
735 .acquire("work", "workers", AcquireOptions::default())
736 .await
737 .expect("acquire from mock");
738
739 assert_eq!(acquired.session_id, "mock-queue-session-1");
740 assert_eq!(acquired.messages.len(), 1);
741 assert_eq!(acquired.messages[0].value, Some(Bytes::from_static(b"job")));
742 }
743
744 #[test]
745 fn queue_messages_distinguish_tombstones_from_empty_values() {
746 let message = |value| {
747 QueueMessage::from(pb::QueuedMessage {
748 topic: "work".into(),
749 value,
750 ..pb::QueuedMessage::default()
751 })
752 };
753
754 assert2::assert!(message(None).value == None);
755 assert2::assert!(message(Some(Vec::new())).value == Some(Bytes::new()));
756 }
757
758 #[tokio::test]
759 async fn mock_queue_sessions_own_delivered_coordinates() {
760 let client = KrabkaClient::new("mock://gateway").unwrap();
761 client
762 .messaging()
763 .publish(
764 "work",
765 Bytes::from_static(b"job"),
766 crate::PublishOptions::default(),
767 )
768 .await
769 .expect("publish to mock");
770 let first = client
771 .queues()
772 .acquire("work", "workers", AcquireOptions::default())
773 .await
774 .expect("first session");
775 let second = client
776 .queues()
777 .acquire("work", "workers", AcquireOptions::default())
778 .await
779 .expect("second session");
780 let entry = QueueAckEntry {
781 topic: "work".into(),
782 partition: 0,
783 offset: 0,
784 ack_type: QueueAckType::Accept,
785 };
786
787 let wrong_session = client
788 .queues()
789 .acknowledge(&second.session_id, vec![entry.clone()])
790 .await
791 .expect("wrong-session acknowledgement returns per-entry result");
792 assert2::assert!(
793 wrong_session
794 == vec![queue_result_error(
795 QueueRenewEntry::from(entry.clone()),
796 "queue message is not acquired"
797 )]
798 );
799 let renewed = client
800 .queues()
801 .renew(
802 &second.session_id,
803 vec![QueueRenewEntry::from(entry.clone())],
804 )
805 .await
806 .expect("wrong-session renew returns per-entry result");
807 assert2::assert!(
808 renewed
809 == vec![queue_result_error(
810 QueueRenewEntry::from(entry),
811 "queue message is not acquired"
812 )]
813 );
814
815 let missing = client
816 .queues()
817 .acquire_with_session(
818 "work",
819 "workers",
820 AcquireOptions::default(),
821 "missing-session",
822 )
823 .await;
824 assert2::assert!(
825 missing
826 == Err(KrabkaError::InvalidArgument(
827 "queue session expired; re-acquire".into()
828 ))
829 );
830 let changed = client
831 .queues()
832 .acquire_with_session(
833 "work",
834 "other-workers",
835 AcquireOptions::default(),
836 first.session_id,
837 )
838 .await;
839 assert2::assert!(
840 changed
841 == Err(KrabkaError::InvalidArgument(
842 "group_id and topics are fixed when a queue session is created".into()
843 ))
844 );
845 }
846
847 #[tokio::test]
848 async fn mock_queue_delivery_state_is_independent_per_group() {
849 let client = KrabkaClient::new("mock://gateway").unwrap();
850 client
851 .messaging()
852 .publish(
853 "work",
854 Bytes::from_static(b"job"),
855 crate::PublishOptions::default(),
856 )
857 .await
858 .expect("publish to mock");
859
860 let first = client
861 .queues()
862 .acquire("work", "workers-a", AcquireOptions::default())
863 .await
864 .expect("group A acquire");
865 let second = client
866 .queues()
867 .acquire("work", "workers-b", AcquireOptions::default())
868 .await
869 .expect("group B acquire");
870
871 assert2::assert!(first.messages.len() == 1);
872 assert2::assert!(second.messages.len() == 1);
873 assert2::assert!(first.messages[0].delivery_count == 1);
874 assert2::assert!(second.messages[0].delivery_count == 1);
875
876 let coordinate = QueueAckEntry {
877 topic: "work".into(),
878 partition: 0,
879 offset: 0,
880 ack_type: QueueAckType::Accept,
881 };
882 let accepted = client
883 .queues()
884 .acknowledge(&second.session_id, vec![coordinate.clone()])
885 .await
886 .expect("group B acknowledge");
887 assert2::assert!(accepted[0].error.is_none());
888
889 let renewed = client
890 .queues()
891 .renew(
892 &first.session_id,
893 vec![QueueRenewEntry::from(coordinate.clone())],
894 )
895 .await
896 .expect("group A renew");
897 assert2::assert!(renewed[0].error.is_none());
898
899 let accepted = client
900 .queues()
901 .acknowledge(&first.session_id, vec![coordinate])
902 .await
903 .expect("group A acknowledge");
904 assert2::assert!(accepted[0].error.is_none());
905 }
906
907 #[test]
908 fn retriable_queue_error_overrides_gateway_code_and_preserves_message() {
909 assert2::assert!(
910 error_info_to_krabka(pb::ErrorInfo {
911 code: 9,
912 message: "coordinator retry".into(),
913 retriable: true,
914 }) == KrabkaError::Transport("coordinator retry".into())
915 );
916 assert2::assert!(
917 error_info_to_krabka(pb::ErrorInfo {
918 code: 9,
919 message: "record is not acquired by this session".into(),
920 retriable: false,
921 }) == KrabkaError::InvalidArgument("record is not acquired by this session".into())
922 );
923 }
924
925 #[tokio::test]
926 async fn stub_modules_return_gated_unimplemented() {
927 let client = KrabkaClient::new("mock://gateway").unwrap();
928 assert_eq!(
929 client.database().connect("orders"),
930 Err(KrabkaError::Unimplemented {
931 module: "database",
932 gated_on: "chapter-f-control-plane"
933 })
934 );
935 assert_eq!(
936 client.blob().put("k", Bytes::from_static(b"v")),
937 Err(KrabkaError::Unimplemented {
938 module: "blob",
939 gated_on: "chapter-b-blob-api"
940 })
941 );
942 }
943}