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, explanations_enabled: bool) -> Value {
91 generate_openapi_effective(engine, explanations_enabled, &DateTimeValue::now())
92}
93
94pub fn generate_openapi_effective(
105 engine: &Engine,
106 explanations_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 Ok(plan) = engine.get_plan(spec_name, Some(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, explanations_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 explanations_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| match engine.schema(name, Some(effective)) {
238 Ok(s) => {
239 let facts_count = s.facts.keys().filter(|n| !n.contains('.')).count();
240 let rules_count = s.rules.len();
241 json!({
242 "name": name,
243 "facts": facts_count,
244 "rules": rules_count
245 })
246 }
247 Err(e) => json!({
248 "name": name,
249 "schema_error": true,
250 "message": e.to_string()
251 }),
252 })
253 .collect();
254
255 json!({
256 "get": {
257 "operationId": "list",
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 "schema_error": { "type": "boolean" },
274 "message": { "type": "string" }
275 },
276 "required": ["name"]
277 }
278 },
279 "example": spec_items
280 }
281 }
282 }
283 }
284 }
285 })
286}
287
288fn health_path_item() -> Value {
289 json!({
290 "get": {
291 "operationId": "healthCheck",
292 "summary": "Health check",
293 "tags": ["Meta"],
294 "responses": {
295 "200": {
296 "description": "Server is healthy",
297 "content": {
298 "application/json": {
299 "schema": {
300 "type": "object",
301 "properties": {
302 "status": { "type": "string" },
303 "service": { "type": "string" },
304 "version": { "type": "string" }
305 },
306 "required": ["status", "service", "version"]
307 }
308 }
309 }
310 }
311 }
312 }
313 })
314}
315
316fn openapi_json_path_item() -> Value {
317 json!({
318 "get": {
319 "operationId": "getOpenApiSpec",
320 "summary": "OpenAPI 3.1 specification",
321 "tags": ["Meta"],
322 "responses": {
323 "200": {
324 "description": "OpenAPI specification as JSON",
325 "content": {
326 "application/json": {
327 "schema": { "type": "object" }
328 }
329 }
330 }
331 }
332 }
333 })
334}
335
336fn error_response_schema() -> Value {
341 json!({
342 "description": "Evaluation error",
343 "content": {
344 "application/json": {
345 "schema": {
346 "type": "object",
347 "properties": {
348 "error": { "type": "string" }
349 },
350 "required": ["error"]
351 }
352 }
353 }
354 })
355}
356
357fn not_found_response_schema() -> Value {
358 json!({
359 "description": "Spec not found",
360 "content": {
361 "application/json": {
362 "schema": {
363 "type": "object",
364 "properties": {
365 "error": { "type": "string" }
366 },
367 "required": ["error"]
368 }
369 }
370 }
371 })
372}
373
374fn x_explanations_header_parameter() -> Value {
379 json!({
380 "name": "x-explanations",
381 "in": "header",
382 "required": false,
383 "description": "Set to request explanation objects in the response (server must be started with --explanations)",
384 "schema": { "type": "string", "default": "true" }
385 })
386}
387
388fn accept_datetime_header_parameter() -> Value {
389 json!({
390 "name": "Accept-Datetime",
391 "in": "header",
392 "required": false,
393 "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit for current. Path may be spec id (name or name~hash) to pin to a content version.",
394 "schema": { "type": "string", "format": "date-time" },
395 "example": "Sat, 01 Jan 2025 00:00:00 GMT"
396 })
397}
398
399fn build_spec_path_item(
400 spec_name: &str,
401 _facts: &[InputFact],
402 response_schema_name: &str,
403 post_body_schema_name: &str,
404 rule_names: &[String],
405 explanations_enabled: bool,
406) -> Value {
407 let response_ref = json!({
408 "$ref": format!("#/components/schemas/{}", response_schema_name)
409 });
410 let body_ref = json!({
411 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
412 });
413
414 let tag = spec_name.replace('/', "_");
415
416 let rules_example = if rule_names.is_empty() {
417 String::new()
418 } else {
419 rule_names.join(",")
420 };
421
422 let rules_param = json!({
423 "name": "rules",
424 "in": "query",
425 "required": false,
426 "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
427 "schema": { "type": "string" },
428 "example": rules_example
429 });
430
431 let mut get_parameters: Vec<Value> = vec![rules_param.clone()];
432 get_parameters.push(accept_datetime_header_parameter());
433 if explanations_enabled {
434 get_parameters.push(x_explanations_header_parameter());
435 }
436
437 let get_summary = "Schema of resolved version (spec, facts, rules, meta, versions)".to_string();
438 let post_summary = "Evaluate".to_string();
439 let get_operation_id = format!("get_{}", spec_name);
440 let post_operation_id = format!("post_{}", spec_name);
441
442 let mut post_parameters: Vec<Value> = vec![rules_param];
443 post_parameters.push(accept_datetime_header_parameter());
444 if explanations_enabled {
445 post_parameters.push(x_explanations_header_parameter());
446 }
447
448 json!({
449 "get": {
450 "operationId": get_operation_id,
451 "summary": get_summary,
452 "tags": [tag],
453 "parameters": get_parameters,
454 "responses": {
455 "200": {
456 "description": "Schema of resolved version. Includes spec identity, hash, facts, rules, meta, and versions. Headers: ETag, Memento-Datetime, Vary.",
457 "content": {
458 "application/json": {
459 "schema": response_ref
460 }
461 }
462 },
463 "400": error_response_schema(),
464 "404": not_found_response_schema()
465 }
466 },
467 "post": {
468 "operationId": post_operation_id,
469 "summary": post_summary,
470 "tags": [tag],
471 "parameters": post_parameters,
472 "requestBody": {
473 "required": true,
474 "content": {
475 "application/x-www-form-urlencoded": {
476 "schema": body_ref
477 }
478 }
479 },
480 "responses": {
481 "200": {
482 "description": "Evaluation results with traceability envelope (spec, effective, hash, result). 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 }
492 }
493 })
494}
495
496fn type_help(lemma_type: &LemmaType) -> String {
502 match &lemma_type.specifications {
503 TypeSpecification::Boolean { help, .. } => help.clone(),
504 TypeSpecification::Scale { help, .. } => help.clone(),
505 TypeSpecification::Number { help, .. } => help.clone(),
506 TypeSpecification::Ratio { help, .. } => help.clone(),
507 TypeSpecification::Text { help, .. } => help.clone(),
508 TypeSpecification::Date { help, .. } => help.clone(),
509 TypeSpecification::Time { help, .. } => help.clone(),
510 TypeSpecification::Duration { help, .. } => help.clone(),
511 TypeSpecification::Veto { .. } => String::new(),
512 TypeSpecification::Undetermined => unreachable!(
513 "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
514 ),
515 }
516}
517
518fn type_default_as_string(lemma_type: &LemmaType) -> Option<String> {
520 match &lemma_type.specifications {
521 TypeSpecification::Boolean { default, .. } => default.map(|b| b.to_string()),
522 TypeSpecification::Scale { default, .. } => {
523 default.as_ref().map(|(d, u)| format!("{} {}", d, u))
524 }
525 TypeSpecification::Number { default, .. } => default.as_ref().map(|d| d.to_string()),
526 TypeSpecification::Ratio { default, .. } => default.as_ref().map(|d| d.to_string()),
527 TypeSpecification::Text { default, .. } => default.clone(),
528 TypeSpecification::Date { default, .. } => default.as_ref().map(|dt| format!("{}", dt)),
529 TypeSpecification::Time { default, .. } => default.as_ref().map(|t| format!("{}", t)),
530 TypeSpecification::Duration { default, .. } => {
531 default.as_ref().map(|(v, u)| format!("{} {}", v, u))
532 }
533 TypeSpecification::Veto { .. } => None,
534 TypeSpecification::Undetermined => unreachable!(
535 "BUG: type_default_as_string called with Undetermined sentinel type; this type must never reach OpenAPI generation"
536 ),
537 }
538}
539
540fn build_post_request_schema(facts: &[InputFact]) -> Value {
545 let mut properties = Map::new();
546 let mut required = Vec::new();
547
548 for fact in facts {
549 properties.insert(
550 fact.name.clone(),
551 build_post_property_schema(&fact.lemma_type, fact.default_value.as_ref()),
552 );
553 if fact.default_value.is_none() {
554 required.push(Value::String(fact.name.clone()));
555 }
556 }
557
558 let mut schema = json!({
559 "type": "object",
560 "properties": Value::Object(properties)
561 });
562 if !required.is_empty() {
563 schema["required"] = Value::Array(required);
564 }
565 schema
566}
567
568fn build_post_property_schema(
569 lemma_type: &LemmaType,
570 fact_value: Option<&lemma::LiteralValue>,
571) -> Value {
572 let mut schema = build_post_type_schema(lemma_type);
573
574 let help = type_help(lemma_type);
575 if !help.is_empty() {
576 schema["description"] = Value::String(help);
577 }
578
579 let default_str = fact_value
581 .map(|v| v.display_value())
582 .or_else(|| type_default_as_string(lemma_type));
583 if let Some(d) = default_str {
584 schema["default"] = Value::String(d);
585 }
586
587 schema
588}
589
590fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
591 match &lemma_type.specifications {
592 TypeSpecification::Text { options, .. } => {
593 let mut schema = json!({ "type": "string" });
594 if !options.is_empty() {
595 schema["enum"] =
596 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
597 }
598 schema
599 }
600 TypeSpecification::Boolean { .. } => {
601 json!({ "type": "string", "enum": ["true", "false"] })
602 }
603 _ => json!({ "type": "string" }),
604 }
605}
606
607fn build_response_schema(
612 schema: &lemma::SpecSchema,
613 rule_names: &[String],
614 explanations_enabled: bool,
615) -> Value {
616 let mut properties = Map::new();
617
618 let explanation_prop = explanations_enabled.then(|| {
619 json!({
620 "type": "object",
621 "description": "Explanation tree (included when x-explanations header is sent and server started with --explanations)"
622 })
623 });
624
625 for rule_name in rule_names {
626 if let Some(rule_type) = schema.rules.get(rule_name) {
627 let result_type_name = type_base_name(rule_type);
628 let mut value_props = Map::new();
629 value_props.insert(
630 "value".to_string(),
631 json!({
632 "type": "string",
633 "description": format!("Computed value (type: {})", result_type_name)
634 }),
635 );
636 if let Some(ref p) = explanation_prop {
637 value_props.insert("explanation".to_string(), p.clone());
638 }
639 let mut veto_props = Map::new();
640 veto_props.insert(
641 "veto_reason".to_string(),
642 json!({
643 "type": "string",
644 "description": "Reason the rule was vetoed (no value produced)"
645 }),
646 );
647 if let Some(ref p) = explanation_prop {
648 veto_props.insert("explanation".to_string(), p.clone());
649 }
650 let value_branch = json!({
651 "type": "object",
652 "properties": Value::Object(value_props),
653 "required": ["value"]
654 });
655 let veto_branch = json!({
656 "type": "object",
657 "properties": Value::Object(veto_props)
658 });
659 properties.insert(
660 rule_name.clone(),
661 json!({
662 "oneOf": [ value_branch, veto_branch ]
663 }),
664 );
665 }
666 }
667
668 json!({
669 "type": "object",
670 "properties": Value::Object(properties)
671 })
672}
673
674fn type_base_name(lemma_type: &LemmaType) -> String {
680 if let Some(ref name) = lemma_type.name {
681 return name.clone();
682 }
683 match &lemma_type.specifications {
684 TypeSpecification::Boolean { .. } => "boolean".to_string(),
685 TypeSpecification::Number { .. } => "number".to_string(),
686 TypeSpecification::Scale { .. } => "scale".to_string(),
687 TypeSpecification::Text { .. } => "text".to_string(),
688 TypeSpecification::Date { .. } => "date".to_string(),
689 TypeSpecification::Time { .. } => "time".to_string(),
690 TypeSpecification::Duration { .. } => "duration".to_string(),
691 TypeSpecification::Ratio { .. } => "ratio".to_string(),
692 TypeSpecification::Veto { .. } => "veto".to_string(),
693 TypeSpecification::Undetermined => unreachable!(
694 "BUG: type_base_name called with Undetermined sentinel type; this type must never reach OpenAPI generation"
695 ),
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use lemma::parsing::ast::DateTimeValue;
703 use lemma::SourceType;
704
705 fn create_engine_with_code(code: &str) -> Engine {
706 let mut engine = Engine::new();
707 engine
708 .load(code, SourceType::Labeled("test.lemma"))
709 .expect("failed to parse lemma code");
710 engine
711 }
712
713 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
714 let mut engine = Engine::new();
715 for (name, code) in files {
716 engine
717 .load(code, SourceType::Labeled(name))
718 .expect("failed to parse lemma code");
719 }
720 engine
721 }
722
723 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
724 DateTimeValue {
725 year,
726 month,
727 day,
728 hour: 0,
729 minute: 0,
730 second: 0,
731 microsecond: 0,
732 timezone: None,
733 }
734 }
735
736 fn has_param(params: &Value, name: &str) -> bool {
737 params
738 .as_array()
739 .map(|a| a.iter().any(|p| p["name"] == name))
740 .unwrap_or(false)
741 }
742
743 fn find_param<'a>(params: &'a Value, name: &str) -> &'a Value {
744 params
745 .as_array()
746 .expect("parameters should be array")
747 .iter()
748 .find(|p| p["name"] == name)
749 .unwrap_or_else(|| panic!("parameter '{}' not found", name))
750 }
751
752 #[test]
757 fn test_generate_openapi_has_required_fields() {
758 let engine =
759 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
760 let spec = generate_openapi(&engine, false);
761
762 assert_eq!(spec["openapi"], "3.1.0");
763 assert!(spec["info"]["title"].is_string());
764 assert!(spec["tags"].is_array());
765 assert!(spec["paths"].is_object());
766 assert!(spec["components"]["schemas"].is_object());
767 }
768
769 #[test]
770 fn test_generate_openapi_tags_order() {
771 let engine =
772 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
773 let spec = generate_openapi(&engine, false);
774
775 let tags = spec["tags"].as_array().expect("tags should be array");
776 let tag_names: Vec<&str> = tags.iter().map(|t| t["name"].as_str().unwrap()).collect();
777 assert_eq!(tag_names, vec!["Specs", "pricing", "Meta"]);
778 }
779
780 #[test]
781 fn test_generate_openapi_x_tag_groups() {
782 let engine =
783 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
784 let spec = generate_openapi(&engine, false);
785
786 let groups = spec["x-tagGroups"]
787 .as_array()
788 .expect("x-tagGroups should be array");
789 assert_eq!(groups.len(), 3);
790 assert_eq!(groups[0]["name"], "Overview");
791 assert_eq!(groups[0]["tags"], json!(["Specs"]));
792 assert_eq!(groups[1]["name"], "Specs");
793 assert_eq!(groups[1]["tags"], json!(["pricing"]));
794 assert_eq!(groups[2]["name"], "Meta");
795 assert_eq!(groups[2]["tags"], json!(["Meta"]));
796 }
797
798 #[test]
799 fn test_index_endpoint_uses_specs_tag() {
800 let engine =
801 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
802 let spec = generate_openapi(&engine, false);
803
804 let index_tag = &spec["paths"]["/"]["get"]["tags"][0];
805 assert_eq!(index_tag, "Specs");
806 }
807
808 #[test]
809 fn test_spec_path_has_get_and_post() {
810 let engine =
811 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
812 let spec = generate_openapi(&engine, false);
813
814 assert!(
815 spec["paths"]["/pricing"].is_object(),
816 "single spec path /pricing"
817 );
818 assert!(spec["paths"]["/pricing"]["get"].is_object());
819 assert!(spec["paths"]["/pricing"]["post"].is_object());
820
821 assert_eq!(
822 spec["paths"]["/pricing"]["get"]["operationId"],
823 "get_pricing"
824 );
825 assert_eq!(
826 spec["paths"]["/pricing"]["post"]["operationId"],
827 "post_pricing"
828 );
829 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
830
831 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
832 .as_array()
833 .expect("parameters array");
834 let param_names: Vec<&str> = get_params
835 .iter()
836 .map(|p| p["name"].as_str().unwrap())
837 .collect();
838 assert!(
839 param_names.contains(&"rules"),
840 "GET must have rules query param"
841 );
842 assert!(
843 param_names.contains(&"Accept-Datetime"),
844 "GET must have Accept-Datetime header"
845 );
846 }
847
848 #[test]
849 fn test_spec_endpoint_has_accept_datetime_and_rules() {
850 let engine =
851 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
852 let spec = generate_openapi(&engine, false);
853
854 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
855 assert!(has_param(get_params, "Accept-Datetime"));
856 assert!(has_param(get_params, "rules"));
857
858 let post_params = &spec["paths"]["/pricing"]["post"]["parameters"];
859 assert!(has_param(post_params, "Accept-Datetime"));
860 }
861
862 #[test]
863 fn test_generate_openapi_meta_routes() {
864 let engine =
865 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
866 let spec = generate_openapi(&engine, false);
867
868 assert!(spec["paths"]["/"].is_object());
869 assert!(spec["paths"]["/health"].is_object());
870 assert!(spec["paths"]["/openapi.json"].is_object());
871 assert!(spec["paths"]["/docs"].is_null());
872 }
873
874 #[test]
875 fn test_generate_openapi_spec_routes() {
876 let engine =
877 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
878 let spec = generate_openapi(&engine, false);
879
880 assert!(spec["paths"]["/pricing"].is_object());
881 assert!(spec["paths"]["/pricing"]["get"].is_object());
882 assert!(spec["paths"]["/pricing"]["post"].is_object());
883 }
884
885 #[test]
886 fn test_generate_openapi_schemas() {
887 let engine =
888 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
889 let spec = generate_openapi(&engine, false);
890
891 assert!(spec["components"]["schemas"]["pricing_response"].is_object());
892 assert!(spec["components"]["schemas"]["pricing_request"].is_object());
893 }
894
895 #[test]
896 fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
897 let engine =
898 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
899 let spec = generate_openapi(&engine, true);
900
901 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
902 assert!(has_param(get_params, "x-explanations"));
903
904 let response_schema = &spec["components"]["schemas"]["pricing_response"];
905 let total_props = &response_schema["properties"]["total"]["oneOf"];
906 let first_branch = &total_props[0]["properties"];
907 assert!(first_branch["explanation"].is_object());
908 }
909
910 #[test]
911 fn test_generate_openapi_multiple_specs() {
912 let engine = create_engine_with_files(vec![
913 (
914 "pricing.lemma",
915 "spec pricing\nfact quantity: 10\nrule total: quantity * 2",
916 ),
917 (
918 "shipping.lemma",
919 "spec shipping\nfact weight: 5\nrule cost: weight * 3",
920 ),
921 ]);
922 let spec = generate_openapi(&engine, false);
923
924 assert!(spec["paths"]["/pricing"].is_object());
925 assert!(spec["paths"]["/shipping"].is_object());
926 }
927
928 #[test]
929 fn test_nested_spec_path_schema_refs_are_valid() {
930 let engine = create_engine_with_code("spec a/b/c\nfact x: [number]\nrule result: x");
931 let spec = generate_openapi(&engine, false);
932
933 assert!(spec["paths"]["/a/b/c"]["post"].is_object());
934 let body_ref = spec["paths"]["/a/b/c"]["post"]["requestBody"]["content"]
935 ["application/x-www-form-urlencoded"]["schema"]["$ref"]
936 .as_str()
937 .unwrap();
938 assert_eq!(body_ref, "#/components/schemas/a_b_c_request");
939 assert!(spec["components"]["schemas"]["a_b_c_request"].is_object());
940 assert!(spec["components"]["schemas"]["a_b_c_request"]["properties"]["x"].is_object());
941 }
942
943 #[test]
944 fn test_spec_endpoint_has_accept_datetime_header() {
945 let engine =
946 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
947 let spec = generate_openapi(&engine, false);
948
949 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
950 assert!(
951 has_param(get_params, "Accept-Datetime"),
952 "GET must have Accept-Datetime header"
953 );
954 let accept_dt = find_param(get_params, "Accept-Datetime");
955 assert_eq!(accept_dt["in"], "header");
956 assert_eq!(accept_dt["required"], false);
957
958 let post_params = &spec["paths"]["/pricing"]["post"]["parameters"];
959 assert!(
960 has_param(post_params, "Accept-Datetime"),
961 "POST must have Accept-Datetime header"
962 );
963 }
964
965 #[test]
970 fn test_generate_openapi_effective_reflects_specific_time() {
971 let engine =
972 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
973 let effective = date(2025, 6, 15);
974 let spec = generate_openapi_effective(&engine, false, &effective);
975
976 assert_eq!(spec["openapi"], "3.1.0");
977 let version = spec["info"]["version"].as_str().unwrap();
978 assert!(
979 version.contains("2025-06-15"),
980 "version string should contain the effective date, got: {}",
981 version
982 );
983 }
984
985 #[test]
986 fn test_effective_shows_correct_temporal_version_interface() {
987 let engine = create_engine_with_files(vec![(
988 "policy.lemma",
989 r#"
990spec policy
991fact base: 100
992rule discount: 10
993
994spec policy 2025-06-01
995fact base: 200
996fact premium: [boolean]
997rule discount: 20
998rule surcharge: 5
999"#,
1000 )]);
1001
1002 let before = date(2025, 3, 1);
1003 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1004
1005 assert!(spec_v1["paths"]["/policy"].is_object());
1006 let v1_response = &spec_v1["components"]["schemas"]["policy_response"];
1007 assert!(
1008 v1_response["properties"]["discount"].is_object(),
1009 "v1 should have discount rule"
1010 );
1011 assert!(
1012 v1_response["properties"]["surcharge"].is_null(),
1013 "v1 must NOT have surcharge rule"
1014 );
1015 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1016 assert!(
1017 v1_request["properties"]["premium"].is_null(),
1018 "v1 must NOT have premium fact"
1019 );
1020
1021 let after = date(2025, 8, 1);
1022 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1023
1024 let v2_response = &spec_v2["components"]["schemas"]["policy_response"];
1025 assert!(
1026 v2_response["properties"]["discount"].is_object(),
1027 "v2 should have discount rule"
1028 );
1029 assert!(
1030 v2_response["properties"]["surcharge"].is_object(),
1031 "v2 should have surcharge rule"
1032 );
1033 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1034 assert!(
1035 v2_request["properties"]["premium"].is_object(),
1036 "v2 should have premium fact"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_effective_per_rule_endpoints_match_temporal_version() {
1042 let engine = create_engine_with_files(vec![(
1043 "policy.lemma",
1044 r#"
1045spec policy
1046fact base: 100
1047rule discount: 10
1048
1049spec policy 2025-06-01
1050fact base: 200
1051rule discount: 20
1052rule surcharge: 5
1053"#,
1054 )]);
1055
1056 let before = date(2025, 3, 1);
1057 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1058 let v1_response = &spec_v1["components"]["schemas"]["policy_response"];
1059 assert!(
1060 v1_response["properties"]["discount"].is_object(),
1061 "v1 should have discount rule"
1062 );
1063 assert!(
1064 v1_response["properties"]["surcharge"].is_null(),
1065 "v1 must NOT have surcharge rule"
1066 );
1067
1068 let after = date(2025, 8, 1);
1069 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1070 let v2_response = &spec_v2["components"]["schemas"]["policy_response"];
1071 assert!(
1072 v2_response["properties"]["discount"].is_object(),
1073 "v2 should have discount rule"
1074 );
1075 assert!(
1076 v2_response["properties"]["surcharge"].is_object(),
1077 "v2 should have surcharge rule"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_effective_tags_reflect_temporal_version() {
1083 let engine = create_engine_with_files(vec![(
1084 "policy.lemma",
1085 r#"
1086spec policy
1087fact base: 100
1088rule discount: 10
1089
1090spec policy 2025-06-01
1091fact base: 200
1092rule discount: 20
1093rule surcharge: 5
1094"#,
1095 )]);
1096
1097 let before = date(2025, 3, 1);
1098 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1099 let v1_tags: Vec<&str> = spec_v1["tags"]
1100 .as_array()
1101 .unwrap()
1102 .iter()
1103 .map(|t| t["name"].as_str().unwrap())
1104 .collect();
1105 assert!(v1_tags.contains(&"policy"));
1106
1107 let after = date(2025, 8, 1);
1108 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1109 let v2_tags: Vec<&str> = spec_v2["tags"]
1110 .as_array()
1111 .unwrap()
1112 .iter()
1113 .map(|t| t["name"].as_str().unwrap())
1114 .collect();
1115 assert!(v2_tags.contains(&"policy"));
1116 }
1117
1118 #[test]
1123 fn test_temporal_sources_unversioned_returns_single_current() {
1124 let engine =
1125 create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
1126 let sources = temporal_api_sources(&engine);
1127
1128 assert_eq!(sources.len(), 1);
1129 assert_eq!(sources[0].title, "Current");
1130 assert_eq!(sources[0].slug, "current");
1131 assert_eq!(sources[0].url, "/openapi.json");
1132 }
1133
1134 #[test]
1135 fn test_temporal_sources_versioned_returns_boundaries_plus_current() {
1136 let engine = create_engine_with_files(vec![(
1137 "policy.lemma",
1138 r#"
1139spec policy
1140fact base: 100
1141rule discount: 10
1142
1143spec policy 2025-06-01
1144fact base: 200
1145rule discount: 20
1146"#,
1147 )]);
1148
1149 let sources = temporal_api_sources(&engine);
1150
1151 assert_eq!(sources.len(), 2, "should have 1 current + 1 boundary");
1152
1153 assert_eq!(sources[0].title, "Current");
1154 assert_eq!(sources[0].slug, "current");
1155 assert_eq!(sources[0].url, "/openapi.json");
1156
1157 assert_eq!(sources[1].title, "Effective 2025-06-01");
1158 assert_eq!(sources[1].slug, "2025-06-01");
1159 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1160 }
1161
1162 #[test]
1163 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1164 let engine = create_engine_with_files(vec![
1165 (
1166 "policy.lemma",
1167 r#"
1168spec policy
1169fact base: 100
1170rule discount: 10
1171
1172spec policy 2025-06-01
1173fact base: 200
1174rule discount: 20
1175"#,
1176 ),
1177 (
1178 "rates.lemma",
1179 r#"
1180spec rates
1181fact rate: 5
1182rule total: rate * 2
1183
1184spec rates 2025-03-01
1185fact rate: 7
1186rule total: rate * 2
1187
1188spec rates 2025-06-01
1189fact rate: 9
1190rule total: rate * 2
1191"#,
1192 ),
1193 ]);
1194
1195 let sources = temporal_api_sources(&engine);
1196
1197 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1198 assert!(
1199 slugs.contains(&"2025-03-01"),
1200 "should contain rates boundary"
1201 );
1202 assert!(
1203 slugs.contains(&"2025-06-01"),
1204 "should contain shared boundary"
1205 );
1206 assert!(slugs.contains(&"current"), "should contain current");
1207 assert_eq!(slugs.len(), 3, "2 unique boundaries + current");
1208 }
1209
1210 #[test]
1211 fn test_temporal_sources_ordered_chronologically() {
1212 let engine = create_engine_with_files(vec![(
1213 "policy.lemma",
1214 r#"
1215spec policy
1216fact base: 100
1217rule discount: 10
1218
1219spec policy 2024-01-01
1220fact base: 50
1221rule discount: 5
1222
1223spec policy 2025-06-01
1224fact base: 200
1225rule discount: 20
1226"#,
1227 )]);
1228
1229 let sources = temporal_api_sources(&engine);
1230 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1231 assert_eq!(slugs, vec!["current", "2025-06-01", "2024-01-01"]);
1232 }
1233
1234 #[test]
1239 fn test_post_schema_text_with_options_has_enum() {
1240 let engine = create_engine_with_code(
1241 "spec test\nfact product: [text -> option \"A\" -> option \"B\"]\nrule result: product",
1242 );
1243 let spec = generate_openapi(&engine, false);
1244
1245 let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1246 assert!(product_prop["enum"].is_array());
1247 let enums = product_prop["enum"].as_array().unwrap();
1248 assert_eq!(enums.len(), 2);
1249 assert_eq!(enums[0], "A");
1250 assert_eq!(enums[1], "B");
1251 }
1252
1253 #[test]
1254 fn test_post_schema_boolean_is_string_with_enum() {
1255 let engine =
1256 create_engine_with_code("spec test\nfact is_active: [boolean]\nrule result: is_active");
1257 let spec = generate_openapi(&engine, false);
1258
1259 let schema = &spec["components"]["schemas"]["test_request"];
1260 let is_active = &schema["properties"]["is_active"];
1261 assert_eq!(is_active["type"], "string");
1262 assert_eq!(is_active["enum"], json!(["true", "false"]));
1263 }
1264
1265 #[test]
1266 fn test_post_schema_number_is_string() {
1267 let engine =
1268 create_engine_with_code("spec test\nfact quantity: [number]\nrule result: quantity");
1269 let spec = generate_openapi(&engine, false);
1270
1271 let schema = &spec["components"]["schemas"]["test_request"];
1272 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1273 }
1274
1275 #[test]
1276 fn test_post_schema_date_is_string() {
1277 let engine =
1278 create_engine_with_code("spec test\nfact deadline: [date]\nrule result: deadline");
1279 let spec = generate_openapi(&engine, false);
1280
1281 let schema = &spec["components"]["schemas"]["test_request"];
1282 assert_eq!(schema["properties"]["deadline"]["type"], "string");
1283 }
1284
1285 #[test]
1286 fn test_fact_with_default_is_not_required() {
1287 let engine = create_engine_with_code(
1288 "spec test\nfact quantity: 10\nfact name: [text]\nrule result: quantity",
1289 );
1290 let spec = generate_openapi(&engine, false);
1291
1292 let schema = &spec["components"]["schemas"]["test_request"];
1293 let required = schema["required"]
1294 .as_array()
1295 .expect("required should be array");
1296
1297 assert!(required.contains(&Value::String("name".to_string())));
1298 assert!(!required.contains(&Value::String("quantity".to_string())));
1299 }
1300
1301 #[test]
1302 fn test_help_and_default_in_openapi() {
1303 let engine = create_engine_with_code(
1304 r#"spec test
1305fact quantity: [number -> help "Number of items to order" -> default 10]
1306fact active: [boolean -> help "Whether the feature is enabled" -> default true]
1307rule result: quantity
1308"#,
1309 );
1310 let spec = generate_openapi(&engine, false);
1311
1312 let req_schema = &spec["components"]["schemas"]["test_request"];
1313 assert!(req_schema["properties"]["quantity"]["description"]
1314 .as_str()
1315 .unwrap()
1316 .contains("Number of items to order"));
1317 assert_eq!(
1318 req_schema["properties"]["quantity"]["default"]
1319 .as_str()
1320 .unwrap(),
1321 "10"
1322 );
1323 assert!(req_schema["properties"]["active"]["description"]
1324 .as_str()
1325 .unwrap()
1326 .contains("Whether the feature is enabled"));
1327 assert_eq!(
1328 req_schema["properties"]["active"]["default"]
1329 .as_str()
1330 .unwrap(),
1331 "true"
1332 );
1333 }
1334}