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": "Serialized ValueKind when not vetoed (numeric magnitudes as strings; quantity/ratio/calendar as {value, unit})"
445 },
446 "display": {
447 "type": "string",
448 "description": "Human-readable formatted value"
449 },
450 "vetoed": { "type": "boolean" },
451 "veto_reason": { "type": "string" },
452 "rule_type": {
453 "type": "string",
454 "description": "Result type name (e.g. number, boolean, money)"
455 },
456 "explanation": explanation
457 }
458 })
459}
460
461fn build_evaluate_response_schema(schema: &lemma::SpecSchema, rule_names: &[String]) -> Value {
463 let mut result_props = Map::new();
464 for rule_name in rule_names {
465 if schema.rules.contains_key(rule_name) {
466 result_props.insert(
467 rule_name.clone(),
468 json!({
469 "$ref": "#/components/schemas/LemmaRuleResult"
470 }),
471 );
472 }
473 }
474
475 json!({
476 "type": "object",
477 "required": ["spec", "effective", "result"],
478 "properties": {
479 "spec": {
480 "type": "string",
481 "description": "Spec set id that was evaluated"
482 },
483 "effective": {
484 "type": "string",
485 "description": "Evaluation instant used for temporal resolution (matches request instant unless overridden)"
486 },
487 "result": {
488 "type": "object",
489 "description": "Rule names to evaluation results (definition order in response; keys match ?rules= filter when set)",
490 "properties": Value::Object(result_props)
491 }
492 }
493 })
494}
495
496fn x_explanations_header_parameter() -> Value {
501 json!({
502 "name": "x-explanations",
503 "in": "header",
504 "required": false,
505 "description": "Set to request explanation objects in the response (server must be started with --explanations)",
506 "schema": { "type": "string", "default": "true" }
507 })
508}
509
510fn accept_datetime_header_parameter() -> Value {
511 json!({
512 "name": "Accept-Datetime",
513 "in": "header",
514 "required": false,
515 "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit to evaluate at the request instant (now).",
516 "schema": { "type": "string", "format": "date-time" },
517 "example": "Sat, 01 Jan 2025 00:00:00 GMT"
518 })
519}
520
521fn build_spec_path_item(
531 spec_name: &str,
532 get_response_schema_name: &str,
533 evaluate_response_schema_name: &str,
534 post_body_schema_name: &str,
535 rule_names: &[String],
536 explanations_enabled: bool,
537 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
538) -> Value {
539 let (effective_from, effective_to) = effective_range;
540
541 let get_schema_ref = json!({
542 "$ref": format!("#/components/schemas/{}", get_response_schema_name)
543 });
544 let evaluate_schema_ref = json!({
545 "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
546 });
547 let body_ref = json!({
548 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
549 });
550
551 let tag = spec_name.replace('.', "_");
552
553 let rules_example = if rule_names.is_empty() {
554 String::new()
555 } else {
556 rule_names.join(",")
557 };
558
559 let rules_param = json!({
560 "name": "rules",
561 "in": "query",
562 "required": false,
563 "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
564 "schema": { "type": "string" },
565 "example": rules_example
566 });
567
568 let mut get_parameters: Vec<Value> = vec![rules_param.clone()];
569 get_parameters.push(accept_datetime_header_parameter());
570 if explanations_enabled {
571 get_parameters.push(x_explanations_header_parameter());
572 }
573
574 let get_summary = "Schema of resolved version (spec, data, rules, meta, versions)".to_string();
575 let post_summary = "Evaluate".to_string();
576 let get_operation_id = format!("get_{}", spec_name);
577 let post_operation_id = format!("post_{}", spec_name);
578
579 let mut post_parameters: Vec<Value> = vec![rules_param];
580 post_parameters.push(accept_datetime_header_parameter());
581 if explanations_enabled {
582 post_parameters.push(x_explanations_header_parameter());
583 }
584
585 let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
586 match dt {
587 Some(d) => Value::String(d.to_string()),
588 None => Value::Null,
589 }
590 };
591
592 json!({
593 "x-effective-from": datetime_or_null(effective_from),
594 "x-effective-to": datetime_or_null(effective_to),
595 "get": {
596 "operationId": get_operation_id,
597 "summary": get_summary,
598 "tags": [tag],
599 "parameters": get_parameters,
600 "responses": {
601 "200": {
602 "description": "Schema of resolved version (spec_set_id, effective_from, data, rules, meta, versions).",
603 "headers": memento_spec_response_headers(),
604 "content": {
605 "application/json": {
606 "schema": get_schema_ref
607 }
608 }
609 },
610 "400": error_response_schema(),
611 "404": not_found_response_schema()
612 }
613 },
614 "post": {
615 "operationId": post_operation_id,
616 "summary": post_summary,
617 "tags": [tag],
618 "parameters": post_parameters,
619 "requestBody": {
620 "required": true,
621 "content": {
622 "application/x-www-form-urlencoded": {
623 "schema": body_ref
624 }
625 }
626 },
627 "responses": {
628 "200": {
629 "description": "Evaluation envelope: spec, effective, result (per-rule RuleResultJson).",
630 "headers": memento_spec_response_headers(),
631 "content": {
632 "application/json": {
633 "schema": evaluate_schema_ref
634 }
635 }
636 },
637 "400": error_response_schema(),
638 "404": not_found_response_schema()
639 }
640 }
641 })
642}
643
644fn type_help(lemma_type: &LemmaType) -> String {
650 match &lemma_type.specifications {
651 TypeSpecification::Boolean { help, .. } => help.clone(),
652 TypeSpecification::Quantity { help, .. } => help.clone(),
653 TypeSpecification::QuantityRange { help, .. } => help.clone(),
654 TypeSpecification::Number { help, .. } => help.clone(),
655 TypeSpecification::NumberRange { help, .. } => help.clone(),
656 TypeSpecification::Ratio { help, .. } => help.clone(),
657 TypeSpecification::RatioRange { help, .. } => help.clone(),
658 TypeSpecification::Text { help, .. } => help.clone(),
659 TypeSpecification::Date { help, .. } => help.clone(),
660 TypeSpecification::DateRange { help, .. } => help.clone(),
661 TypeSpecification::Time { help, .. } => help.clone(),
662 TypeSpecification::Calendar { help, .. } => help.clone(),
663 TypeSpecification::CalendarRange { help, .. } => help.clone(),
664 TypeSpecification::Veto { .. } => String::new(),
665 TypeSpecification::Undetermined => unreachable!(
666 "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
667 ),
668 }
669}
670
671fn build_post_request_schema(data: &[InputData]) -> Value {
676 let mut properties = Map::new();
677 let mut required = Vec::new();
678
679 for data in data {
680 let default_for_docs = data
681 .bound_value
682 .as_ref()
683 .or(data.suggestion_default.as_ref());
684 properties.insert(
685 data.name.clone(),
686 build_post_property_schema(&data.lemma_type, default_for_docs),
687 );
688 if data.bound_value.is_none() && data.suggestion_default.is_none() {
689 required.push(Value::String(data.name.clone()));
690 }
691 }
692
693 let mut schema = json!({
694 "type": "object",
695 "properties": Value::Object(properties)
696 });
697 if !required.is_empty() {
698 schema["required"] = Value::Array(required);
699 }
700 schema
701}
702
703fn build_post_property_schema(
704 lemma_type: &LemmaType,
705 data_value: Option<&lemma::LiteralValue>,
706) -> Value {
707 let mut schema = build_post_type_schema(lemma_type);
708
709 let help = type_help(lemma_type);
710 if !help.is_empty() {
711 schema["description"] = Value::String(help);
712 }
713
714 if let Some(v) = data_value {
715 schema["default"] = Value::String(v.display_value());
716 }
717
718 schema
719}
720
721fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
722 match &lemma_type.specifications {
723 TypeSpecification::Text { options, .. } => {
724 let mut schema = json!({ "type": "string" });
725 if !options.is_empty() {
726 schema["enum"] =
727 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
728 }
729 schema
730 }
731 TypeSpecification::Boolean { .. } => {
732 json!({ "type": "string", "enum": ["true", "false"] })
733 }
734 _ => json!({ "type": "string" }),
735 }
736}
737
738#[cfg(test)]
743mod tests {
744 use super::*;
745 use lemma::parsing::ast::DateTimeValue;
746 use lemma::SourceType;
747
748 fn create_engine_with_code(code: &str) -> Engine {
749 let mut engine = Engine::new();
750 engine
751 .load(
752 code,
753 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
754 )
755 .expect("failed to parse lemma code");
756 engine
757 }
758
759 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
760 let mut engine = Engine::new();
761 for (name, code) in files {
762 engine
763 .load(
764 code,
765 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
766 )
767 .expect("failed to parse lemma code");
768 }
769 engine
770 }
771
772 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
773 DateTimeValue {
774 year,
775 month,
776 day,
777 hour: 0,
778 minute: 0,
779 second: 0,
780 microsecond: 0,
781 timezone: None,
782 }
783 }
784
785 fn has_param(params: &Value, name: &str) -> bool {
786 params
787 .as_array()
788 .map(|a| a.iter().any(|p| p["name"] == name))
789 .unwrap_or(false)
790 }
791
792 #[test]
797 fn test_generate_openapi_x_tag_groups() {
798 let engine = create_engine_with_code(
799 "spec pricing
800 data quantity: 10
801 rule total: quantity * 2",
802 );
803 let spec = generate_openapi(&engine, false);
804
805 let groups = spec["x-tagGroups"]
806 .as_array()
807 .expect("x-tagGroups should be array");
808 assert_eq!(groups.len(), 2);
809 assert_eq!(groups[0]["name"], "Overview");
810 assert_eq!(groups[0]["tags"], json!(["Specs"]));
811 assert_eq!(groups[1]["name"], "Specs");
812 assert_eq!(groups[1]["tags"], json!(["pricing"]));
813 }
814
815 #[test]
816 fn test_spec_path_has_get_and_post() {
817 let engine = create_engine_with_code(
818 "spec pricing
819 data quantity: 10
820 rule total: quantity * 2",
821 );
822 let spec = generate_openapi(&engine, false);
823
824 assert!(
825 spec["paths"]["/pricing"].is_object(),
826 "single spec path /pricing"
827 );
828 assert!(spec["paths"]["/pricing"]["get"].is_object());
829 assert!(spec["paths"]["/pricing"]["post"].is_object());
830
831 assert_eq!(
832 spec["paths"]["/pricing"]["get"]["operationId"],
833 "get_pricing"
834 );
835 assert_eq!(
836 spec["paths"]["/pricing"]["post"]["operationId"],
837 "post_pricing"
838 );
839 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
840
841 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
842 .as_array()
843 .expect("parameters array");
844 let param_names: Vec<&str> = get_params
845 .iter()
846 .map(|p| p["name"].as_str().unwrap())
847 .collect();
848 assert!(
849 param_names.contains(&"rules"),
850 "GET must have rules query param"
851 );
852 assert!(
853 param_names.contains(&"Accept-Datetime"),
854 "GET must have Accept-Datetime header"
855 );
856
857 let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
858 ["application/json"]["schema"]["$ref"]
859 .as_str()
860 .unwrap();
861 let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
862 ["application/json"]["schema"]["$ref"]
863 .as_str()
864 .unwrap();
865 assert_eq!(get_ref, "#/components/schemas/pricing_get_response");
866 assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
867 assert_ne!(get_ref, post_ref);
868
869 let get_schema = &spec["components"]["schemas"]["pricing_get_response"];
870 assert!(get_schema["properties"]["spec_set_id"]["type"] == "string");
871 assert!(get_schema["properties"]["versions"].is_object());
872
873 let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
874 assert!(h200["headers"]["Memento-Datetime"].is_object());
875 assert!(h200["headers"]["Vary"].is_object());
876 }
877
878 #[test]
883 fn test_openapi_omits_shell_and_legacy_schema_routes() {
884 let engine = create_engine_with_code(
885 "spec pricing
886 data quantity: 10
887 rule total: quantity * 2",
888 );
889 let spec = generate_openapi(&engine, false);
890
891 let paths = spec["paths"].as_object().expect("paths object");
892 assert!(paths.contains_key("/"));
893 assert_eq!(paths["/"]["get"]["operationId"], "list");
894 assert!(!paths.contains_key("/openapi.json"));
895 assert!(!paths.contains_key("/health"));
896 assert!(!paths.contains_key("/docs"));
897 assert!(!paths.contains_key("/schema/pricing"));
898 assert!(!paths.contains_key("/schema/pricing/{rules}"));
899 assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
900 }
901
902 #[test]
903 fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
904 let engine = create_engine_with_code(
905 "spec pricing
906 data quantity: 10
907 rule total: quantity * 2",
908 );
909 let spec = generate_openapi(&engine, true);
910
911 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
912 assert!(has_param(get_params, "x-explanations"));
913
914 let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
915 assert!(rule_result["properties"]["explanation"].is_object());
916 assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
917 assert!(rule_result["properties"]["rule_type"]["type"] == "string");
918
919 let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
920 assert!(evaluate["required"]
921 .as_array()
922 .unwrap()
923 .contains(&json!("spec")));
924 assert!(evaluate["required"]
925 .as_array()
926 .unwrap()
927 .contains(&json!("effective")));
928 assert!(evaluate["required"]
929 .as_array()
930 .unwrap()
931 .contains(&json!("result")));
932 let total_ref = evaluate["properties"]["result"]["properties"]["total"]["$ref"]
933 .as_str()
934 .unwrap();
935 assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
936 }
937
938 #[test]
939 fn test_generate_openapi_multiple_specs() {
940 let engine = create_engine_with_files(vec![
941 (
942 "pricing.lemma",
943 "spec pricing
944 data quantity: 10
945 rule total: quantity * 2",
946 ),
947 (
948 "shipping.lemma",
949 "spec shipping
950 data weight: 5
951 rule cost: weight * 3",
952 ),
953 ]);
954 let spec = generate_openapi(&engine, false);
955
956 assert!(spec["paths"]["/pricing"].is_object());
957 assert!(spec["paths"]["/shipping"].is_object());
958 }
959
960 #[test]
961 fn test_nested_spec_path_schema_refs_are_valid() {
962 let engine = create_engine_with_code(
963 "spec bc
964 data x: number
965 rule result: x",
966 );
967 let spec = generate_openapi(&engine, false);
968
969 assert!(spec["paths"]["/bc"]["post"].is_object());
970 let body_ref = spec["paths"]["/bc"]["post"]["requestBody"]["content"]
971 ["application/x-www-form-urlencoded"]["schema"]["$ref"]
972 .as_str()
973 .unwrap();
974 assert_eq!(body_ref, "#/components/schemas/bc_request");
975 assert!(spec["components"]["schemas"]["bc_request"].is_object());
976 assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
977 }
978
979 #[test]
984 fn test_generate_openapi_effective_reflects_specific_time() {
985 let engine = create_engine_with_code(
986 "spec pricing
987 data quantity: 10
988 rule total: quantity * 2",
989 );
990 let effective = date(2025, 6, 15);
991 let spec = generate_openapi_effective(&engine, false, &effective);
992
993 assert_eq!(spec["openapi"], "3.1.0");
994 let version = spec["info"]["version"].as_str().unwrap();
995 assert!(
996 version.contains("2025-06-15"),
997 "version string should contain the effective date, got: {}",
998 version
999 );
1000 }
1001
1002 #[test]
1003 fn test_effective_shows_correct_temporal_version_interface() {
1004 let engine = create_engine_with_files(vec![(
1005 "policy.lemma",
1006 r#"
1007spec policy
1008data base: 100
1009rule discount: 10
1010
1011spec policy 2025-06-01
1012data base: 200
1013data premium: boolean
1014rule discount: 20
1015rule surcharge:
1016 5
1017 unless premium then 10
1018"#,
1019 )]);
1020
1021 let before = date(2025, 3, 1);
1022 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1023
1024 assert!(spec_v1["paths"]["/policy"].is_object());
1025 let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1026 let v1_result = &v1_evaluate["properties"]["result"]["properties"];
1027 assert_eq!(
1028 v1_result["discount"]["$ref"].as_str(),
1029 Some("#/components/schemas/LemmaRuleResult"),
1030 "v1 should have discount rule"
1031 );
1032 assert!(
1033 v1_result["surcharge"].is_null(),
1034 "v1 must NOT have surcharge rule"
1035 );
1036 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1037 assert!(
1038 v1_request["properties"]["premium"].is_null(),
1039 "v1 must NOT have premium data"
1040 );
1041
1042 let after = date(2025, 8, 1);
1043 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1044
1045 let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1046 let v2_result = &v2_evaluate["properties"]["result"]["properties"];
1047 assert!(
1048 v2_result["discount"]["$ref"].is_string(),
1049 "v2 should have discount rule"
1050 );
1051 assert!(
1052 v2_result["surcharge"]["$ref"].is_string(),
1053 "v2 should have surcharge rule"
1054 );
1055 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1056 assert!(
1057 v2_request["properties"]["premium"].is_object(),
1058 "v2 should have premium data"
1059 );
1060 }
1061
1062 #[test]
1071 fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1072 let engine = create_engine_with_files(vec![(
1073 "policy.lemma",
1074 r#"
1075spec policy 2025-01-01
1076data base: 10
1077rule total: base
1078
1079spec policy 2026-01-01
1080data base: 99
1081rule total: base
1082"#,
1083 )]);
1084
1085 let at_earlier = date(2025, 6, 1);
1086 let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1087 let earlier_path = &earlier_doc["paths"]["/policy"];
1088 assert_eq!(
1089 earlier_path["x-effective-from"].as_str(),
1090 Some("2025-01-01"),
1091 "earlier version effective_from on PathItem"
1092 );
1093 assert_eq!(
1094 earlier_path["x-effective-to"].as_str(),
1095 Some("2026-01-01"),
1096 "earlier version effective_to equals next version's effective_from"
1097 );
1098
1099 let at_latest = date(2026, 6, 1);
1100 let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1101 let latest_path = &latest_doc["paths"]["/policy"];
1102 assert_eq!(
1103 latest_path["x-effective-from"].as_str(),
1104 Some("2026-01-01"),
1105 "latest version effective_from on PathItem"
1106 );
1107 assert!(
1108 latest_path["x-effective-to"].is_null(),
1109 "latest version has no successor; x-effective-to must be null: {latest_path}"
1110 );
1111 }
1112
1113 #[test]
1116 fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1117 let engine = create_engine_with_code(
1118 "spec pricing
1119 data quantity: 10
1120 rule total: quantity * 2",
1121 );
1122 let document = generate_openapi(&engine, false);
1123 let path_item = &document["paths"]["/pricing"];
1124 assert!(
1125 path_item["x-effective-from"].is_null(),
1126 "unversioned spec: x-effective-from must be null: {path_item}"
1127 );
1128 assert!(
1129 path_item["x-effective-to"].is_null(),
1130 "unversioned spec: x-effective-to must be null: {path_item}"
1131 );
1132 }
1133
1134 #[test]
1139 fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1140 let engine = create_engine_with_files(vec![(
1141 "policy.lemma",
1142 r#"
1143spec policy
1144data base: 100
1145rule discount: 10
1146
1147spec policy 2025-06-01
1148data base: 200
1149rule discount: 20
1150"#,
1151 )]);
1152
1153 let sources = temporal_api_sources(&engine);
1154
1155 assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1156
1157 assert_eq!(sources[0].title, "Now");
1158 assert_eq!(sources[0].slug, NOW_SLUG);
1159 assert_eq!(sources[0].url, "/openapi.json");
1160
1161 assert_eq!(sources[1].title, "Effective 2025-06-01");
1162 assert_eq!(sources[1].slug, "2025-06-01");
1163 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1164 }
1165
1166 #[test]
1167 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1168 let engine = create_engine_with_files(vec![
1169 (
1170 "policy.lemma",
1171 r#"
1172spec policy
1173data base: 100
1174rule discount: 10
1175
1176spec policy 2025-06-01
1177data base: 200
1178rule discount: 20
1179"#,
1180 ),
1181 (
1182 "rates.lemma",
1183 r#"
1184spec rates
1185data rate: 5
1186rule total: rate * 2
1187
1188spec rates 2025-03-01
1189data rate: 7
1190rule total: rate * 2
1191
1192spec rates 2025-06-01
1193data rate: 9
1194rule total: rate * 2
1195"#,
1196 ),
1197 ]);
1198
1199 let sources = temporal_api_sources(&engine);
1200
1201 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1202 assert!(
1203 slugs.contains(&"2025-03-01"),
1204 "should contain rates boundary"
1205 );
1206 assert!(
1207 slugs.contains(&"2025-06-01"),
1208 "should contain shared boundary"
1209 );
1210 assert!(slugs.contains(&NOW_SLUG), "should contain now");
1211 assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1212 }
1213
1214 #[test]
1215 fn test_temporal_sources_ordered_chronologically() {
1216 let engine = create_engine_with_files(vec![(
1217 "policy.lemma",
1218 r#"
1219spec policy
1220data base: 100
1221rule discount: 10
1222
1223spec policy 2024-01-01
1224data base: 50
1225rule discount: 5
1226
1227spec policy 2025-06-01
1228data base: 200
1229rule discount: 20
1230"#,
1231 )]);
1232
1233 let sources = temporal_api_sources(&engine);
1234 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1235 assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1236 }
1237
1238 #[test]
1243 fn test_post_schema_text_with_options_has_enum() {
1244 let engine = create_engine_with_code(
1245 "spec test
1246 data product: text -> option \"A\" -> option \"B\"
1247 rule result: product",
1248 );
1249 let spec = generate_openapi(&engine, false);
1250
1251 let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1252 assert!(product_prop["enum"].is_array());
1253 let enums = product_prop["enum"].as_array().unwrap();
1254 assert_eq!(enums.len(), 2);
1255 assert_eq!(enums[0], "A");
1256 assert_eq!(enums[1], "B");
1257 }
1258
1259 #[test]
1260 fn test_post_schema_boolean_is_string_with_enum() {
1261 let engine = create_engine_with_code(
1262 "spec test
1263 data is_active: boolean
1264 rule result: is_active",
1265 );
1266 let spec = generate_openapi(&engine, false);
1267
1268 let schema = &spec["components"]["schemas"]["test_request"];
1269 let is_active = &schema["properties"]["is_active"];
1270 assert_eq!(is_active["type"], "string");
1271 assert_eq!(is_active["enum"], json!(["true", "false"]));
1272 }
1273
1274 #[test]
1275 fn test_post_schema_number_is_string() {
1276 let engine = create_engine_with_code(
1277 "spec test
1278 data quantity: number
1279 rule result: quantity",
1280 );
1281 let spec = generate_openapi(&engine, false);
1282
1283 let schema = &spec["components"]["schemas"]["test_request"];
1284 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1285 }
1286
1287 #[test]
1288 fn test_data_with_default_is_not_required() {
1289 let engine = create_engine_with_code(
1290 "spec test
1291 data quantity: 10
1292 data name: text
1293 rule result: quantity
1294 rule label: name",
1295 );
1296 let spec = generate_openapi(&engine, false);
1297
1298 let schema = &spec["components"]["schemas"]["test_request"];
1299 let required = schema["required"]
1300 .as_array()
1301 .expect("required should be array");
1302
1303 assert!(required.contains(&Value::String("name".to_string())));
1304 assert!(!required.contains(&Value::String("quantity".to_string())));
1305 }
1306
1307 #[test]
1308 fn test_help_and_default_in_openapi() {
1309 let engine = create_engine_with_code(
1310 r#"spec test
1311data quantity: number -> help "Number of items to order" -> default 10
1312data active: boolean -> help "Whether the feature is enabled" -> default true
1313rule result:
1314 quantity
1315 unless active then 0
1316"#,
1317 );
1318 let spec = generate_openapi(&engine, false);
1319
1320 let req_schema = &spec["components"]["schemas"]["test_request"];
1321 assert!(req_schema["properties"]["quantity"]["description"]
1322 .as_str()
1323 .unwrap()
1324 .contains("Number of items to order"));
1325 assert_eq!(
1326 req_schema["properties"]["quantity"]["default"]
1327 .as_str()
1328 .unwrap(),
1329 "10"
1330 );
1331 assert!(req_schema["properties"]["active"]["description"]
1332 .as_str()
1333 .unwrap()
1334 .contains("Whether the feature is enabled"));
1335 assert_eq!(
1336 req_schema["properties"]["active"]["default"]
1337 .as_str()
1338 .unwrap(),
1339 "true"
1340 );
1341 }
1342}