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