Skip to main content

lean_ctx/core/ocla/
wire_api.rs

1//! Axum REST projection for the public OCLA wire contract.
2
3use axum::{
4    Json, Router,
5    extract::Path,
6    http::StatusCode,
7    routing::{delete, get, post},
8};
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use std::{
12    collections::HashMap,
13    sync::{Mutex, OnceLock},
14};
15
16use super::budget::{BudgetLedger, BudgetLimit, BudgetScope};
17use super::capsule::CapsuleStore;
18use super::health::{SystemHealth, check_system_health};
19use super::{
20    CanonicalTokenEnvelopeV1, OCLA_API_VERSION, OclaCapability, OclaCapabilityKind, OclaRegistry,
21};
22use crate::core::a2a::dlq::{DeadLetter, DlqStats};
23use crate::core::ocla::wire::decode_envelope;
24
25/// Builds the stateless OCLA REST router for merging into an Axum application.
26pub fn ocla_router() -> Router {
27    Router::new()
28        .route("/ocla/v1/health", get(health))
29        .route("/ocla/v1/capabilities", get(capabilities))
30        .route("/ocla/v1/envelope", post(envelope))
31        .route("/ocla/v1/envelope/batch", post(envelope_batch))
32        .route("/ocla/v1/agents", get(agents))
33        .route("/ocla/v1/metrics", get(metrics))
34        .route("/ocla/v1/ledger/summary", get(ledger_summary))
35        .route("/ocla/v1/budget", post(set_budget))
36        .route(
37            "/ocla/v1/budget/{scope}",
38            get(get_budget).delete(delete_budget),
39        )
40        .route("/ocla/v1/dlq", get(dlq))
41        .route("/ocla/v1/dlq/{id}/retry", post(dlq_retry))
42        .route("/ocla/v1/dlq/{id}", delete(dlq_delete))
43        .route("/ocla/v1/capsule", post(capsule_register))
44        .route("/ocla/v1/capsule/{ref}", get(capsule_resolve))
45        .route("/ocla/v1/capsule/{ref}/fork", post(capsule_fork))
46}
47
48#[derive(Default)]
49struct BudgetStore {
50    ledger: BudgetLedger,
51    limits: HashMap<BudgetScope, BudgetLimit>,
52}
53
54static BUDGET_STORE: OnceLock<Mutex<BudgetStore>> = OnceLock::new();
55
56fn budget_store() -> &'static Mutex<BudgetStore> {
57    BUDGET_STORE.get_or_init(|| Mutex::new(BudgetStore::default()))
58}
59
60pub fn admit_budgeted_request(scope: &str, tokens: u64, usd: f64) -> Result<(), String> {
61    let scope = parse_budget_scope(scope)?;
62    let mut store = budget_store()
63        .lock()
64        .unwrap_or_else(std::sync::PoisonError::into_inner);
65    store
66        .ledger
67        .check_budget_with_cost(&scope, tokens, usd)
68        .map_err(|err| err.to_string())?;
69    store.ledger.record_consumption(&scope, tokens, usd);
70    Ok(())
71}
72
73#[cfg(test)]
74pub(crate) fn set_test_budget_limit(limit: BudgetLimit) {
75    let mut store = budget_store()
76        .lock()
77        .unwrap_or_else(std::sync::PoisonError::into_inner);
78    store.ledger = BudgetLedger::new();
79    store.limits.clear();
80    store.ledger.set_limit(limit.clone());
81    store.limits.insert(limit.scope.clone(), limit);
82}
83
84#[derive(Debug, Deserialize)]
85struct SetBudgetRequest {
86    scope: String,
87    max_tokens_per_day: u64,
88    max_usd_per_day: f64,
89}
90
91#[derive(Serialize)]
92struct BudgetResponse {
93    scope: String,
94    max_tokens_per_day: u64,
95    max_usd_per_day: f64,
96    consumed_tokens: u64,
97    consumed_usd: f64,
98}
99
100fn parse_budget_scope(raw: &str) -> Result<BudgetScope, String> {
101    let (kind, name) = raw
102        .split_once(':')
103        .ok_or_else(|| "scope must use org:name, team:name, or user:name".to_string())?;
104    if name.is_empty() || name.contains(':') {
105        return Err("scope name must be non-empty and contain no ':'".to_string());
106    }
107    match kind {
108        "org" => Ok(BudgetScope::Org(name.to_string())),
109        "team" => Ok(BudgetScope::Team(name.to_string())),
110        "user" => Ok(BudgetScope::User(name.to_string())),
111        _ => Err("scope must use org:name, team:name, or user:name".to_string()),
112    }
113}
114
115fn budget_scope_name(scope: &BudgetScope) -> String {
116    match scope {
117        BudgetScope::Org(name) => format!("org:{name}"),
118        BudgetScope::Team(name) => format!("team:{name}"),
119        BudgetScope::User(name) => format!("user:{name}"),
120    }
121}
122
123fn budget_response(
124    scope: &BudgetScope,
125    limit: &BudgetLimit,
126    ledger: &BudgetLedger,
127) -> BudgetResponse {
128    BudgetResponse {
129        scope: budget_scope_name(scope),
130        max_tokens_per_day: limit.max_tokens_per_day,
131        max_usd_per_day: limit.max_usd_per_day,
132        consumed_tokens: ledger.consumed_tokens(scope),
133        consumed_usd: ledger.consumed_usd(scope),
134    }
135}
136
137async fn set_budget(
138    Json(request): Json<SetBudgetRequest>,
139) -> Result<Json<BudgetResponse>, (StatusCode, Json<Value>)> {
140    if !request.max_usd_per_day.is_finite() || request.max_usd_per_day < 0.0 {
141        return Err(invalid_request(
142            "max_usd_per_day must be finite and non-negative",
143        ));
144    }
145    let scope = parse_budget_scope(&request.scope).map_err(invalid_request)?;
146    let limit = BudgetLimit {
147        scope: scope.clone(),
148        max_tokens_per_day: request.max_tokens_per_day,
149        max_usd_per_day: request.max_usd_per_day,
150    };
151    let mut store = budget_store()
152        .lock()
153        .unwrap_or_else(std::sync::PoisonError::into_inner);
154    store.ledger.set_limit(limit.clone());
155    store.limits.insert(scope.clone(), limit.clone());
156    Ok(Json(budget_response(&scope, &limit, &store.ledger)))
157}
158
159async fn get_budget(
160    Path(raw_scope): Path<String>,
161) -> Result<Json<BudgetResponse>, (StatusCode, Json<Value>)> {
162    let scope = parse_budget_scope(&raw_scope).map_err(invalid_request)?;
163    let store = budget_store()
164        .lock()
165        .unwrap_or_else(std::sync::PoisonError::into_inner);
166    let Some(limit) = store.limits.get(&scope) else {
167        return Err((
168            StatusCode::NOT_FOUND,
169            Json(json!({"error": "budget not found"})),
170        ));
171    };
172    Ok(Json(budget_response(&scope, limit, &store.ledger)))
173}
174
175async fn delete_budget(
176    Path(raw_scope): Path<String>,
177) -> Result<StatusCode, (StatusCode, Json<Value>)> {
178    let scope = parse_budget_scope(&raw_scope).map_err(invalid_request)?;
179    let mut store = budget_store()
180        .lock()
181        .unwrap_or_else(std::sync::PoisonError::into_inner);
182    if store.limits.remove(&scope).is_none() {
183        return Err((
184            StatusCode::NOT_FOUND,
185            Json(json!({"error": "budget not found"})),
186        ));
187    }
188    Ok(StatusCode::NO_CONTENT)
189}
190
191async fn health() -> Json<SystemHealth> {
192    Json(check_system_health())
193}
194
195#[derive(Serialize)]
196struct CapabilitiesResponse {
197    version: &'static str,
198    capabilities: Vec<OclaCapability>,
199}
200
201async fn capabilities() -> Json<CapabilitiesResponse> {
202    let registry = OclaRegistry::global();
203    let capabilities = vec![
204        registry.observation_hook.capability(),
205        registry.usage_sink.capability(),
206        registry.metrics_exporter.capability(),
207        registry.savings_ledger.capability(),
208        registry.intent_classifier.capability(),
209        registry.outcome_tracker.capability(),
210        registry.compression_provider.capability(),
211        registry.response_optimizer.capability(),
212        registry.model_router.capability(),
213        registry.efficiency_analyzer.capability(),
214        registry.config_tuner.capability(),
215        registry.experiment_runner.capability(),
216        registry.connector_scheduler.capability(),
217        registry.agent_gateway.capability(),
218    ];
219    debug_assert_eq!(capabilities.len(), OclaCapabilityKind::ALL.len());
220
221    Json(CapabilitiesResponse {
222        version: OCLA_API_VERSION,
223        capabilities,
224    })
225}
226
227async fn envelope(
228    body: String,
229) -> Result<Json<CanonicalTokenEnvelopeV1>, (StatusCode, Json<Value>)> {
230    decode_envelope(&body).map(Json).map_err(invalid_request)
231}
232
233#[derive(Serialize)]
234struct BatchEnvelopeResult {
235    valid: bool,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    envelope: Option<CanonicalTokenEnvelopeV1>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    error: Option<String>,
240}
241
242async fn envelope_batch(Json(envelopes): Json<Vec<Value>>) -> Json<Vec<BatchEnvelopeResult>> {
243    let results = envelopes
244        .into_iter()
245        .map(|envelope| match serde_json::to_string(&envelope) {
246            Ok(json) => match decode_envelope(&json) {
247                Ok(envelope) => BatchEnvelopeResult {
248                    valid: true,
249                    envelope: Some(envelope),
250                    error: None,
251                },
252                Err(error) => BatchEnvelopeResult {
253                    valid: false,
254                    envelope: None,
255                    error: Some(error.to_string()),
256                },
257            },
258            Err(error) => BatchEnvelopeResult {
259                valid: false,
260                envelope: None,
261                error: Some(error.to_string()),
262            },
263        })
264        .collect();
265    Json(results)
266}
267
268#[derive(Serialize)]
269struct AgentsResponse {
270    agents: Vec<Value>,
271}
272
273async fn agents() -> Json<AgentsResponse> {
274    Json(AgentsResponse { agents: Vec::new() })
275}
276
277#[derive(Serialize)]
278struct MetricsResponse {
279    total_events: usize,
280    saved_tokens: u64,
281    saved_usd: f64,
282    trait_adoption_count: usize,
283}
284
285async fn metrics() -> Json<MetricsResponse> {
286    let summary = crate::core::savings_ledger::summary();
287    Json(MetricsResponse {
288        total_events: summary.total_events,
289        saved_tokens: summary.saved_tokens,
290        saved_usd: summary.saved_usd,
291        trait_adoption_count: OclaCapabilityKind::ALL.len(),
292    })
293}
294
295#[derive(Serialize)]
296struct LedgerSummaryResponse {
297    events: usize,
298    tokens: u64,
299    usd: f64,
300}
301
302async fn ledger_summary() -> Json<LedgerSummaryResponse> {
303    let summary = crate::core::savings_ledger::summary();
304    Json(LedgerSummaryResponse {
305        events: summary.total_events,
306        tokens: summary.saved_tokens,
307        usd: summary.saved_usd,
308    })
309}
310
311#[derive(Serialize)]
312struct DlqResponse {
313    dead_letters: Vec<DeadLetter>,
314    stats: DlqStats,
315}
316
317async fn dlq() -> Json<DlqResponse> {
318    let queue = super::health::dead_letter_queue();
319    Json(DlqResponse {
320        dead_letters: queue.peek_all(),
321        stats: queue.stats(),
322    })
323}
324
325async fn dlq_retry(Path(id): Path<String>) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
326    super::health::dead_letter_queue()
327        .retry(&id)
328        .map(|()| Json(json!({"id": id, "retried": true})))
329        .map_err(invalid_request)
330}
331
332async fn dlq_delete(Path(id): Path<String>) -> Result<StatusCode, (StatusCode, Json<Value>)> {
333    if super::health::dead_letter_queue().dequeue(&id).is_some() {
334        Ok(StatusCode::NO_CONTENT)
335    } else {
336        Err((
337            StatusCode::NOT_FOUND,
338            Json(json!({"error": format!("dead letter not found: {id}")})),
339        ))
340    }
341}
342
343fn invalid_request(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
344    (
345        StatusCode::BAD_REQUEST,
346        Json(json!({"error": error.to_string()})),
347    )
348}
349
350static CAPSULE_STORE: OnceLock<CapsuleStore> = OnceLock::new();
351
352fn capsule_store() -> &'static CapsuleStore {
353    CAPSULE_STORE.get_or_init(CapsuleStore::new)
354}
355
356async fn capsule_register(body: String) -> (StatusCode, Json<Value>) {
357    let capsule_ref = capsule_store().register(body.as_bytes());
358    (
359        StatusCode::CREATED,
360        Json(json!({"capsule_ref": capsule_ref})),
361    )
362}
363
364async fn capsule_resolve(Path(capsule_ref): Path<String>) -> (StatusCode, Json<Value>) {
365    match capsule_store().resolve(&capsule_ref) {
366        Ok(data) => {
367            let text = String::from_utf8_lossy(&data);
368            (
369                StatusCode::OK,
370                Json(json!({"capsule_ref": capsule_ref, "data": text})),
371            )
372        }
373        Err(_) => (
374            StatusCode::NOT_FOUND,
375            Json(json!({"error": "capsule not found"})),
376        ),
377    }
378}
379
380#[derive(Deserialize)]
381struct ForkRequest {
382    budget_tokens: u64,
383}
384
385async fn capsule_fork(
386    Path(capsule_ref): Path<String>,
387    Json(req): Json<ForkRequest>,
388) -> (StatusCode, Json<Value>) {
389    match capsule_store().fork(&capsule_ref, req.budget_tokens) {
390        Ok(child_ref) => (StatusCode::CREATED, Json(json!({"capsule_ref": child_ref}))),
391        Err(_) => (
392            StatusCode::NOT_FOUND,
393            Json(json!({"error": "parent capsule not found"})),
394        ),
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::{CanonicalTokenEnvelopeV1, OCLA_API_VERSION, ocla_router};
401    use axum::body::Body;
402    use axum::body::to_bytes;
403    use axum::http::{Request, StatusCode, header};
404    use serde_json::{Value, json};
405    use tower::ServiceExt;
406
407    fn request_context() -> super::super::OclaRequestContext {
408        super::super::OclaRequestContext {
409            request_id: "request-1".into(),
410            session_id: "session-1".into(),
411            agent_id: "agent-1".into(),
412            content_ref: "blake3:content".into(),
413            tenant_id: None,
414            trace_id: "trace-1".into(),
415        }
416    }
417
418    fn valid_envelope() -> CanonicalTokenEnvelopeV1 {
419        CanonicalTokenEnvelopeV1 {
420            schema_version: super::super::CANONICAL_TOKEN_ENVELOPE_SCHEMA_VERSION,
421            context: request_context(),
422            surface: super::super::TokenEnvelopeSurface::Proxy,
423            direction: super::super::TokenFlowDirection::Input,
424            provider: "openai".into(),
425            model: "gpt-5".into(),
426            token_balance: super::super::TokenBalanceV1 {
427                original_tokens: 100,
428                materialized_tokens: 80,
429                delivered_tokens: 60,
430                provider_billed_tokens: 60,
431            },
432            route_ref: Some("route-1".into()),
433            policy_ref: None,
434            idempotency_key: "request-1:input".into(),
435        }
436    }
437
438    async fn json_response(response: axum::response::Response) -> Value {
439        let body = to_bytes(response.into_body(), 1_000_000)
440            .await
441            .expect("response body");
442        serde_json::from_slice(&body).expect("JSON response")
443    }
444
445    fn budget_request(method: &str, uri: &str, body: Option<Value>) -> Request<Body> {
446        Request::builder()
447            .method(method)
448            .uri(uri)
449            .header(header::CONTENT_TYPE, "application/json")
450            .body(body.map_or_else(Body::empty, |body| Body::from(body.to_string())))
451            .expect("request")
452    }
453
454    async fn set_budget_for_test(scope: &str, tokens: u64, usd: f64) {
455        ocla_router()
456            .oneshot(budget_request(
457                "POST",
458                "/ocla/v1/budget",
459                Some(json!({"scope": scope, "max_tokens_per_day": tokens, "max_usd_per_day": usd})),
460            ))
461            .await
462            .expect("response");
463    }
464
465    #[tokio::test]
466    async fn health_endpoint_returns_full_report() {
467        let response = ocla_router()
468            .oneshot(
469                Request::builder()
470                    .method("GET")
471                    .uri("/ocla/v1/health")
472                    .body(Body::empty())
473                    .expect("request"),
474            )
475            .await
476            .expect("response");
477
478        assert_eq!(response.status(), StatusCode::OK);
479        let body = json_response(response).await;
480        assert_eq!(body["version"], OCLA_API_VERSION);
481        assert_eq!(body["components"].as_array().expect("components").len(), 21);
482        assert!(body.get("overall").is_some());
483        assert!(body.get("uptime_seconds").is_some());
484    }
485
486    #[tokio::test]
487    async fn capabilities_endpoint_lists_all_fourteen_statuses() {
488        let response = ocla_router()
489            .oneshot(
490                Request::builder()
491                    .method("GET")
492                    .uri("/ocla/v1/capabilities")
493                    .body(Body::empty())
494                    .expect("request"),
495            )
496            .await
497            .expect("response");
498
499        assert_eq!(response.status(), StatusCode::OK);
500        let body = json_response(response).await;
501        assert_eq!(body["version"], OCLA_API_VERSION);
502        assert_eq!(body["capabilities"].as_array().expect("list").len(), 14);
503        assert!(
504            body["capabilities"]
505                .as_array()
506                .expect("list")
507                .iter()
508                .all(|capability| capability["status"] == "available")
509        );
510    }
511
512    #[tokio::test]
513    async fn envelope_endpoint_decodes_valid_json_and_rejects_invalid_json() {
514        let wire = serde_json::to_string(&valid_envelope()).expect("envelope JSON");
515        let response = ocla_router()
516            .clone()
517            .oneshot(
518                Request::builder()
519                    .method("POST")
520                    .uri("/ocla/v1/envelope")
521                    .header(header::CONTENT_TYPE, "application/json")
522                    .body(Body::from(wire))
523                    .expect("request"),
524            )
525            .await
526            .expect("response");
527
528        assert_eq!(response.status(), StatusCode::OK);
529        assert_eq!(json_response(response).await, json!(valid_envelope()));
530
531        let response = ocla_router()
532            .oneshot(
533                Request::builder()
534                    .method("POST")
535                    .uri("/ocla/v1/envelope")
536                    .header(header::CONTENT_TYPE, "application/json")
537                    .body(Body::from(r#"{"schema_version":99}"#))
538                    .expect("request"),
539            )
540            .await
541            .expect("response");
542        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
543    }
544
545    #[tokio::test]
546    async fn ledger_summary_endpoint_returns_events_tokens_and_usd() {
547        let response = ocla_router()
548            .oneshot(
549                Request::builder()
550                    .method("GET")
551                    .uri("/ocla/v1/ledger/summary")
552                    .body(Body::empty())
553                    .expect("request"),
554            )
555            .await
556            .expect("response");
557
558        assert_eq!(response.status(), StatusCode::OK);
559        let body = json_response(response).await;
560        assert!(body.get("events").is_some());
561        assert!(body.get("tokens").is_some());
562        assert!(body.get("usd").is_some());
563    }
564
565    #[tokio::test]
566    async fn agents_endpoint_returns_registered_agents_schema() {
567        let response = ocla_router()
568            .oneshot(
569                Request::builder()
570                    .method("GET")
571                    .uri("/ocla/v1/agents")
572                    .body(Body::empty())
573                    .expect("request"),
574            )
575            .await
576            .expect("response");
577
578        assert_eq!(response.status(), StatusCode::OK);
579        assert_eq!(json_response(response).await, json!({"agents": []}));
580    }
581
582    #[tokio::test]
583    async fn metrics_endpoint_returns_key_ocla_metrics() {
584        let response = ocla_router()
585            .oneshot(
586                Request::builder()
587                    .method("GET")
588                    .uri("/ocla/v1/metrics")
589                    .body(Body::empty())
590                    .expect("request"),
591            )
592            .await
593            .expect("response");
594
595        assert_eq!(response.status(), StatusCode::OK);
596        let body = json_response(response).await;
597        assert!(body.get("total_events").is_some());
598        assert!(body.get("saved_tokens").is_some());
599        assert!(body.get("saved_usd").is_some());
600        assert_eq!(body["trait_adoption_count"], 14);
601    }
602
603    #[tokio::test]
604    async fn envelope_batch_endpoint_reports_valid_and_invalid_items() {
605        let body = json!([valid_envelope(), {"schema_version": 99}]);
606        let response = ocla_router()
607            .oneshot(
608                Request::builder()
609                    .method("POST")
610                    .uri("/ocla/v1/envelope/batch")
611                    .header(header::CONTENT_TYPE, "application/json")
612                    .body(Body::from(body.to_string()))
613                    .expect("request"),
614            )
615            .await
616            .expect("response");
617
618        assert_eq!(response.status(), StatusCode::OK);
619        let results = json_response(response).await;
620        assert_eq!(results.as_array().expect("results").len(), 2);
621        assert_eq!(results[0]["valid"], true);
622        assert_eq!(results[0]["envelope"], json!(valid_envelope()));
623        assert_eq!(results[1]["valid"], false);
624        assert!(results[1].get("error").is_some());
625    }
626
627    #[tokio::test]
628    async fn budget_post_endpoint_sets_and_returns_limit() {
629        let response = ocla_router()
630            .oneshot(budget_request(
631                "POST",
632                "/ocla/v1/budget",
633                Some(json!({
634                    "scope": "org:wire-api-set",
635                    "max_tokens_per_day": 100_000,
636                    "max_usd_per_day": 50.0,
637                })),
638            ))
639            .await
640            .expect("response");
641
642        assert_eq!(response.status(), StatusCode::OK);
643        let body = json_response(response).await;
644        assert_eq!(body["max_tokens_per_day"], 100_000);
645    }
646
647    #[tokio::test]
648    async fn dlq_endpoint_returns_entries_and_stats() {
649        let response = ocla_router()
650            .oneshot(
651                Request::builder()
652                    .method("GET")
653                    .uri("/ocla/v1/dlq")
654                    .body(Body::empty())
655                    .expect("request"),
656            )
657            .await
658            .expect("response");
659
660        assert_eq!(response.status(), StatusCode::OK);
661        let body = json_response(response).await;
662        assert!(body.get("dead_letters").is_some());
663        assert!(body.get("stats").is_some());
664    }
665    #[tokio::test]
666    async fn budget_get_endpoint_returns_configured_limit_and_consumption() {
667        set_budget_for_test("team:wire-api-get", 500, 5.0).await;
668
669        let response = ocla_router()
670            .oneshot(budget_request(
671                "GET",
672                "/ocla/v1/budget/team:wire-api-get",
673                None,
674            ))
675            .await
676            .expect("response");
677
678        assert_eq!(response.status(), StatusCode::OK);
679        let body = json_response(response).await;
680        assert_eq!(body["max_tokens_per_day"], 500);
681        assert_eq!(body["max_usd_per_day"], 5.0);
682    }
683
684    #[tokio::test]
685    async fn budget_delete_endpoint_removes_limit() {
686        set_budget_for_test("user:wire-api-delete", 25, 1.0).await;
687
688        let response = ocla_router()
689            .oneshot(budget_request(
690                "DELETE",
691                "/ocla/v1/budget/user:wire-api-delete",
692                None,
693            ))
694            .await
695            .expect("response");
696        assert_eq!(response.status(), StatusCode::NO_CONTENT);
697
698        let response = ocla_router()
699            .oneshot(budget_request(
700                "GET",
701                "/ocla/v1/budget/user:wire-api-delete",
702                None,
703            ))
704            .await
705            .expect("response");
706        assert_eq!(response.status(), StatusCode::NOT_FOUND);
707    }
708
709    #[tokio::test]
710    async fn dlq_retry_and_delete_return_not_found_for_missing_id() {
711        let retry = ocla_router()
712            .clone()
713            .oneshot(
714                Request::builder()
715                    .method("POST")
716                    .uri("/ocla/v1/dlq/missing/retry")
717                    .body(Body::empty())
718                    .expect("request"),
719            )
720            .await
721            .expect("response");
722        assert_eq!(retry.status(), StatusCode::BAD_REQUEST);
723
724        let delete = ocla_router()
725            .oneshot(
726                Request::builder()
727                    .method("DELETE")
728                    .uri("/ocla/v1/dlq/missing")
729                    .body(Body::empty())
730                    .expect("request"),
731            )
732            .await
733            .expect("response");
734        assert_eq!(delete.status(), StatusCode::NOT_FOUND);
735    }
736}