1use lemma::parsing::ast::DateTimeValue;
19use lemma::{Engine, LemmaType, TypeSpecification};
20use serde_json::{json, Map, Value};
21
22#[derive(Debug, Clone, serde::Serialize)]
27pub struct ApiSource {
28 pub title: String,
29 pub slug: String,
30 pub url: String,
31}
32
33pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
43 let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
44 std::collections::BTreeSet::new();
45
46 let all_specs = engine.list_specs();
47 let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
48 for spec in &all_specs {
49 if seen_names.insert(spec.name.clone()) {
50 for s in all_specs.iter().filter(|s| s.name == spec.name) {
51 if let Some(af) = s.effective_from() {
52 all_boundaries.insert(af.clone());
53 }
54 }
55 }
56 }
57
58 if all_boundaries.is_empty() {
59 return vec![ApiSource {
60 title: "Current".to_string(),
61 slug: "current".to_string(),
62 url: "/openapi.json".to_string(),
63 }];
64 }
65
66 let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
67
68 sources.push(ApiSource {
69 title: "Current".to_string(),
70 slug: "current".to_string(),
71 url: "/openapi.json".to_string(),
72 });
73
74 for boundary in all_boundaries.iter().rev() {
75 let label = boundary.to_string();
76 sources.push(ApiSource {
77 title: format!("Effective {}", label),
78 slug: label.clone(),
79 url: format!("/openapi.json?effective={}", label),
80 });
81 }
82
83 sources
84}
85
86pub fn generate_openapi(engine: &Engine, proofs_enabled: bool) -> Value {
91 generate_openapi_effective(engine, proofs_enabled, &DateTimeValue::now())
92}
93
94pub fn generate_openapi_effective(
105 engine: &Engine,
106 proofs_enabled: bool,
107 effective: &DateTimeValue,
108) -> Value {
109 let mut paths = Map::new();
110 let mut components_schemas = Map::new();
111
112 let active_specs = engine.list_specs_effective(effective);
113 let unique_spec_names: Vec<String> = active_specs.iter().map(|s| s.name.clone()).collect();
114
115 for spec_name in &unique_spec_names {
116 if let Some(plan) = engine.get_execution_plan(spec_name, None, effective) {
117 let schema = plan.schema();
118 let facts = collect_input_facts_from_schema(&schema);
119 let rule_names: Vec<String> = schema.rules.keys().cloned().collect();
120
121 let safe_name = spec_name.replace('/', "_");
122 let response_schema_name = format!("{}_response", safe_name);
123 components_schemas.insert(
124 response_schema_name.clone(),
125 build_response_schema(&schema, &rule_names, proofs_enabled),
126 );
127
128 let post_body_schema_name = format!("{}_request", safe_name);
129 components_schemas.insert(
130 post_body_schema_name.clone(),
131 build_post_request_schema(&facts),
132 );
133
134 let path = format!("/{}", spec_name);
135 paths.insert(
136 path,
137 build_spec_path_item(
138 spec_name,
139 &facts,
140 &response_schema_name,
141 &post_body_schema_name,
142 &rule_names,
143 proofs_enabled,
144 ),
145 );
146 }
147 }
148
149 paths.insert(
150 "/".to_string(),
151 index_path_item(&unique_spec_names, engine, effective),
152 );
153 paths.insert("/health".to_string(), health_path_item());
154 paths.insert("/openapi.json".to_string(), openapi_json_path_item());
155
156 let mut tags = vec![json!({
157 "name": "Specs",
158 "description": "Simple API to retrieve the list of Lemma specs"
159 })];
160 for spec_name in &unique_spec_names {
161 let safe_tag = spec_name.replace('/', "_");
162 tags.push(json!({
163 "name": safe_tag,
164 "x-displayName": spec_name,
165 "description": format!("GET schema or POST evaluate for spec '{}'. Use ?rules= to scope.", spec_name)
166 }));
167 }
168 tags.push(json!({
169 "name": "Meta",
170 "description": "Server metadata and introspection endpoints"
171 }));
172
173 let spec_tags: Vec<Value> = unique_spec_names
174 .iter()
175 .map(|n| Value::String(n.replace('/', "_")))
176 .collect();
177
178 let tag_groups = vec![
179 json!({ "name": "Overview", "tags": ["Specs"] }),
180 json!({ "name": "Specs", "tags": spec_tags }),
181 json!({ "name": "Meta", "tags": ["Meta"] }),
182 ];
183
184 let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
185
186 json!({
187 "openapi": "3.1.0",
188 "info": {
189 "title": "Lemma API",
190 "description": "Lemma is a declarative language for expressing business logic — pricing rules, tax calculations, eligibility criteria, contracts, and policies. Learn more at [LemmaBase.com](https://lemmabase.com).",
191 "version": version_label
192 },
193 "tags": tags,
194 "x-tagGroups": tag_groups,
195 "paths": Value::Object(paths),
196 "components": {
197 "schemas": Value::Object(components_schemas)
198 }
199 })
200}
201
202struct InputFact {
204 name: String,
206 lemma_type: LemmaType,
208 default_value: Option<lemma::LiteralValue>,
211}
212
213fn collect_input_facts_from_schema(schema: &lemma::SpecSchema) -> Vec<InputFact> {
218 schema
219 .facts
220 .iter()
221 .filter(|(name, _)| !name.contains('.'))
222 .map(|(name, (lemma_type, default))| InputFact {
223 name: name.clone(),
224 lemma_type: lemma_type.clone(),
225 default_value: default.clone(),
226 })
227 .collect()
228}
229
230fn index_path_item(spec_names: &[String], engine: &Engine, effective: &DateTimeValue) -> Value {
235 let spec_items: Vec<Value> = spec_names
236 .iter()
237 .map(|name| {
238 let (facts_count, rules_count) = engine
239 .get_execution_plan(name, None, effective)
240 .map(|p| {
241 let schema = p.schema();
242 let facts_count = schema.facts.keys().filter(|n| !n.contains('.')).count();
243 let rules_count = schema.rules.len();
244 (facts_count, rules_count)
245 })
246 .unwrap_or((0, 0));
247 json!({
248 "name": name,
249 "facts": facts_count,
250 "rules": rules_count
251 })
252 })
253 .collect();
254
255 json!({
256 "get": {
257 "operationId": "listSpecs",
258 "summary": "List all available specs",
259 "tags": ["Specs"],
260 "responses": {
261 "200": {
262 "description": "List of loaded Lemma specs",
263 "content": {
264 "application/json": {
265 "schema": {
266 "type": "array",
267 "items": {
268 "type": "object",
269 "properties": {
270 "name": { "type": "string" },
271 "facts": { "type": "integer" },
272 "rules": { "type": "integer" }
273 },
274 "required": ["name", "facts", "rules"]
275 }
276 },
277 "example": spec_items
278 }
279 }
280 }
281 }
282 }
283 })
284}
285
286fn health_path_item() -> Value {
287 json!({
288 "get": {
289 "operationId": "healthCheck",
290 "summary": "Health check",
291 "tags": ["Meta"],
292 "responses": {
293 "200": {
294 "description": "Server is healthy",
295 "content": {
296 "application/json": {
297 "schema": {
298 "type": "object",
299 "properties": {
300 "status": { "type": "string" },
301 "service": { "type": "string" },
302 "version": { "type": "string" }
303 },
304 "required": ["status", "service", "version"]
305 }
306 }
307 }
308 }
309 }
310 }
311 })
312}
313
314fn openapi_json_path_item() -> Value {
315 json!({
316 "get": {
317 "operationId": "getOpenApiSpec",
318 "summary": "OpenAPI 3.1 specification",
319 "tags": ["Meta"],
320 "responses": {
321 "200": {
322 "description": "OpenAPI specification as JSON",
323 "content": {
324 "application/json": {
325 "schema": { "type": "object" }
326 }
327 }
328 }
329 }
330 }
331 })
332}
333
334fn error_response_schema() -> Value {
339 json!({
340 "description": "Evaluation error",
341 "content": {
342 "application/json": {
343 "schema": {
344 "type": "object",
345 "properties": {
346 "error": { "type": "string" }
347 },
348 "required": ["error"]
349 }
350 }
351 }
352 })
353}
354
355fn not_found_response_schema() -> Value {
356 json!({
357 "description": "Spec not found",
358 "content": {
359 "application/json": {
360 "schema": {
361 "type": "object",
362 "properties": {
363 "error": { "type": "string" }
364 },
365 "required": ["error"]
366 }
367 }
368 }
369 })
370}
371
372fn hash_conflict_response_schema() -> Value {
373 json!({
374 "description": "Hash mismatch: the ?hash= value doesn't match the resolved spec's composite content hash",
375 "content": {
376 "application/json": {
377 "schema": {
378 "type": "object",
379 "properties": {
380 "error": { "type": "string" }
381 },
382 "required": ["error"]
383 }
384 }
385 }
386 })
387}
388
389fn x_proofs_header_parameter() -> Value {
394 json!({
395 "name": "x-proofs",
396 "in": "header",
397 "required": false,
398 "description": "Set to request proof objects in the response (server must be started with --proofs)",
399 "schema": { "type": "string", "default": "true" }
400 })
401}
402
403fn accept_datetime_header_parameter() -> Value {
404 json!({
405 "name": "Accept-Datetime",
406 "in": "header",
407 "required": false,
408 "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit for current. Use ?hash= to pin to a specific content version.",
409 "schema": { "type": "string", "format": "date-time" },
410 "example": "Sat, 01 Jan 2025 00:00:00 GMT"
411 })
412}
413
414fn hash_query_parameter() -> Value {
415 json!({
416 "name": "hash",
417 "in": "query",
418 "required": false,
419 "description": "Pin to a specific content hash. If the resolved spec's composite hash doesn't match, the server returns 409 Conflict.",
420 "schema": { "type": "string" },
421 "example": "a1b2c3d4"
422 })
423}
424
425fn build_spec_path_item(
426 spec_name: &str,
427 _facts: &[InputFact],
428 response_schema_name: &str,
429 post_body_schema_name: &str,
430 rule_names: &[String],
431 proofs_enabled: bool,
432) -> Value {
433 let response_ref = json!({
434 "$ref": format!("#/components/schemas/{}", response_schema_name)
435 });
436 let body_ref = json!({
437 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
438 });
439
440 let tag = spec_name.replace('/', "_");
441
442 let rules_example = if rule_names.is_empty() {
443 String::new()
444 } else {
445 rule_names.join(",")
446 };
447
448 let rules_param = json!({
449 "name": "rules",
450 "in": "query",
451 "required": false,
452 "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
453 "schema": { "type": "string" },
454 "example": rules_example
455 });
456
457 let mut get_parameters: Vec<Value> = vec![rules_param.clone(), hash_query_parameter()];
458 get_parameters.push(accept_datetime_header_parameter());
459 if proofs_enabled {
460 get_parameters.push(x_proofs_header_parameter());
461 }
462
463 let get_summary = "Schema of resolved version (spec, facts, rules, meta, versions)".to_string();
464 let post_summary = "Evaluate".to_string();
465 let get_operation_id = format!("get_{}", spec_name);
466 let post_operation_id = format!("post_{}", spec_name);
467
468 let mut post_parameters: Vec<Value> = vec![rules_param, hash_query_parameter()];
469 post_parameters.push(accept_datetime_header_parameter());
470 if proofs_enabled {
471 post_parameters.push(x_proofs_header_parameter());
472 }
473
474 json!({
475 "get": {
476 "operationId": get_operation_id,
477 "summary": get_summary,
478 "tags": [tag],
479 "parameters": get_parameters,
480 "responses": {
481 "200": {
482 "description": "Schema of resolved version. Includes spec identity, hash, facts, rules, meta, and versions. Headers: ETag, Memento-Datetime, Vary.",
483 "content": {
484 "application/json": {
485 "schema": response_ref
486 }
487 }
488 },
489 "400": error_response_schema(),
490 "404": not_found_response_schema(),
491 "409": hash_conflict_response_schema()
492 }
493 },
494 "post": {
495 "operationId": post_operation_id,
496 "summary": post_summary,
497 "tags": [tag],
498 "parameters": post_parameters,
499 "requestBody": {
500 "required": true,
501 "content": {
502 "application/x-www-form-urlencoded": {
503 "schema": body_ref
504 }
505 }
506 },
507 "responses": {
508 "200": {
509 "description": "Evaluation results with traceability envelope (spec, effective, hash, result). Headers: ETag, Memento-Datetime, Vary.",
510 "content": {
511 "application/json": {
512 "schema": response_ref
513 }
514 }
515 },
516 "400": error_response_schema(),
517 "404": not_found_response_schema(),
518 "409": hash_conflict_response_schema()
519 }
520 }
521 })
522}
523
524fn type_help(lemma_type: &LemmaType) -> String {
530 match &lemma_type.specifications {
531 TypeSpecification::Boolean { help, .. } => help.clone(),
532 TypeSpecification::Scale { help, .. } => help.clone(),
533 TypeSpecification::Number { help, .. } => help.clone(),
534 TypeSpecification::Ratio { help, .. } => help.clone(),
535 TypeSpecification::Text { help, .. } => help.clone(),
536 TypeSpecification::Date { help, .. } => help.clone(),
537 TypeSpecification::Time { help, .. } => help.clone(),
538 TypeSpecification::Duration { help, .. } => help.clone(),
539 TypeSpecification::Veto { .. } => String::new(),
540 TypeSpecification::Undetermined => unreachable!(
541 "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
542 ),
543 }
544}
545
546fn type_default_as_string(lemma_type: &LemmaType) -> Option<String> {
548 match &lemma_type.specifications {
549 TypeSpecification::Boolean { default, .. } => default.map(|b| b.to_string()),
550 TypeSpecification::Scale { default, .. } => {
551 default.as_ref().map(|(d, u)| format!("{} {}", d, u))
552 }
553 TypeSpecification::Number { default, .. } => default.as_ref().map(|d| d.to_string()),
554 TypeSpecification::Ratio { default, .. } => default.as_ref().map(|d| d.to_string()),
555 TypeSpecification::Text { default, .. } => default.clone(),
556 TypeSpecification::Date { default, .. } => default.as_ref().map(|dt| format!("{}", dt)),
557 TypeSpecification::Time { default, .. } => default.as_ref().map(|t| format!("{}", t)),
558 TypeSpecification::Duration { default, .. } => {
559 default.as_ref().map(|(v, u)| format!("{} {}", v, u))
560 }
561 TypeSpecification::Veto { .. } => None,
562 TypeSpecification::Undetermined => unreachable!(
563 "BUG: type_default_as_string called with Undetermined sentinel type; this type must never reach OpenAPI generation"
564 ),
565 }
566}
567
568fn build_post_request_schema(facts: &[InputFact]) -> Value {
573 let mut properties = Map::new();
574 let mut required = Vec::new();
575
576 for fact in facts {
577 properties.insert(
578 fact.name.clone(),
579 build_post_property_schema(&fact.lemma_type, fact.default_value.as_ref()),
580 );
581 if fact.default_value.is_none() {
582 required.push(Value::String(fact.name.clone()));
583 }
584 }
585
586 let mut schema = json!({
587 "type": "object",
588 "properties": Value::Object(properties)
589 });
590 if !required.is_empty() {
591 schema["required"] = Value::Array(required);
592 }
593 schema
594}
595
596fn build_post_property_schema(
597 lemma_type: &LemmaType,
598 fact_value: Option<&lemma::LiteralValue>,
599) -> Value {
600 let mut schema = build_post_type_schema(lemma_type);
601
602 let help = type_help(lemma_type);
603 if !help.is_empty() {
604 schema["description"] = Value::String(help);
605 }
606
607 let default_str = fact_value
609 .map(|v| v.display_value())
610 .or_else(|| type_default_as_string(lemma_type));
611 if let Some(d) = default_str {
612 schema["default"] = Value::String(d);
613 }
614
615 schema
616}
617
618fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
619 match &lemma_type.specifications {
620 TypeSpecification::Text { options, .. } => {
621 let mut schema = json!({ "type": "string" });
622 if !options.is_empty() {
623 schema["enum"] =
624 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
625 }
626 schema
627 }
628 TypeSpecification::Boolean { .. } => {
629 json!({ "type": "string", "enum": ["true", "false"] })
630 }
631 _ => json!({ "type": "string" }),
632 }
633}
634
635fn build_response_schema(
640 schema: &lemma::SpecSchema,
641 rule_names: &[String],
642 proofs_enabled: bool,
643) -> Value {
644 let mut properties = Map::new();
645
646 let proof_prop = proofs_enabled.then(|| {
647 json!({
648 "type": "object",
649 "description": "Proof tree (included when x-proofs header is sent and server started with --proofs)"
650 })
651 });
652
653 for rule_name in rule_names {
654 if let Some(rule_type) = schema.rules.get(rule_name) {
655 let result_type_name = type_base_name(rule_type);
656 let mut value_props = Map::new();
657 value_props.insert(
658 "value".to_string(),
659 json!({
660 "type": "string",
661 "description": format!("Computed value (type: {})", result_type_name)
662 }),
663 );
664 if let Some(ref p) = proof_prop {
665 value_props.insert("proof".to_string(), p.clone());
666 }
667 let mut veto_props = Map::new();
668 veto_props.insert(
669 "veto_reason".to_string(),
670 json!({
671 "type": "string",
672 "description": "Reason the rule was vetoed (no value produced)"
673 }),
674 );
675 if let Some(ref p) = proof_prop {
676 veto_props.insert("proof".to_string(), p.clone());
677 }
678 let value_branch = json!({
679 "type": "object",
680 "properties": Value::Object(value_props),
681 "required": ["value"]
682 });
683 let veto_branch = json!({
684 "type": "object",
685 "properties": Value::Object(veto_props)
686 });
687 properties.insert(
688 rule_name.clone(),
689 json!({
690 "oneOf": [ value_branch, veto_branch ]
691 }),
692 );
693 }
694 }
695
696 json!({
697 "type": "object",
698 "properties": Value::Object(properties)
699 })
700}
701
702fn type_base_name(lemma_type: &LemmaType) -> String {
708 if let Some(ref name) = lemma_type.name {
709 return name.clone();
710 }
711 match &lemma_type.specifications {
712 TypeSpecification::Boolean { .. } => "boolean".to_string(),
713 TypeSpecification::Number { .. } => "number".to_string(),
714 TypeSpecification::Scale { .. } => "scale".to_string(),
715 TypeSpecification::Text { .. } => "text".to_string(),
716 TypeSpecification::Date { .. } => "date".to_string(),
717 TypeSpecification::Time { .. } => "time".to_string(),
718 TypeSpecification::Duration { .. } => "duration".to_string(),
719 TypeSpecification::Ratio { .. } => "ratio".to_string(),
720 TypeSpecification::Veto { .. } => "veto".to_string(),
721 TypeSpecification::Undetermined => unreachable!(
722 "BUG: type_base_name called with Undetermined sentinel type; this type must never reach OpenAPI generation"
723 ),
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use lemma::parsing::ast::DateTimeValue;
731
732 fn create_engine_with_code(code: &str) -> Engine {
733 let mut engine = Engine::new();
734 let files: std::collections::HashMap<String, String> =
735 std::iter::once(("test.lemma".to_string(), code.to_string())).collect();
736 engine
737 .add_lemma_files(files)
738 .expect("failed to parse lemma code");
739 engine
740 }
741
742 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
743 let mut engine = Engine::new();
744 let file_map: std::collections::HashMap<String, String> = files
745 .into_iter()
746 .map(|(name, code)| (name.to_string(), code.to_string()))
747 .collect();
748 engine
749 .add_lemma_files(file_map)
750 .expect("failed to parse lemma code");
751 engine
752 }
753
754 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
755 DateTimeValue {
756 year,
757 month,
758 day,
759 hour: 0,
760 minute: 0,
761 second: 0,
762 microsecond: 0,
763 timezone: None,
764 }
765 }
766
767 fn has_param(params: &Value, name: &str) -> bool {
768 params
769 .as_array()
770 .map(|a| a.iter().any(|p| p["name"] == name))
771 .unwrap_or(false)
772 }
773
774 fn find_param<'a>(params: &'a Value, name: &str) -> &'a Value {
775 params
776 .as_array()
777 .expect("parameters should be array")
778 .iter()
779 .find(|p| p["name"] == name)
780 .unwrap_or_else(|| panic!("parameter '{}' not found", name))
781 }
782
783 #[test]
788 fn test_generate_openapi_has_required_fields() {
789 let engine =
790 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
791 let spec = generate_openapi(&engine, false);
792
793 assert_eq!(spec["openapi"], "3.1.0");
794 assert!(spec["info"]["title"].is_string());
795 assert!(spec["tags"].is_array());
796 assert!(spec["paths"].is_object());
797 assert!(spec["components"]["schemas"].is_object());
798 }
799
800 #[test]
801 fn test_generate_openapi_tags_order() {
802 let engine =
803 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
804 let spec = generate_openapi(&engine, false);
805
806 let tags = spec["tags"].as_array().expect("tags should be array");
807 let tag_names: Vec<&str> = tags.iter().map(|t| t["name"].as_str().unwrap()).collect();
808 assert_eq!(tag_names, vec!["Specs", "pricing", "Meta"]);
809 }
810
811 #[test]
812 fn test_generate_openapi_x_tag_groups() {
813 let engine =
814 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
815 let spec = generate_openapi(&engine, false);
816
817 let groups = spec["x-tagGroups"]
818 .as_array()
819 .expect("x-tagGroups should be array");
820 assert_eq!(groups.len(), 3);
821 assert_eq!(groups[0]["name"], "Overview");
822 assert_eq!(groups[0]["tags"], json!(["Specs"]));
823 assert_eq!(groups[1]["name"], "Specs");
824 assert_eq!(groups[1]["tags"], json!(["pricing"]));
825 assert_eq!(groups[2]["name"], "Meta");
826 assert_eq!(groups[2]["tags"], json!(["Meta"]));
827 }
828
829 #[test]
830 fn test_index_endpoint_uses_specs_tag() {
831 let engine =
832 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
833 let spec = generate_openapi(&engine, false);
834
835 let index_tag = &spec["paths"]["/"]["get"]["tags"][0];
836 assert_eq!(index_tag, "Specs");
837 }
838
839 #[test]
840 fn test_spec_path_has_get_and_post() {
841 let engine =
842 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
843 let spec = generate_openapi(&engine, false);
844
845 assert!(
846 spec["paths"]["/pricing"].is_object(),
847 "single spec path /pricing"
848 );
849 assert!(spec["paths"]["/pricing"]["get"].is_object());
850 assert!(spec["paths"]["/pricing"]["post"].is_object());
851
852 assert_eq!(
853 spec["paths"]["/pricing"]["get"]["operationId"],
854 "get_pricing"
855 );
856 assert_eq!(
857 spec["paths"]["/pricing"]["post"]["operationId"],
858 "post_pricing"
859 );
860 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
861
862 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
863 .as_array()
864 .expect("parameters array");
865 let param_names: Vec<&str> = get_params
866 .iter()
867 .map(|p| p["name"].as_str().unwrap())
868 .collect();
869 assert!(
870 param_names.contains(&"rules"),
871 "GET must have rules query param"
872 );
873 assert!(
874 param_names.contains(&"Accept-Datetime"),
875 "GET must have Accept-Datetime header"
876 );
877 }
878
879 #[test]
880 fn test_spec_endpoint_has_accept_datetime_and_rules() {
881 let engine =
882 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
883 let spec = generate_openapi(&engine, false);
884
885 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
886 assert!(has_param(get_params, "Accept-Datetime"));
887 assert!(has_param(get_params, "rules"));
888
889 let post_params = &spec["paths"]["/pricing"]["post"]["parameters"];
890 assert!(has_param(post_params, "Accept-Datetime"));
891 }
892
893 #[test]
894 fn test_generate_openapi_meta_routes() {
895 let engine =
896 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
897 let spec = generate_openapi(&engine, false);
898
899 assert!(spec["paths"]["/"].is_object());
900 assert!(spec["paths"]["/health"].is_object());
901 assert!(spec["paths"]["/openapi.json"].is_object());
902 assert!(spec["paths"]["/docs"].is_null());
903 }
904
905 #[test]
906 fn test_generate_openapi_spec_routes() {
907 let engine =
908 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
909 let spec = generate_openapi(&engine, false);
910
911 assert!(spec["paths"]["/pricing"].is_object());
912 assert!(spec["paths"]["/pricing"]["get"].is_object());
913 assert!(spec["paths"]["/pricing"]["post"].is_object());
914 }
915
916 #[test]
917 fn test_generate_openapi_schemas() {
918 let engine =
919 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
920 let spec = generate_openapi(&engine, false);
921
922 assert!(spec["components"]["schemas"]["pricing_response"].is_object());
923 assert!(spec["components"]["schemas"]["pricing_request"].is_object());
924 }
925
926 #[test]
927 fn test_generate_openapi_proofs_enabled_adds_x_proofs_and_proof_schema() {
928 let engine =
929 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
930 let spec = generate_openapi(&engine, true);
931
932 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
933 assert!(has_param(get_params, "x-proofs"));
934
935 let response_schema = &spec["components"]["schemas"]["pricing_response"];
936 let total_props = &response_schema["properties"]["total"]["oneOf"];
937 let first_branch = &total_props[0]["properties"];
938 assert!(first_branch["proof"].is_object());
939 }
940
941 #[test]
942 fn test_generate_openapi_multiple_specs() {
943 let engine = create_engine_with_files(vec![
944 (
945 "pricing.lemma",
946 "spec pricing\nfact quantity: 10\nrule total: quantity * 2",
947 ),
948 (
949 "shipping.lemma",
950 "spec shipping\nfact weight: 5\nrule cost: weight * 3",
951 ),
952 ]);
953 let spec = generate_openapi(&engine, false);
954
955 assert!(spec["paths"]["/pricing"].is_object());
956 assert!(spec["paths"]["/shipping"].is_object());
957 }
958
959 #[test]
960 fn test_nested_spec_path_schema_refs_are_valid() {
961 let engine = create_engine_with_code("spec a/b/c\nfact x: [number]\nrule result: x");
962 let spec = generate_openapi(&engine, false);
963
964 assert!(spec["paths"]["/a/b/c"]["post"].is_object());
965 let body_ref = spec["paths"]["/a/b/c"]["post"]["requestBody"]["content"]
966 ["application/x-www-form-urlencoded"]["schema"]["$ref"]
967 .as_str()
968 .unwrap();
969 assert_eq!(body_ref, "#/components/schemas/a_b_c_request");
970 assert!(spec["components"]["schemas"]["a_b_c_request"].is_object());
971 assert!(spec["components"]["schemas"]["a_b_c_request"]["properties"]["x"].is_object());
972 }
973
974 #[test]
975 fn test_spec_endpoint_has_accept_datetime_header() {
976 let engine =
977 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
978 let spec = generate_openapi(&engine, false);
979
980 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
981 assert!(
982 has_param(get_params, "Accept-Datetime"),
983 "GET must have Accept-Datetime header"
984 );
985 let accept_dt = find_param(get_params, "Accept-Datetime");
986 assert_eq!(accept_dt["in"], "header");
987 assert_eq!(accept_dt["required"], false);
988
989 let post_params = &spec["paths"]["/pricing"]["post"]["parameters"];
990 assert!(
991 has_param(post_params, "Accept-Datetime"),
992 "POST must have Accept-Datetime header"
993 );
994 }
995
996 #[test]
1001 fn test_generate_openapi_effective_reflects_specific_time() {
1002 let engine =
1003 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
1004 let effective = date(2025, 6, 15);
1005 let spec = generate_openapi_effective(&engine, false, &effective);
1006
1007 assert_eq!(spec["openapi"], "3.1.0");
1008 let version = spec["info"]["version"].as_str().unwrap();
1009 assert!(
1010 version.contains("2025-06-15"),
1011 "version string should contain the effective date, got: {}",
1012 version
1013 );
1014 }
1015
1016 #[test]
1017 fn test_effective_shows_correct_temporal_version_interface() {
1018 let engine = create_engine_with_files(vec![(
1019 "policy.lemma",
1020 r#"
1021spec policy
1022fact base: 100
1023rule discount: 10
1024
1025spec policy 2025-06-01
1026fact base: 200
1027fact premium: [boolean]
1028rule discount: 20
1029rule surcharge: 5
1030"#,
1031 )]);
1032
1033 let before = date(2025, 3, 1);
1034 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1035
1036 assert!(spec_v1["paths"]["/policy"].is_object());
1037 let v1_response = &spec_v1["components"]["schemas"]["policy_response"];
1038 assert!(
1039 v1_response["properties"]["discount"].is_object(),
1040 "v1 should have discount rule"
1041 );
1042 assert!(
1043 v1_response["properties"]["surcharge"].is_null(),
1044 "v1 must NOT have surcharge rule"
1045 );
1046 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1047 assert!(
1048 v1_request["properties"]["premium"].is_null(),
1049 "v1 must NOT have premium fact"
1050 );
1051
1052 let after = date(2025, 8, 1);
1053 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1054
1055 let v2_response = &spec_v2["components"]["schemas"]["policy_response"];
1056 assert!(
1057 v2_response["properties"]["discount"].is_object(),
1058 "v2 should have discount rule"
1059 );
1060 assert!(
1061 v2_response["properties"]["surcharge"].is_object(),
1062 "v2 should have surcharge rule"
1063 );
1064 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1065 assert!(
1066 v2_request["properties"]["premium"].is_object(),
1067 "v2 should have premium fact"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_effective_per_rule_endpoints_match_temporal_version() {
1073 let engine = create_engine_with_files(vec![(
1074 "policy.lemma",
1075 r#"
1076spec policy
1077fact base: 100
1078rule discount: 10
1079
1080spec policy 2025-06-01
1081fact base: 200
1082rule discount: 20
1083rule surcharge: 5
1084"#,
1085 )]);
1086
1087 let before = date(2025, 3, 1);
1088 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1089 let v1_response = &spec_v1["components"]["schemas"]["policy_response"];
1090 assert!(
1091 v1_response["properties"]["discount"].is_object(),
1092 "v1 should have discount rule"
1093 );
1094 assert!(
1095 v1_response["properties"]["surcharge"].is_null(),
1096 "v1 must NOT have surcharge rule"
1097 );
1098
1099 let after = date(2025, 8, 1);
1100 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1101 let v2_response = &spec_v2["components"]["schemas"]["policy_response"];
1102 assert!(
1103 v2_response["properties"]["discount"].is_object(),
1104 "v2 should have discount rule"
1105 );
1106 assert!(
1107 v2_response["properties"]["surcharge"].is_object(),
1108 "v2 should have surcharge rule"
1109 );
1110 }
1111
1112 #[test]
1113 fn test_effective_tags_reflect_temporal_version() {
1114 let engine = create_engine_with_files(vec![(
1115 "policy.lemma",
1116 r#"
1117spec policy
1118fact base: 100
1119rule discount: 10
1120
1121spec policy 2025-06-01
1122fact base: 200
1123rule discount: 20
1124rule surcharge: 5
1125"#,
1126 )]);
1127
1128 let before = date(2025, 3, 1);
1129 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1130 let v1_tags: Vec<&str> = spec_v1["tags"]
1131 .as_array()
1132 .unwrap()
1133 .iter()
1134 .map(|t| t["name"].as_str().unwrap())
1135 .collect();
1136 assert!(v1_tags.contains(&"policy"));
1137
1138 let after = date(2025, 8, 1);
1139 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1140 let v2_tags: Vec<&str> = spec_v2["tags"]
1141 .as_array()
1142 .unwrap()
1143 .iter()
1144 .map(|t| t["name"].as_str().unwrap())
1145 .collect();
1146 assert!(v2_tags.contains(&"policy"));
1147 }
1148
1149 #[test]
1154 fn test_temporal_sources_unversioned_returns_single_current() {
1155 let engine =
1156 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
1157 let sources = temporal_api_sources(&engine);
1158
1159 assert_eq!(sources.len(), 1);
1160 assert_eq!(sources[0].title, "Current");
1161 assert_eq!(sources[0].slug, "current");
1162 assert_eq!(sources[0].url, "/openapi.json");
1163 }
1164
1165 #[test]
1166 fn test_temporal_sources_versioned_returns_boundaries_plus_current() {
1167 let engine = create_engine_with_files(vec![(
1168 "policy.lemma",
1169 r#"
1170spec policy
1171fact base: 100
1172rule discount: 10
1173
1174spec policy 2025-06-01
1175fact base: 200
1176rule discount: 20
1177"#,
1178 )]);
1179
1180 let sources = temporal_api_sources(&engine);
1181
1182 assert_eq!(sources.len(), 2, "should have 1 current + 1 boundary");
1183
1184 assert_eq!(sources[0].title, "Current");
1185 assert_eq!(sources[0].slug, "current");
1186 assert_eq!(sources[0].url, "/openapi.json");
1187
1188 assert_eq!(sources[1].title, "Effective 2025-06-01");
1189 assert_eq!(sources[1].slug, "2025-06-01");
1190 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1191 }
1192
1193 #[test]
1194 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1195 let engine = create_engine_with_files(vec![
1196 (
1197 "policy.lemma",
1198 r#"
1199spec policy
1200fact base: 100
1201rule discount: 10
1202
1203spec policy 2025-06-01
1204fact base: 200
1205rule discount: 20
1206"#,
1207 ),
1208 (
1209 "rates.lemma",
1210 r#"
1211spec rates
1212fact rate: 5
1213rule total: rate * 2
1214
1215spec rates 2025-03-01
1216fact rate: 7
1217rule total: rate * 2
1218
1219spec rates 2025-06-01
1220fact rate: 9
1221rule total: rate * 2
1222"#,
1223 ),
1224 ]);
1225
1226 let sources = temporal_api_sources(&engine);
1227
1228 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1229 assert!(
1230 slugs.contains(&"2025-03-01"),
1231 "should contain rates boundary"
1232 );
1233 assert!(
1234 slugs.contains(&"2025-06-01"),
1235 "should contain shared boundary"
1236 );
1237 assert!(slugs.contains(&"current"), "should contain current");
1238 assert_eq!(slugs.len(), 3, "2 unique boundaries + current");
1239 }
1240
1241 #[test]
1242 fn test_temporal_sources_ordered_chronologically() {
1243 let engine = create_engine_with_files(vec![(
1244 "policy.lemma",
1245 r#"
1246spec policy
1247fact base: 100
1248rule discount: 10
1249
1250spec policy 2024-01-01
1251fact base: 50
1252rule discount: 5
1253
1254spec policy 2025-06-01
1255fact base: 200
1256rule discount: 20
1257"#,
1258 )]);
1259
1260 let sources = temporal_api_sources(&engine);
1261 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1262 assert_eq!(slugs, vec!["current", "2025-06-01", "2024-01-01"]);
1263 }
1264
1265 #[test]
1270 fn test_post_schema_text_with_options_has_enum() {
1271 let engine = create_engine_with_code(
1272 "spec test\nfact product: [text -> option \"A\" -> option \"B\"]\nrule result: product",
1273 );
1274 let spec = generate_openapi(&engine, false);
1275
1276 let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1277 assert!(product_prop["enum"].is_array());
1278 let enums = product_prop["enum"].as_array().unwrap();
1279 assert_eq!(enums.len(), 2);
1280 assert_eq!(enums[0], "A");
1281 assert_eq!(enums[1], "B");
1282 }
1283
1284 #[test]
1285 fn test_post_schema_boolean_is_string_with_enum() {
1286 let engine =
1287 create_engine_with_code("spec test\nfact is_active: [boolean]\nrule result: is_active");
1288 let spec = generate_openapi(&engine, false);
1289
1290 let schema = &spec["components"]["schemas"]["test_request"];
1291 let is_active = &schema["properties"]["is_active"];
1292 assert_eq!(is_active["type"], "string");
1293 assert_eq!(is_active["enum"], json!(["true", "false"]));
1294 }
1295
1296 #[test]
1297 fn test_post_schema_number_is_string() {
1298 let engine =
1299 create_engine_with_code("spec test\nfact quantity: [number]\nrule result: quantity");
1300 let spec = generate_openapi(&engine, false);
1301
1302 let schema = &spec["components"]["schemas"]["test_request"];
1303 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1304 }
1305
1306 #[test]
1307 fn test_post_schema_date_is_string() {
1308 let engine =
1309 create_engine_with_code("spec test\nfact deadline: [date]\nrule result: deadline");
1310 let spec = generate_openapi(&engine, false);
1311
1312 let schema = &spec["components"]["schemas"]["test_request"];
1313 assert_eq!(schema["properties"]["deadline"]["type"], "string");
1314 }
1315
1316 #[test]
1317 fn test_fact_with_default_is_not_required() {
1318 let engine = create_engine_with_code(
1319 "spec test\nfact quantity: 10\nfact name: [text]\nrule result: quantity",
1320 );
1321 let spec = generate_openapi(&engine, false);
1322
1323 let schema = &spec["components"]["schemas"]["test_request"];
1324 let required = schema["required"]
1325 .as_array()
1326 .expect("required should be array");
1327
1328 assert!(required.contains(&Value::String("name".to_string())));
1329 assert!(!required.contains(&Value::String("quantity".to_string())));
1330 }
1331
1332 #[test]
1333 fn test_help_and_default_in_openapi() {
1334 let engine = create_engine_with_code(
1335 r#"spec test
1336fact quantity: [number -> help "Number of items to order" -> default 10]
1337fact active: [boolean -> help "Whether the feature is enabled" -> default true]
1338rule result: quantity
1339"#,
1340 );
1341 let spec = generate_openapi(&engine, false);
1342
1343 let req_schema = &spec["components"]["schemas"]["test_request"];
1344 assert!(req_schema["properties"]["quantity"]["description"]
1345 .as_str()
1346 .unwrap()
1347 .contains("Number of items to order"));
1348 assert_eq!(
1349 req_schema["properties"]["quantity"]["default"]
1350 .as_str()
1351 .unwrap(),
1352 "10"
1353 );
1354 assert!(req_schema["properties"]["active"]["description"]
1355 .as_str()
1356 .unwrap()
1357 .contains("Whether the feature is enabled"));
1358 assert_eq!(
1359 req_schema["properties"]["active"]["default"]
1360 .as_str()
1361 .unwrap(),
1362 "true"
1363 );
1364 }
1365}