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