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