1use lemma::parsing::ast::DateTimeValue;
19use lemma::{Engine, LemmaType, TypeSpecification};
20use serde_json::{json, Map, Value};
21use std::sync::Arc;
22
23pub const NOW_SLUG: &str = "now";
25
26#[derive(Debug, Clone, serde::Serialize)]
31pub struct ApiSource {
32 pub title: String,
33 pub slug: String,
34 pub url: String,
35}
36
37pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
47 let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
48 std::collections::BTreeSet::new();
49
50 for repo in engine.list() {
51 for ss in &repo.specs {
52 for (spec, _, _) in ss.iter_with_ranges() {
53 if let Some(af) = spec.effective_from() {
54 all_boundaries.insert(af.clone());
55 }
56 }
57 }
58 }
59
60 if all_boundaries.is_empty() {
61 return vec![ApiSource {
62 title: "Now".to_string(),
63 slug: NOW_SLUG.to_string(),
64 url: "/openapi.json".to_string(),
65 }];
66 }
67
68 let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
69
70 sources.push(ApiSource {
71 title: "Now".to_string(),
72 slug: NOW_SLUG.to_string(),
73 url: "/openapi.json".to_string(),
74 });
75
76 for boundary in all_boundaries.iter().rev() {
77 let label = boundary.to_string();
78 sources.push(ApiSource {
79 title: format!("Effective {}", label),
80 slug: label.clone(),
81 url: format!("/openapi.json?effective={}", label),
82 });
83 }
84
85 sources
86}
87
88pub fn generate_openapi(engine: &Engine, explanations_enabled: bool) -> Value {
93 generate_openapi_effective(engine, explanations_enabled, &DateTimeValue::now())
94}
95
96pub fn generate_openapi_effective(
113 engine: &Engine,
114 explanations_enabled: bool,
115 effective: &DateTimeValue,
116) -> Value {
117 let mut paths = Map::new();
118 let mut components_schemas = Map::new();
119
120 components_schemas.insert(
121 "LemmaRuleResult".to_string(),
122 build_rule_result_schema(explanations_enabled),
123 );
124
125 let workspace = engine.get_workspace();
126 let effective_instant = lemma::parsing::EffectiveDate::DateTimeValue(effective.clone());
127
128 let active_specs: Vec<(
129 Arc<lemma::LemmaSpec>,
130 Option<DateTimeValue>,
131 Option<DateTimeValue>,
132 )> = workspace
133 .specs
134 .iter()
135 .filter_map(|ss| {
136 ss.spec_at(&effective_instant).map(|spec| {
137 let (from, to) = ss.effective_range(&spec);
138 (spec, from, to)
139 })
140 })
141 .collect();
142
143 let unique_spec_names: Vec<String> = active_specs
144 .iter()
145 .map(|(s, _, _)| s.name.clone())
146 .collect();
147
148 paths.insert(
149 "/".to_string(),
150 index_path_item(&unique_spec_names, engine, effective),
151 );
152
153 for (spec_arc, spec_effective_from, spec_effective_to) in &active_specs {
154 let spec_name = &spec_arc.name;
155 if let Ok(plan) = engine.get_plan(None, spec_name, Some(effective)) {
156 let schema = plan.schema();
157 let data = collect_input_data_from_schema(&schema);
158 let rule_names: Vec<String> = schema.rules.keys().cloned().collect();
159
160 let safe_name = spec_name.replace('.', "_");
161 let get_response_schema_name = format!("{}_get_response", safe_name);
162 components_schemas.insert(
163 get_response_schema_name.clone(),
164 build_get_schema_response(),
165 );
166
167 let evaluate_response_schema_name = format!("{}_evaluate_response", safe_name);
168 components_schemas.insert(
169 evaluate_response_schema_name.clone(),
170 build_evaluate_response_schema(&schema, &rule_names),
171 );
172
173 let post_body_schema_name = format!("{}_request", safe_name);
174 components_schemas.insert(
175 post_body_schema_name.clone(),
176 build_post_request_schema(&data),
177 );
178
179 let path = format!("/{}", spec_name);
180 paths.insert(
181 path,
182 build_spec_path_item(
183 spec_name,
184 &get_response_schema_name,
185 &evaluate_response_schema_name,
186 &post_body_schema_name,
187 &rule_names,
188 explanations_enabled,
189 (spec_effective_from.as_ref(), spec_effective_to.as_ref()),
190 ),
191 );
192 }
193 }
194
195 let mut tags = vec![json!({
196 "name": "Specs",
197 "description": "Simple API to retrieve the list of Lemma specs"
198 })];
199 for spec_name in &unique_spec_names {
200 let safe_tag = spec_name.replace('.', "_");
201 tags.push(json!({
202 "name": safe_tag,
203 "x-displayName": spec_name,
204 "description": format!("GET schema or POST evaluate for spec '{}'. Use ?rules= to scope.", spec_name)
205 }));
206 }
207
208 let spec_tags: Vec<Value> = unique_spec_names
209 .iter()
210 .map(|n| Value::String(n.replace('.', "_")))
211 .collect();
212
213 let tag_groups = vec![
214 json!({ "name": "Overview", "tags": ["Specs"] }),
215 json!({ "name": "Specs", "tags": spec_tags }),
216 ];
217
218 let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
219
220 json!({
221 "openapi": "3.1.0",
222 "info": {
223 "title": "Lemma API",
224 "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).\n\n**Temporal resolution.** `GET /{spec}` describes **version boundaries**: each entry in `versions` carries the half-open `[effective_from, effective_to)` validity range of a temporal version. `POST /{spec}` treats the request's effective instant (from the `Accept-Datetime` header, or the evaluation envelope's `effective` field) as the **evaluation instant** used to pick the active version and compute the result.",
225 "version": version_label
226 },
227 "tags": tags,
228 "x-tagGroups": tag_groups,
229 "paths": Value::Object(paths),
230 "components": {
231 "schemas": Value::Object(components_schemas)
232 }
233 })
234}
235
236struct InputData {
238 name: String,
240 lemma_type: LemmaType,
242 bound_value: Option<lemma::LiteralValue>,
244 suggestion_default: Option<lemma::LiteralValue>,
246}
247
248fn collect_input_data_from_schema(schema: &lemma::SpecSchema) -> Vec<InputData> {
253 schema
254 .data
255 .iter()
256 .filter(|(name, _)| !name.contains('.'))
257 .map(|(name, entry)| InputData {
258 name: name.clone(),
259 lemma_type: entry.lemma_type.clone(),
260 bound_value: entry.bound_value.clone(),
261 suggestion_default: entry.default.clone(),
262 })
263 .collect()
264}
265
266fn index_path_item(spec_names: &[String], engine: &Engine, effective: &DateTimeValue) -> Value {
271 let spec_items: Vec<Value> = spec_names
272 .iter()
273 .map(|name| match engine.schema(None, name, Some(effective)) {
274 Ok(s) => {
275 let data_count = s.data.keys().filter(|n| !n.contains('.')).count();
276 let rules_count = s.rules.len();
277 json!({
278 "name": name,
279 "data": data_count,
280 "rules": rules_count
281 })
282 }
283 Err(e) => json!({
284 "name": name,
285 "schema_error": true,
286 "message": e.to_string()
287 }),
288 })
289 .collect();
290
291 json!({
292 "get": {
293 "operationId": "list",
294 "summary": "List all available specs",
295 "tags": ["Specs"],
296 "responses": {
297 "200": {
298 "description": "List of loaded Lemma specs",
299 "content": {
300 "application/json": {
301 "schema": {
302 "type": "array",
303 "items": {
304 "type": "object",
305 "properties": {
306 "name": { "type": "string" },
307 "data": { "type": "integer" },
308 "rules": { "type": "integer" },
309 "schema_error": { "type": "boolean" },
310 "message": { "type": "string" }
311 },
312 "required": ["name"]
313 }
314 },
315 "example": spec_items
316 }
317 }
318 }
319 }
320 }
321 })
322}
323
324fn error_response_schema() -> Value {
329 json!({
330 "description": "Evaluation error",
331 "content": {
332 "application/json": {
333 "schema": {
334 "type": "object",
335 "properties": {
336 "error": { "type": "string" }
337 },
338 "required": ["error"]
339 }
340 }
341 }
342 })
343}
344
345fn not_found_response_schema() -> Value {
346 json!({
347 "description": "Spec not found",
348 "content": {
349 "application/json": {
350 "schema": {
351 "type": "object",
352 "properties": {
353 "error": { "type": "string" }
354 },
355 "required": ["error"]
356 }
357 }
358 }
359 })
360}
361
362fn memento_spec_response_headers() -> Value {
363 json!({
364 "Memento-Datetime": {
365 "description": "RFC 7089: datetime of the resolved spec version (absent for unversioned specs)",
366 "schema": { "type": "string" }
367 },
368 "Vary": {
369 "description": "Indicates negotiation on Accept-Datetime",
370 "schema": { "type": "string", "example": "Accept-Datetime" }
371 }
372 })
373}
374
375fn build_get_schema_response() -> Value {
377 json!({
378 "type": "object",
379 "required": ["spec_set_id", "data", "rules", "meta", "versions"],
380 "properties": {
381 "spec_set_id": {
382 "type": "string",
383 "description": "Spec set identifier (path segments, e.g. org/product/pricing)"
384 },
385 "effective_from": {
386 "type": ["string", "null"],
387 "description": "Effective-from of the resolved temporal version, if any"
388 },
389 "data": {
390 "type": "object",
391 "description": "Input data names mapped to type metadata and optional defaults",
392 "additionalProperties": true
393 },
394 "rules": {
395 "type": "object",
396 "description": "Rule names mapped to result types (scoped by ?rules= when provided)",
397 "additionalProperties": true
398 },
399 "meta": {
400 "type": "object",
401 "description": "Spec metadata key/value pairs",
402 "additionalProperties": true
403 },
404 "versions": {
405 "type": "array",
406 "description": "All loaded temporal versions for this spec name, each with a half-open [effective_from, effective_to) range",
407 "items": {
408 "type": "object",
409 "required": ["effective_from", "effective_to"],
410 "properties": {
411 "effective_from": {
412 "type": ["string", "null"],
413 "description": "Start of validity for this version; null when unbounded (no earlier version exists)"
414 },
415 "effective_to": {
416 "type": ["string", "null"],
417 "description": "Exclusive end of validity (same instant as the next version's effective_from); null when this is the latest version and has no successor"
418 }
419 }
420 }
421 }
422 }
423 })
424}
425
426fn build_rule_result_schema(explanations_enabled: bool) -> Value {
428 let mut explanation = json!({
429 "type": "object",
430 "description": "Structured explanation tree when explanations are enabled"
431 });
432 if explanations_enabled {
433 explanation["description"] = Value::String(
434 "Structured explanation tree (present when x-explanations is sent and server uses --explanations)"
435 .to_string(),
436 );
437 }
438
439 json!({
440 "type": "object",
441 "required": ["vetoed", "rule_type"],
442 "properties": {
443 "value": {
444 "description": "Native JSON value when not vetoed (boolean, number, string, array, object)"
445 },
446 "unit": {
447 "type": "string",
448 "description": "Unit for scale/duration results (e.g. currency code, hours)"
449 },
450 "display": {
451 "type": "string",
452 "description": "Human-readable formatted value"
453 },
454 "vetoed": { "type": "boolean" },
455 "veto_reason": { "type": "string" },
456 "rule_type": {
457 "type": "string",
458 "description": "Result type name (e.g. number, boolean, money)"
459 },
460 "explanation": explanation
461 }
462 })
463}
464
465fn build_evaluate_response_schema(schema: &lemma::SpecSchema, rule_names: &[String]) -> Value {
467 let mut result_props = Map::new();
468 for rule_name in rule_names {
469 if schema.rules.contains_key(rule_name) {
470 result_props.insert(
471 rule_name.clone(),
472 json!({
473 "$ref": "#/components/schemas/LemmaRuleResult"
474 }),
475 );
476 }
477 }
478
479 json!({
480 "type": "object",
481 "required": ["spec", "effective", "result"],
482 "properties": {
483 "spec": {
484 "type": "string",
485 "description": "Spec set id that was evaluated"
486 },
487 "effective": {
488 "type": "string",
489 "description": "Evaluation instant used for temporal resolution (matches request instant unless overridden)"
490 },
491 "result": {
492 "type": "object",
493 "description": "Rule names to evaluation results (definition order in response; keys match ?rules= filter when set)",
494 "properties": Value::Object(result_props)
495 }
496 }
497 })
498}
499
500fn x_explanations_header_parameter() -> Value {
505 json!({
506 "name": "x-explanations",
507 "in": "header",
508 "required": false,
509 "description": "Set to request explanation objects in the response (server must be started with --explanations)",
510 "schema": { "type": "string", "default": "true" }
511 })
512}
513
514fn accept_datetime_header_parameter() -> Value {
515 json!({
516 "name": "Accept-Datetime",
517 "in": "header",
518 "required": false,
519 "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit to evaluate at the request instant (now).",
520 "schema": { "type": "string", "format": "date-time" },
521 "example": "Sat, 01 Jan 2025 00:00:00 GMT"
522 })
523}
524
525fn build_spec_path_item(
535 spec_name: &str,
536 get_response_schema_name: &str,
537 evaluate_response_schema_name: &str,
538 post_body_schema_name: &str,
539 rule_names: &[String],
540 explanations_enabled: bool,
541 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
542) -> Value {
543 let (effective_from, effective_to) = effective_range;
544
545 let get_schema_ref = json!({
546 "$ref": format!("#/components/schemas/{}", get_response_schema_name)
547 });
548 let evaluate_schema_ref = json!({
549 "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
550 });
551 let body_ref = json!({
552 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
553 });
554
555 let tag = spec_name.replace('.', "_");
556
557 let rules_example = if rule_names.is_empty() {
558 String::new()
559 } else {
560 rule_names.join(",")
561 };
562
563 let rules_param = json!({
564 "name": "rules",
565 "in": "query",
566 "required": false,
567 "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
568 "schema": { "type": "string" },
569 "example": rules_example
570 });
571
572 let mut get_parameters: Vec<Value> = vec![rules_param.clone()];
573 get_parameters.push(accept_datetime_header_parameter());
574 if explanations_enabled {
575 get_parameters.push(x_explanations_header_parameter());
576 }
577
578 let get_summary = "Schema of resolved version (spec, data, rules, meta, versions)".to_string();
579 let post_summary = "Evaluate".to_string();
580 let get_operation_id = format!("get_{}", spec_name);
581 let post_operation_id = format!("post_{}", spec_name);
582
583 let mut post_parameters: Vec<Value> = vec![rules_param];
584 post_parameters.push(accept_datetime_header_parameter());
585 if explanations_enabled {
586 post_parameters.push(x_explanations_header_parameter());
587 }
588
589 let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
590 match dt {
591 Some(d) => Value::String(d.to_string()),
592 None => Value::Null,
593 }
594 };
595
596 json!({
597 "x-effective-from": datetime_or_null(effective_from),
598 "x-effective-to": datetime_or_null(effective_to),
599 "get": {
600 "operationId": get_operation_id,
601 "summary": get_summary,
602 "tags": [tag],
603 "parameters": get_parameters,
604 "responses": {
605 "200": {
606 "description": "Schema of resolved version (spec_set_id, effective_from, data, rules, meta, versions).",
607 "headers": memento_spec_response_headers(),
608 "content": {
609 "application/json": {
610 "schema": get_schema_ref
611 }
612 }
613 },
614 "400": error_response_schema(),
615 "404": not_found_response_schema()
616 }
617 },
618 "post": {
619 "operationId": post_operation_id,
620 "summary": post_summary,
621 "tags": [tag],
622 "parameters": post_parameters,
623 "requestBody": {
624 "required": true,
625 "content": {
626 "application/x-www-form-urlencoded": {
627 "schema": body_ref
628 }
629 }
630 },
631 "responses": {
632 "200": {
633 "description": "Evaluation envelope: spec, effective, result (per-rule RuleResultJson).",
634 "headers": memento_spec_response_headers(),
635 "content": {
636 "application/json": {
637 "schema": evaluate_schema_ref
638 }
639 }
640 },
641 "400": error_response_schema(),
642 "404": not_found_response_schema()
643 }
644 }
645 })
646}
647
648fn type_help(lemma_type: &LemmaType) -> String {
654 match &lemma_type.specifications {
655 TypeSpecification::Boolean { help, .. } => help.clone(),
656 TypeSpecification::Scale { help, .. } => help.clone(),
657 TypeSpecification::Number { help, .. } => help.clone(),
658 TypeSpecification::Ratio { help, .. } => help.clone(),
659 TypeSpecification::Text { help, .. } => help.clone(),
660 TypeSpecification::Date { help, .. } => help.clone(),
661 TypeSpecification::Time { help, .. } => help.clone(),
662 TypeSpecification::Duration { help, .. } => help.clone(),
663 TypeSpecification::Veto { .. } => String::new(),
664 TypeSpecification::Undetermined => unreachable!(
665 "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
666 ),
667 }
668}
669
670fn build_post_request_schema(data: &[InputData]) -> Value {
675 let mut properties = Map::new();
676 let mut required = Vec::new();
677
678 for data in data {
679 let default_for_docs = data
680 .bound_value
681 .as_ref()
682 .or(data.suggestion_default.as_ref());
683 properties.insert(
684 data.name.clone(),
685 build_post_property_schema(&data.lemma_type, default_for_docs),
686 );
687 if data.bound_value.is_none() && data.suggestion_default.is_none() {
688 required.push(Value::String(data.name.clone()));
689 }
690 }
691
692 let mut schema = json!({
693 "type": "object",
694 "properties": Value::Object(properties)
695 });
696 if !required.is_empty() {
697 schema["required"] = Value::Array(required);
698 }
699 schema
700}
701
702fn build_post_property_schema(
703 lemma_type: &LemmaType,
704 data_value: Option<&lemma::LiteralValue>,
705) -> Value {
706 let mut schema = build_post_type_schema(lemma_type);
707
708 let help = type_help(lemma_type);
709 if !help.is_empty() {
710 schema["description"] = Value::String(help);
711 }
712
713 if let Some(v) = data_value {
714 schema["default"] = Value::String(v.display_value());
715 }
716
717 schema
718}
719
720fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
721 match &lemma_type.specifications {
722 TypeSpecification::Text { options, .. } => {
723 let mut schema = json!({ "type": "string" });
724 if !options.is_empty() {
725 schema["enum"] =
726 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
727 }
728 schema
729 }
730 TypeSpecification::Boolean { .. } => {
731 json!({ "type": "string", "enum": ["true", "false"] })
732 }
733 _ => json!({ "type": "string" }),
734 }
735}
736
737#[cfg(test)]
742mod tests {
743 use super::*;
744 use lemma::parsing::ast::DateTimeValue;
745 use lemma::SourceType;
746
747 fn create_engine_with_code(code: &str) -> Engine {
748 let mut engine = Engine::new();
749 engine
750 .load(
751 code,
752 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
753 )
754 .expect("failed to parse lemma code");
755 engine
756 }
757
758 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
759 let mut engine = Engine::new();
760 for (name, code) in files {
761 engine
762 .load(
763 code,
764 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
765 )
766 .expect("failed to parse lemma code");
767 }
768 engine
769 }
770
771 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
772 DateTimeValue {
773 year,
774 month,
775 day,
776 hour: 0,
777 minute: 0,
778 second: 0,
779 microsecond: 0,
780 timezone: None,
781 }
782 }
783
784 fn has_param(params: &Value, name: &str) -> bool {
785 params
786 .as_array()
787 .map(|a| a.iter().any(|p| p["name"] == name))
788 .unwrap_or(false)
789 }
790
791 #[test]
796 fn test_generate_openapi_x_tag_groups() {
797 let engine = create_engine_with_code(
798 "spec pricing
799 data quantity: 10
800 rule total: quantity * 2",
801 );
802 let spec = generate_openapi(&engine, false);
803
804 let groups = spec["x-tagGroups"]
805 .as_array()
806 .expect("x-tagGroups should be array");
807 assert_eq!(groups.len(), 2);
808 assert_eq!(groups[0]["name"], "Overview");
809 assert_eq!(groups[0]["tags"], json!(["Specs"]));
810 assert_eq!(groups[1]["name"], "Specs");
811 assert_eq!(groups[1]["tags"], json!(["pricing"]));
812 }
813
814 #[test]
815 fn test_spec_path_has_get_and_post() {
816 let engine = create_engine_with_code(
817 "spec pricing
818 data quantity: 10
819 rule total: quantity * 2",
820 );
821 let spec = generate_openapi(&engine, false);
822
823 assert!(
824 spec["paths"]["/pricing"].is_object(),
825 "single spec path /pricing"
826 );
827 assert!(spec["paths"]["/pricing"]["get"].is_object());
828 assert!(spec["paths"]["/pricing"]["post"].is_object());
829
830 assert_eq!(
831 spec["paths"]["/pricing"]["get"]["operationId"],
832 "get_pricing"
833 );
834 assert_eq!(
835 spec["paths"]["/pricing"]["post"]["operationId"],
836 "post_pricing"
837 );
838 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
839
840 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
841 .as_array()
842 .expect("parameters array");
843 let param_names: Vec<&str> = get_params
844 .iter()
845 .map(|p| p["name"].as_str().unwrap())
846 .collect();
847 assert!(
848 param_names.contains(&"rules"),
849 "GET must have rules query param"
850 );
851 assert!(
852 param_names.contains(&"Accept-Datetime"),
853 "GET must have Accept-Datetime header"
854 );
855
856 let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
857 ["application/json"]["schema"]["$ref"]
858 .as_str()
859 .unwrap();
860 let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
861 ["application/json"]["schema"]["$ref"]
862 .as_str()
863 .unwrap();
864 assert_eq!(get_ref, "#/components/schemas/pricing_get_response");
865 assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
866 assert_ne!(get_ref, post_ref);
867
868 let get_schema = &spec["components"]["schemas"]["pricing_get_response"];
869 assert!(get_schema["properties"]["spec_set_id"]["type"] == "string");
870 assert!(get_schema["properties"]["versions"].is_object());
871
872 let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
873 assert!(h200["headers"]["Memento-Datetime"].is_object());
874 assert!(h200["headers"]["Vary"].is_object());
875 }
876
877 #[test]
882 fn test_openapi_omits_shell_and_legacy_schema_routes() {
883 let engine = create_engine_with_code(
884 "spec pricing
885 data quantity: 10
886 rule total: quantity * 2",
887 );
888 let spec = generate_openapi(&engine, false);
889
890 let paths = spec["paths"].as_object().expect("paths object");
891 assert!(paths.contains_key("/"));
892 assert_eq!(paths["/"]["get"]["operationId"], "list");
893 assert!(!paths.contains_key("/openapi.json"));
894 assert!(!paths.contains_key("/health"));
895 assert!(!paths.contains_key("/docs"));
896 assert!(!paths.contains_key("/schema/pricing"));
897 assert!(!paths.contains_key("/schema/pricing/{rules}"));
898 assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
899 }
900
901 #[test]
902 fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
903 let engine = create_engine_with_code(
904 "spec pricing
905 data quantity: 10
906 rule total: quantity * 2",
907 );
908 let spec = generate_openapi(&engine, true);
909
910 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
911 assert!(has_param(get_params, "x-explanations"));
912
913 let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
914 assert!(rule_result["properties"]["explanation"].is_object());
915 assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
916 assert!(rule_result["properties"]["rule_type"]["type"] == "string");
917
918 let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
919 assert!(evaluate["required"]
920 .as_array()
921 .unwrap()
922 .contains(&json!("spec")));
923 assert!(evaluate["required"]
924 .as_array()
925 .unwrap()
926 .contains(&json!("effective")));
927 assert!(evaluate["required"]
928 .as_array()
929 .unwrap()
930 .contains(&json!("result")));
931 let total_ref = evaluate["properties"]["result"]["properties"]["total"]["$ref"]
932 .as_str()
933 .unwrap();
934 assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
935 }
936
937 #[test]
938 fn test_generate_openapi_multiple_specs() {
939 let engine = create_engine_with_files(vec![
940 (
941 "pricing.lemma",
942 "spec pricing
943 data quantity: 10
944 rule total: quantity * 2",
945 ),
946 (
947 "shipping.lemma",
948 "spec shipping
949 data weight: 5
950 rule 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(
962 "spec bc
963 data x: number
964 rule result: x",
965 );
966 let spec = generate_openapi(&engine, false);
967
968 assert!(spec["paths"]["/bc"]["post"].is_object());
969 let body_ref = spec["paths"]["/bc"]["post"]["requestBody"]["content"]
970 ["application/x-www-form-urlencoded"]["schema"]["$ref"]
971 .as_str()
972 .unwrap();
973 assert_eq!(body_ref, "#/components/schemas/bc_request");
974 assert!(spec["components"]["schemas"]["bc_request"].is_object());
975 assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
976 }
977
978 #[test]
983 fn test_generate_openapi_effective_reflects_specific_time() {
984 let engine = create_engine_with_code(
985 "spec pricing
986 data quantity: 10
987 rule total: quantity * 2",
988 );
989 let effective = date(2025, 6, 15);
990 let spec = generate_openapi_effective(&engine, false, &effective);
991
992 assert_eq!(spec["openapi"], "3.1.0");
993 let version = spec["info"]["version"].as_str().unwrap();
994 assert!(
995 version.contains("2025-06-15"),
996 "version string should contain the effective date, got: {}",
997 version
998 );
999 }
1000
1001 #[test]
1002 fn test_effective_shows_correct_temporal_version_interface() {
1003 let engine = create_engine_with_files(vec![(
1004 "policy.lemma",
1005 r#"
1006spec policy
1007data base: 100
1008rule discount: 10
1009
1010spec policy 2025-06-01
1011data base: 200
1012data premium: boolean
1013rule discount: 20
1014rule surcharge:
1015 5
1016 unless premium then 10
1017"#,
1018 )]);
1019
1020 let before = date(2025, 3, 1);
1021 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1022
1023 assert!(spec_v1["paths"]["/policy"].is_object());
1024 let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1025 let v1_result = &v1_evaluate["properties"]["result"]["properties"];
1026 assert_eq!(
1027 v1_result["discount"]["$ref"].as_str(),
1028 Some("#/components/schemas/LemmaRuleResult"),
1029 "v1 should have discount rule"
1030 );
1031 assert!(
1032 v1_result["surcharge"].is_null(),
1033 "v1 must NOT have surcharge rule"
1034 );
1035 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1036 assert!(
1037 v1_request["properties"]["premium"].is_null(),
1038 "v1 must NOT have premium data"
1039 );
1040
1041 let after = date(2025, 8, 1);
1042 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1043
1044 let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1045 let v2_result = &v2_evaluate["properties"]["result"]["properties"];
1046 assert!(
1047 v2_result["discount"]["$ref"].is_string(),
1048 "v2 should have discount rule"
1049 );
1050 assert!(
1051 v2_result["surcharge"]["$ref"].is_string(),
1052 "v2 should have surcharge rule"
1053 );
1054 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1055 assert!(
1056 v2_request["properties"]["premium"].is_object(),
1057 "v2 should have premium data"
1058 );
1059 }
1060
1061 #[test]
1070 fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1071 let engine = create_engine_with_files(vec![(
1072 "policy.lemma",
1073 r#"
1074spec policy 2025-01-01
1075data base: 10
1076rule total: base
1077
1078spec policy 2026-01-01
1079data base: 99
1080rule total: base
1081"#,
1082 )]);
1083
1084 let at_earlier = date(2025, 6, 1);
1085 let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1086 let earlier_path = &earlier_doc["paths"]["/policy"];
1087 assert_eq!(
1088 earlier_path["x-effective-from"].as_str(),
1089 Some("2025-01-01"),
1090 "earlier version effective_from on PathItem"
1091 );
1092 assert_eq!(
1093 earlier_path["x-effective-to"].as_str(),
1094 Some("2026-01-01"),
1095 "earlier version effective_to equals next version's effective_from"
1096 );
1097
1098 let at_latest = date(2026, 6, 1);
1099 let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1100 let latest_path = &latest_doc["paths"]["/policy"];
1101 assert_eq!(
1102 latest_path["x-effective-from"].as_str(),
1103 Some("2026-01-01"),
1104 "latest version effective_from on PathItem"
1105 );
1106 assert!(
1107 latest_path["x-effective-to"].is_null(),
1108 "latest version has no successor; x-effective-to must be null: {latest_path}"
1109 );
1110 }
1111
1112 #[test]
1115 fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1116 let engine = create_engine_with_code(
1117 "spec pricing
1118 data quantity: 10
1119 rule total: quantity * 2",
1120 );
1121 let document = generate_openapi(&engine, false);
1122 let path_item = &document["paths"]["/pricing"];
1123 assert!(
1124 path_item["x-effective-from"].is_null(),
1125 "unversioned spec: x-effective-from must be null: {path_item}"
1126 );
1127 assert!(
1128 path_item["x-effective-to"].is_null(),
1129 "unversioned spec: x-effective-to must be null: {path_item}"
1130 );
1131 }
1132
1133 #[test]
1138 fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1139 let engine = create_engine_with_files(vec![(
1140 "policy.lemma",
1141 r#"
1142spec policy
1143data base: 100
1144rule discount: 10
1145
1146spec policy 2025-06-01
1147data base: 200
1148rule discount: 20
1149"#,
1150 )]);
1151
1152 let sources = temporal_api_sources(&engine);
1153
1154 assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1155
1156 assert_eq!(sources[0].title, "Now");
1157 assert_eq!(sources[0].slug, NOW_SLUG);
1158 assert_eq!(sources[0].url, "/openapi.json");
1159
1160 assert_eq!(sources[1].title, "Effective 2025-06-01");
1161 assert_eq!(sources[1].slug, "2025-06-01");
1162 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1163 }
1164
1165 #[test]
1166 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1167 let engine = create_engine_with_files(vec![
1168 (
1169 "policy.lemma",
1170 r#"
1171spec policy
1172data base: 100
1173rule discount: 10
1174
1175spec policy 2025-06-01
1176data base: 200
1177rule discount: 20
1178"#,
1179 ),
1180 (
1181 "rates.lemma",
1182 r#"
1183spec rates
1184data rate: 5
1185rule total: rate * 2
1186
1187spec rates 2025-03-01
1188data rate: 7
1189rule total: rate * 2
1190
1191spec rates 2025-06-01
1192data rate: 9
1193rule total: rate * 2
1194"#,
1195 ),
1196 ]);
1197
1198 let sources = temporal_api_sources(&engine);
1199
1200 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1201 assert!(
1202 slugs.contains(&"2025-03-01"),
1203 "should contain rates boundary"
1204 );
1205 assert!(
1206 slugs.contains(&"2025-06-01"),
1207 "should contain shared boundary"
1208 );
1209 assert!(slugs.contains(&NOW_SLUG), "should contain now");
1210 assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1211 }
1212
1213 #[test]
1214 fn test_temporal_sources_ordered_chronologically() {
1215 let engine = create_engine_with_files(vec![(
1216 "policy.lemma",
1217 r#"
1218spec policy
1219data base: 100
1220rule discount: 10
1221
1222spec policy 2024-01-01
1223data base: 50
1224rule discount: 5
1225
1226spec policy 2025-06-01
1227data base: 200
1228rule discount: 20
1229"#,
1230 )]);
1231
1232 let sources = temporal_api_sources(&engine);
1233 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1234 assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1235 }
1236
1237 #[test]
1242 fn test_post_schema_text_with_options_has_enum() {
1243 let engine = create_engine_with_code(
1244 "spec test
1245 data product: text -> option \"A\" -> option \"B\"
1246 rule result: product",
1247 );
1248 let spec = generate_openapi(&engine, false);
1249
1250 let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1251 assert!(product_prop["enum"].is_array());
1252 let enums = product_prop["enum"].as_array().unwrap();
1253 assert_eq!(enums.len(), 2);
1254 assert_eq!(enums[0], "A");
1255 assert_eq!(enums[1], "B");
1256 }
1257
1258 #[test]
1259 fn test_post_schema_boolean_is_string_with_enum() {
1260 let engine = create_engine_with_code(
1261 "spec test
1262 data is_active: boolean
1263 rule result: is_active",
1264 );
1265 let spec = generate_openapi(&engine, false);
1266
1267 let schema = &spec["components"]["schemas"]["test_request"];
1268 let is_active = &schema["properties"]["is_active"];
1269 assert_eq!(is_active["type"], "string");
1270 assert_eq!(is_active["enum"], json!(["true", "false"]));
1271 }
1272
1273 #[test]
1274 fn test_post_schema_number_is_string() {
1275 let engine = create_engine_with_code(
1276 "spec test
1277 data quantity: number
1278 rule result: quantity",
1279 );
1280 let spec = generate_openapi(&engine, false);
1281
1282 let schema = &spec["components"]["schemas"]["test_request"];
1283 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1284 }
1285
1286 #[test]
1287 fn test_data_with_default_is_not_required() {
1288 let engine = create_engine_with_code(
1289 "spec test
1290 data quantity: 10
1291 data name: text
1292 rule result: quantity
1293 rule label: name",
1294 );
1295 let spec = generate_openapi(&engine, false);
1296
1297 let schema = &spec["components"]["schemas"]["test_request"];
1298 let required = schema["required"]
1299 .as_array()
1300 .expect("required should be array");
1301
1302 assert!(required.contains(&Value::String("name".to_string())));
1303 assert!(!required.contains(&Value::String("quantity".to_string())));
1304 }
1305
1306 #[test]
1307 fn test_help_and_default_in_openapi() {
1308 let engine = create_engine_with_code(
1309 r#"spec test
1310data quantity: number -> help "Number of items to order" -> default 10
1311data active: boolean -> help "Whether the feature is enabled" -> default true
1312rule result:
1313 quantity
1314 unless active then 0
1315"#,
1316 );
1317 let spec = generate_openapi(&engine, false);
1318
1319 let req_schema = &spec["components"]["schemas"]["test_request"];
1320 assert!(req_schema["properties"]["quantity"]["description"]
1321 .as_str()
1322 .unwrap()
1323 .contains("Number of items to order"));
1324 assert_eq!(
1325 req_schema["properties"]["quantity"]["default"]
1326 .as_str()
1327 .unwrap(),
1328 "10"
1329 );
1330 assert!(req_schema["properties"]["active"]["description"]
1331 .as_str()
1332 .unwrap()
1333 .contains("Whether the feature is enabled"));
1334 assert_eq!(
1335 req_schema["properties"]["active"]["default"]
1336 .as_str()
1337 .unwrap(),
1338 "true"
1339 );
1340 }
1341}