1use serde_json::{Value, json};
4
5use super::{
6 OCLA_API_VERSION,
7 wire::{agent_envelope_schema, canonical_envelope_schema},
8};
9
10fn schema_ref(name: &str) -> Value {
11 json!({"$ref": format!("#/components/schemas/{name}")})
12}
13
14fn error_response(description: &str) -> Value {
15 json!({
16 "description": description,
17 "content": {
18 "application/json": {"schema": schema_ref("OclaError")}
19 }
20 })
21}
22
23fn capability_schema() -> Value {
24 json!({
25 "type": "object",
26 "required": ["kind", "api_version", "status", "limits"],
27 "properties": {
28 "kind": {
29 "type": "string",
30 "enum": [
31 "observation_hook", "usage_sink", "metrics_exporter",
32 "savings_ledger", "intent_classifier", "outcome_tracker",
33 "compression_provider", "response_optimizer", "model_router",
34 "efficiency_analyzer", "config_tuner", "experiment_runner",
35 "connector_scheduler", "agent_gateway"
36 ]
37 },
38 "api_version": {"type": "string"},
39 "status": {
40 "type": "string",
41 "enum": ["available", "degraded", "unavailable"]
42 },
43 "limits": {
44 "type": "object",
45 "additionalProperties": {"type": "integer", "minimum": 0}
46 }
47 }
48 })
49}
50
51fn savings_event_schema() -> Value {
52 let required = json!([
53 "ts",
54 "tool",
55 "mechanism",
56 "model_id",
57 "tokenizer",
58 "baseline_tokens",
59 "actual_tokens",
60 "saved_tokens",
61 "bounce_adjustment",
62 "unit_price_per_m_usd",
63 "saved_usd",
64 "repo_hash",
65 "agent_id",
66 "prev_hash",
67 "entry_hash",
68 "version"
69 ]);
70 let mut properties = serde_json::Map::new();
71 for fields in [
72 json!({
73 "ts": {"type": "string"},
74 "tool": {"type": "string"},
75 "mechanism": {"type": "string", "enum": ["compression", "routing", "caching"]},
76 "model_id": {"type": "string"},
77 "tokenizer": {"type": "string"},
78 "baseline_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
79 "actual_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
80 "saved_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
81 "bounce_adjustment": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
82 "unit_price_per_m_usd": {"type": "number"},
83 "saved_usd": {"type": "number"},
84 "repo_hash": {"type": "string"},
85 "agent_id": {"type": "string"},
86 "trace_id": {"type": ["string", "null"]},
87 "prev_hash": {"type": "string"},
88 "entry_hash": {"type": "string"},
89 "version": {"type": "string"},
90 }),
91 json!({
92 "intent_tag": {"type": ["string", "null"]},
93 "outcome": {"type": ["string", "null"]},
94 "model_original": {"type": ["string", "null"]},
95 "model_routed": {"type": ["string", "null"]},
96 "routing_savings": {"type": ["integer", "null"], "minimum": 0, "maximum": u64::MAX},
97 "response_original_tokens": {"type": ["integer", "null"], "minimum": 0, "maximum": u64::MAX},
98 "response_delivered_tokens": {"type": ["integer", "null"], "minimum": 0, "maximum": u64::MAX},
99 "agent_chain_id": {"type": ["string", "null"]},
100 "chain_depth": {"type": ["integer", "null"], "minimum": 0, "maximum": u8::MAX},
101 "measurement_method": {
102 "type": ["string", "null"],
103 "enum": ["direct_count", "holdout", "baseline_estimate", "provider_reconciled", "unknown", null]
104 },
105 "evidence_class": {
106 "type": ["string", "null"],
107 "enum": ["measured", "approximated", "statistical", "declared", "unclassified", null]
108 },
109 "confidence": {"type": ["number", "null"], "minimum": 0, "maximum": 1},
110 "quality_signal": {"type": ["string", "null"]},
111 "attribution_group": {"type": ["string", "null"]},
112 "attribution_id": {"type": ["string", "null"]},
113 "baseline_ref": {"type": ["string", "null"]},
114 "price_version": {"type": ["string", "null"]},
115 }),
116 json!({
117 "customer_approval": {
118 "type": ["string", "null"],
119 "enum": ["pending", "approved", "disputed", "superseded", null]
120 },
121 "settlement_status": {
122 "type": ["string", "null"],
123 "enum": ["ineligible", "eligible", "settled", "reversed", null]
124 }
125 }),
126 ] {
127 properties.extend(fields.as_object().expect("event fields object").clone());
128 }
129 json!({"type": "object", "required": required, "properties": properties})
130}
131
132fn ledger_summary_schema() -> Value {
133 json!({
134 "type": "object",
135 "required": [
136 "total_events", "saved_tokens", "saved_usd", "bounce_tokens",
137 "bounce_events", "tokenizers", "by_model", "by_day",
138 "by_tool", "by_mechanism", "net_saved_tokens"
139 ],
140 "properties": {
141 "total_events": {"type": "integer", "minimum": 0},
142 "saved_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
143 "saved_usd": {"type": "number"},
144 "bounce_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
145 "bounce_events": {"type": "integer", "minimum": 0},
146 "tokenizers": {"type": "array", "items": {"type": "string"}},
147 "by_model": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerModelTotals"}},
148 "by_day": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerDayTotals"}},
149 "by_tool": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerToolTotals"}},
150 "by_mechanism": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerMechanismTotals"}},
151 "net_saved_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX}
152 }
153 })
154}
155
156fn idempotency_key_parameter() -> Value {
157 json!({
158 "name": "Idempotency-Key",
159 "in": "header",
160 "required": true,
161 "description": "Client-supplied key used to make envelope submission idempotent.",
162 "schema": {"type": "string", "minLength": 1}
163 })
164}
165
166fn agent_schema() -> Value {
167 json!({
168 "type": "object",
169 "required": ["agent_id", "status"],
170 "properties": {
171 "agent_id": {"type": "string", "minLength": 1},
172 "status": {"type": "string", "enum": ["active", "idle", "offline"]},
173 "last_seen": {"type": ["string", "null"]},
174 "capabilities": {"type": "array", "items": {"type": "string"}}
175 }
176 })
177}
178
179fn agents_response_schema() -> Value {
180 json!({
181 "type": "object",
182 "required": ["api_version", "agents"],
183 "properties": {
184 "api_version": {"const": OCLA_API_VERSION},
185 "agents": {"type": "array", "items": schema_ref("OclaAgent")}
186 }
187 })
188}
189
190fn metric_schema() -> Value {
191 json!({
192 "type": "object",
193 "required": ["name", "value_milli"],
194 "properties": {
195 "name": {"type": "string", "minLength": 1},
196 "value_milli": {"type": "integer"},
197 "dimensions": {"type": "object", "additionalProperties": {"type": "string"}}
198 }
199 })
200}
201
202fn metrics_response_schema() -> Value {
203 json!({
204 "type": "object",
205 "required": ["api_version", "metrics"],
206 "properties": {
207 "api_version": {"const": OCLA_API_VERSION},
208 "metrics": {"type": "array", "items": schema_ref("OclaMetric")}
209 }
210 })
211}
212
213fn dlq_entry_schema() -> Value {
214 json!({
215 "type": "object",
216 "required": [
217 "id", "original_message", "target_agent", "error", "attempts",
218 "first_failed_at", "last_failed_at"
219 ],
220 "properties": {
221 "id": {"type": "string", "minLength": 1},
222 "original_message": {"type": "string"},
223 "target_agent": {"type": "string"},
224 "error": {"type": "string"},
225 "attempts": {"type": "integer", "minimum": 0, "maximum": 255},
226 "first_failed_at": {"type": "string"},
227 "last_failed_at": {"type": "string"}
228 }
229 })
230}
231
232fn dlq_stats_schema() -> Value {
233 json!({
234 "type": "object",
235 "required": ["total", "oldest_age_seconds", "by_target_agent"],
236 "properties": {
237 "total": {"type": "integer", "minimum": 0},
238 "oldest_age_seconds": {"type": "integer", "minimum": 0},
239 "by_target_agent": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}
240 }
241 })
242}
243
244fn dlq_response_schema() -> Value {
245 json!({
246 "type": "object",
247 "required": ["dead_letters", "stats"],
248 "properties": {
249 "dead_letters": {"type": "array", "items": schema_ref("DeadLetter")},
250 "stats": schema_ref("DlqStats")
251 }
252 })
253}
254
255fn dlq_retry_response_schema() -> Value {
256 json!({
257 "type": "object",
258 "required": ["id", "retried"],
259 "properties": {"id": {"type": "string"}, "retried": {"const": true}}
260 })
261}
262
263fn health_status_schema() -> Value {
264 json!({
265 "oneOf": [
266 {"const": "healthy"},
267 {
268 "type": "object",
269 "required": ["degraded"],
270 "properties": {"degraded": {"type": "string"}}
271 },
272 {
273 "type": "object",
274 "required": ["unhealthy"],
275 "properties": {"unhealthy": {"type": "string"}}
276 }
277 ]
278 })
279}
280
281fn health_component_schema() -> Value {
282 json!({
283 "type": "object",
284 "required": ["name", "status", "latency_ms"],
285 "properties": {
286 "name": {"type": "string"},
287 "status": health_status_schema(),
288 "latency_ms": {"type": ["integer", "null"], "minimum": 0},
289 "details": {
290 "type": "object",
291 "required": ["total", "oldest_age_secs"],
292 "properties": {
293 "total": {"type": "integer", "minimum": 0},
294 "oldest_age_secs": {"type": "integer", "minimum": 0}
295 }
296 }
297 }
298 })
299}
300
301fn health_response_schema() -> Value {
302 json!({
303 "type": "object",
304 "required": ["overall", "components", "uptime_seconds", "version"],
305 "properties": {
306 "overall": health_status_schema(),
307 "components": {"type": "array", "items": health_component_schema()},
308 "uptime_seconds": {"type": "integer", "minimum": 0},
309 "version": {"type": "string", "const": OCLA_API_VERSION}
310 }
311 })
312}
313
314fn envelope_batch_response_schema() -> Value {
315 json!({
316 "type": "object",
317 "required": ["api_version", "accepted", "rejected", "envelopes"],
318 "properties": {
319 "api_version": {"const": OCLA_API_VERSION},
320 "accepted": {"type": "integer", "minimum": 0},
321 "rejected": {"type": "integer", "minimum": 0},
322 "envelopes": {
323 "type": "array",
324 "items": {
325 "oneOf": [
326 schema_ref("CanonicalTokenEnvelopeV1"),
327 schema_ref("AgentEnvelopeV1")
328 ]
329 }
330 }
331 }
332 })
333}
334
335fn budget_request_schema() -> Value {
336 json!({
337 "type": "object",
338 "required": ["scope", "max_tokens_per_day", "max_usd_per_day"],
339 "properties": {
340 "scope": {
341 "type": "string",
342 "pattern": "^(org|team|user):.+$",
343 "minLength": 5
344 },
345 "max_tokens_per_day": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
346 "max_usd_per_day": {"type": "number", "minimum": 0}
347 }
348 })
349}
350
351fn budget_response_schema() -> Value {
352 json!({
353 "type": "object",
354 "required": [
355 "scope", "max_tokens_per_day", "max_usd_per_day",
356 "consumed_tokens", "consumed_usd"
357 ],
358 "properties": {
359 "scope": {"type": "string"},
360 "max_tokens_per_day": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
361 "max_usd_per_day": {"type": "number", "minimum": 0},
362 "consumed_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
363 "consumed_usd": {"type": "number", "minimum": 0}
364 }
365 })
366}
367
368fn budget_scope_parameter() -> Value {
369 json!({
370 "name": "scope",
371 "in": "path",
372 "required": true,
373 "schema": {"type": "string", "pattern": "^(org|team|user):.+$"}
374 })
375}
376
377#[must_use]
379pub fn ocla_openapi_spec() -> Value {
380 let envelope_request = json!({
381 "oneOf": [
382 schema_ref("CanonicalTokenEnvelopeV1"),
383 schema_ref("AgentEnvelopeV1")
384 ]
385 });
386
387 let mut spec = json!({
388 "openapi": "3.1.0",
389 "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
390 "info": {
391 "title": "LeanCTX OCLA API",
392 "description": "Provider-neutral Open Context & Token Lifecycle Architecture contract.",
393 "version": OCLA_API_VERSION,
394 "license": {"name": "Apache-2.0"}
395 },
396 "servers": [{"url": "/"}],
397 "paths": {
398 "/ocla/v1/health": {
399 "get": {
400 "operationId": "oclaHealth",
401 "summary": "Check OCLA availability",
402 "responses": {
403 "200": {"description": "OCLA is available", "content": {"application/json": {"schema": schema_ref("HealthResponse")}}},
404 "503": error_response("OCLA is unavailable")
405 }
406 }
407 },
408 "/ocla/v1/capabilities": {
409 "get": {
410 "operationId": "oclaCapabilities",
411 "summary": "List registered OCLA capabilities",
412 "responses": {
413 "200": {"description": "Registered capabilities", "content": {"application/json": {"schema": schema_ref("CapabilitiesResponse")}}},
414 "503": error_response("Capability registry is unavailable")
415 }
416 }
417 },
418 "/ocla/v1/agents": {
419 "get": {
420 "operationId": "oclaAgents",
421 "summary": "List connected OCLA agents",
422 "responses": {
423 "200": {"description": "Connected agents", "content": {"application/json": {"schema": schema_ref("AgentsResponse")}}},
424 "503": error_response("Agent registry is unavailable")
425 }
426 }
427 },
428 "/ocla/v1/metrics": {
429 "get": {
430 "operationId": "oclaMetrics",
431 "summary": "Read OCLA metrics",
432 "responses": {
433 "200": {"description": "Current OCLA metrics", "content": {"application/json": {"schema": schema_ref("MetricsResponse")}}},
434 "503": error_response("Metrics exporter is unavailable")
435 }
436 }
437 },
438 "/ocla/v1/envelope": {
439 "post": {
440 "operationId": "submitOclaEnvelope",
441 "summary": "Validate and accept a payload-free OCLA envelope",
442 "parameters": [idempotency_key_parameter()],
443 "requestBody": {"required": true, "content": {"application/json": {"schema": envelope_request}}},
444 "responses": {
445 "200": {"description": "Envelope accepted", "content": {"application/json": {"schema": envelope_request}}},
446 "400": error_response("Envelope failed validation")
447 }
448 }
449 },
450 "/ocla/v1/envelope/batch": {
451 "post": {
452 "operationId": "submitOclaEnvelopeBatch",
453 "summary": "Validate and accept multiple OCLA envelopes",
454 "parameters": [idempotency_key_parameter()],
455 "requestBody": {
456 "required": true,
457 "content": {"application/json": {"schema": {
458 "type": "array",
459 "minItems": 1,
460 "maxItems": 1000,
461 "items": envelope_request
462 }}}
463 },
464 "responses": {
465 "200": {"description": "Envelope batch accepted", "content": {"application/json": {"schema": schema_ref("EnvelopeBatchResponse")}}},
466 "400": error_response("One or more envelopes failed validation")
467 }
468 }
469 },
470 "/ocla/v1/ledger/summary": {
471 "get": {
472 "operationId": "getOclaLedger",
473 "summary": "Read the verified local savings ledger",
474 "parameters": [
475 {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}},
476 {"name": "mechanism", "in": "query", "required": false, "schema": {"type": "string", "enum": ["compression", "routing", "caching"]}}
477 ],
478 "responses": {
479 "200": {"description": "Verified ledger snapshot", "content": {"application/json": {"schema": schema_ref("LedgerResponse")}}},
480 "503": error_response("Ledger is unavailable or invalid")
481 }
482 }
483 },
484 "/ocla/v1/budget": {
485 "post": {
486 "operationId": "setOclaBudget",
487 "summary": "Set a daily OCLA budget limit",
488 "requestBody": {
489 "required": true,
490 "content": {"application/json": {"schema": schema_ref("BudgetRequest")}}
491 },
492 "responses": {
493 "200": {"description": "Budget limit set", "content": {"application/json": {"schema": schema_ref("BudgetResponse")}}},
494 "400": error_response("Budget request is invalid")
495 }
496 }
497 },
498 "/ocla/v1/budget/{scope}": {
499 "get": {
500 "operationId": "getOclaBudget",
501 "summary": "Read a daily OCLA budget limit and consumption",
502 "parameters": [budget_scope_parameter()],
503 "responses": {
504 "200": {"description": "Budget limit and consumption", "content": {"application/json": {"schema": schema_ref("BudgetResponse")}}},
505 "400": error_response("Budget scope is invalid"),
506 "404": error_response("Budget limit was not found")
507 }
508 },
509 "delete": {
510 "operationId": "deleteOclaBudget",
511 "summary": "Remove a daily OCLA budget limit",
512 "parameters": [budget_scope_parameter()],
513 "responses": {
514 "204": {"description": "Budget limit removed"},
515 "400": error_response("Budget scope is invalid"),
516 "404": error_response("Budget limit was not found")
517 }
518 }
519 },
520 "/ocla/v1/dlq": {
521 "get": {
522 "operationId": "getOclaDlq",
523 "summary": "List dead letters and queue statistics",
524 "responses": {
525 "200": {"description": "Dead-letter queue snapshot", "content": {"application/json": {"schema": schema_ref("DlqResponse")}}}
526 }
527 }
528 },
529 "/ocla/v1/dlq/{id}/retry": {
530 "post": {
531 "operationId": "retryOclaDeadLetter",
532 "summary": "Retry delivery of a dead letter",
533 "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "minLength": 1}}],
534 "responses": {
535 "200": {"description": "Dead letter retried", "content": {"application/json": {"schema": schema_ref("DlqRetryResponse")}}},
536 "400": error_response("Dead letter retry failed")
537 }
538 }
539 },
540 "/ocla/v1/dlq/{id}": {
541 "delete": {
542 "operationId": "deleteOclaDeadLetter",
543 "summary": "Remove a dead letter without retrying",
544 "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "minLength": 1}}],
545 "responses": {
546 "204": {"description": "Dead letter removed"},
547 "404": error_response("Dead letter was not found")
548 }
549 }
550 },
551
552 },
553 "components": {
554 "schemas": {
555 "CanonicalTokenEnvelopeV1": canonical_envelope_schema(),
556 "AgentEnvelopeV1": agent_envelope_schema(),
557 "AgentsResponse": agents_response_schema(),
558 "OclaAgent": agent_schema(),
559 "MetricsResponse": metrics_response_schema(),
560 "OclaMetric": metric_schema(),
561 "EnvelopeBatchResponse": envelope_batch_response_schema(),
562 "BudgetRequest": budget_request_schema(),
563 "BudgetResponse": budget_response_schema(),
564 "DeadLetter": dlq_entry_schema(),
565 "DlqStats": dlq_stats_schema(),
566 "DlqResponse": dlq_response_schema(),
567 "DlqRetryResponse": dlq_retry_response_schema(),
568 "HealthResponse": health_response_schema(),
569
570 "CapabilitiesResponse": {
571 "type": "object",
572 "required": ["api_version", "capabilities"],
573 "properties": {"api_version": {"const": OCLA_API_VERSION}, "capabilities": {"type": "array", "items": schema_ref("OclaCapability")}}
574 },
575 "OclaCapability": capability_schema(),
576 "OclaError": {
577 "type": "object",
578 "required": ["error"],
579 "properties": {"error": {"type": "string"}, "code": {"type": "string"}}
580 },
581 "SavingsEvent": savings_event_schema(),
582 "LedgerModelTotals": {"type": "array", "prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 0}, {"type": "number"}], "minItems": 3, "maxItems": 3},
583 "LedgerDayTotals": {"type": "array", "prefixItems": [{"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"}, {"type": "integer", "minimum": 0}, {"type": "number"}], "minItems": 3, "maxItems": 3},
584 "LedgerToolTotals": {"type": "array", "prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 0}], "minItems": 2, "maxItems": 2},
585 "LedgerMechanismTotals": {"type": "array", "prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 0}, {"type": "number"}], "minItems": 3, "maxItems": 3},
586 "LedgerSummary": ledger_summary_schema(),
587 "LedgerResponse": {
588 "type": "object",
589 "required": ["api_version", "verified", "events", "summary"],
590 "properties": {
591 "api_version": {"const": OCLA_API_VERSION},
592 "verified": {"type": "boolean"},
593 "events": {"type": "array", "items": schema_ref("SavingsEvent")},
594 "summary": schema_ref("LedgerSummary")
595 }
596 }
597 }
598 }
599 });
600 if let Some(paths) = spec["paths"].as_object_mut() {
601 paths.insert("/ocla/v1/capsule".into(), capsule_register_path());
602 paths.insert("/ocla/v1/capsule/{ref}".into(), capsule_resolve_path());
603 paths.insert("/ocla/v1/capsule/{ref}/fork".into(), capsule_fork_path());
604 }
605 if let Some(schemas) = spec["components"]["schemas"].as_object_mut() {
606 schemas.insert("CapsuleRegisterResponse".into(), json!({"type": "object", "required": ["capsule_ref"], "properties": {"capsule_ref": {"type": "string", "pattern": "^capsule:[0-9a-f]{64}$"}}}));
607 schemas.insert("CapsuleResolveResponse".into(), json!({"type": "object", "required": ["capsule_ref", "data"], "properties": {"capsule_ref": {"type": "string"}, "data": {"type": "string"}}}));
608 schemas.insert("CapsuleForkRequest".into(), json!({"type": "object", "required": ["budget_tokens"], "properties": {"budget_tokens": {"type": "integer", "minimum": 0}}}));
609 }
610 spec
611}
612
613fn capsule_register_path() -> Value {
614 json!({"post": {"operationId": "registerCapsule", "summary": "Register a new content-addressed capsule", "requestBody": {"required": true, "content": {"text/plain": {"schema": {"type": "string"}}}}, "responses": {"201": {"description": "Capsule registered", "content": {"application/json": {"schema": schema_ref("CapsuleRegisterResponse")}}}}}})
615}
616
617fn capsule_resolve_path() -> Value {
618 json!({"get": {"operationId": "resolveCapsule", "summary": "Resolve a capsule to its materialized data", "parameters": [{"name": "ref", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Capsule data", "content": {"application/json": {"schema": schema_ref("CapsuleResolveResponse")}}}, "404": error_response("Capsule not found")}}})
619}
620
621fn capsule_fork_path() -> Value {
622 json!({"post": {"operationId": "forkCapsule", "summary": "Create a CoW fork of an existing capsule", "parameters": [{"name": "ref", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": schema_ref("CapsuleForkRequest")}}}, "responses": {"201": {"description": "Fork created", "content": {"application/json": {"schema": schema_ref("CapsuleRegisterResponse")}}}, "404": error_response("Parent capsule not found")}}})
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn openapi_snapshot_matches_checked_in_contract() {
631 if std::env::var_os("LEANCTX_UPDATE_OCLA_OPENAPI_SNAPSHOT").is_some() {
632 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
633 .join("tests/fixtures/ocla_openapi_snapshot.json");
634 let json = serde_json::to_string(&ocla_openapi_spec())
635 .expect("serialize OCLA OpenAPI snapshot");
636 std::fs::write(path, json + "\n").expect("write OCLA OpenAPI snapshot");
637 return;
638 }
639 let expected = include_str!("../../../tests/fixtures/ocla_openapi_snapshot.json").trim();
640 let actual =
641 serde_json::to_string(&ocla_openapi_spec()).expect("serialize OCLA OpenAPI snapshot");
642 assert_eq!(actual, expected);
643 }
644
645 #[test]
646 fn openapi_exposes_all_ocla_endpoints_and_wire_schemas() {
647 let spec = ocla_openapi_spec();
648 let paths = spec["paths"].as_object().expect("paths object");
649 assert!(paths.contains_key("/ocla/v1/health"));
650 assert!(paths.contains_key("/ocla/v1/capabilities"));
651 assert!(paths.contains_key("/ocla/v1/agents"));
652 assert!(paths.contains_key("/ocla/v1/metrics"));
653 assert!(paths.contains_key("/ocla/v1/envelope"));
654 assert!(paths.contains_key("/ocla/v1/envelope/batch"));
655 assert!(paths.contains_key("/ocla/v1/ledger/summary"));
656 assert!(paths.contains_key("/ocla/v1/budget"));
657 assert!(paths.contains_key("/ocla/v1/budget/{scope}"));
658 assert!(paths.contains_key("/ocla/v1/dlq"));
659 assert!(paths.contains_key("/ocla/v1/dlq/{id}/retry"));
660 assert!(paths.contains_key("/ocla/v1/dlq/{id}"));
661 assert!(paths.contains_key("/ocla/v1/capsule"));
662 assert!(paths.contains_key("/ocla/v1/capsule/{ref}"));
663 assert!(paths.contains_key("/ocla/v1/capsule/{ref}/fork"));
664 assert!(spec["components"]["schemas"]["CanonicalTokenEnvelopeV1"].is_object());
665 assert!(spec["components"]["schemas"]["AgentEnvelopeV1"].is_object());
666 assert!(spec["components"]["schemas"]["AgentsResponse"].is_object());
667 assert!(spec["components"]["schemas"]["MetricsResponse"].is_object());
668 assert!(spec["components"]["schemas"]["EnvelopeBatchResponse"].is_object());
669 assert!(spec["components"]["schemas"]["BudgetRequest"].is_object());
670 assert!(spec["components"]["schemas"]["BudgetResponse"].is_object());
671 assert!(spec["components"]["schemas"]["DeadLetter"].is_object());
672 assert!(spec["components"]["schemas"]["DlqStats"].is_object());
673 assert!(spec["components"]["schemas"]["DlqResponse"].is_object());
674 assert!(spec["components"]["schemas"]["DlqRetryResponse"].is_object());
675 assert!(spec["components"]["schemas"]["HealthResponse"].is_object());
676 assert!(spec["components"]["schemas"]["CapsuleRegisterResponse"].is_object());
677 assert!(spec["components"]["schemas"]["CapsuleResolveResponse"].is_object());
678 assert!(spec["components"]["schemas"]["CapsuleForkRequest"].is_object());
679 let serialized = serde_json::to_string(&spec).expect("serialize OpenAPI spec");
680 serde_json::from_str::<Value>(&serialized).expect("OpenAPI spec is valid JSON");
681 assert_eq!(
682 spec["paths"]["/ocla/v1/envelope"]["post"]["parameters"][0]["name"],
683 "Idempotency-Key"
684 );
685 assert_eq!(
686 spec["paths"]["/ocla/v1/envelope/batch"]["post"]["parameters"][0]["name"],
687 "Idempotency-Key"
688 );
689 }
690}