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