1use lemma::{DateTimeValue, Engine, LemmaType, ListedSpec, TypeSpecification};
19use serde_json::{json, Map, Value};
20
21pub const NOW_SLUG: &str = "now";
23
24#[derive(Debug, Clone, serde::Serialize)]
29pub struct ApiSource {
30 pub title: String,
31 pub slug: String,
32 pub url: String,
33}
34
35pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
45 let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
46 std::collections::BTreeSet::new();
47
48 for repo in engine.list() {
49 for ls in &repo.specs {
50 if let Some(af) = &ls.effective_from {
51 all_boundaries.insert(af.clone());
52 }
53 }
54 }
55
56 if all_boundaries.is_empty() {
57 return vec![ApiSource {
58 title: "Now".to_string(),
59 slug: NOW_SLUG.to_string(),
60 url: "/openapi.json".to_string(),
61 }];
62 }
63
64 let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
65
66 sources.push(ApiSource {
67 title: "Now".to_string(),
68 slug: NOW_SLUG.to_string(),
69 url: "/openapi.json".to_string(),
70 });
71
72 for boundary in all_boundaries.iter().rev() {
73 let label = boundary.to_string();
74 sources.push(ApiSource {
75 title: format!("Effective {}", label),
76 slug: label.clone(),
77 url: format!("/openapi.json?effective={}", label),
78 });
79 }
80
81 sources
82}
83
84pub fn generate_openapi(engine: &Engine, explanations_enabled: bool) -> Value {
89 generate_openapi_effective(engine, explanations_enabled, &DateTimeValue::now())
90}
91
92pub fn generate_openapi_effective(
109 engine: &Engine,
110 explanations_enabled: bool,
111 effective: &DateTimeValue,
112) -> Value {
113 let mut paths = Map::new();
114 let mut components_schemas = Map::new();
115
116 components_schemas.insert(
117 "LemmaRuleResult".to_string(),
118 build_rule_result_schema(explanations_enabled),
119 );
120
121 let repositories = engine.list();
122 let workspace = repositories
123 .iter()
124 .find(|r| r.repository.is_none())
125 .map(|r| r.specs.as_slice())
126 .expect("BUG: workspace repository must exist in list()");
127
128 let is_active = |ls: &ListedSpec| -> bool {
129 let after_start = match &ls.effective_from {
130 None => true,
131 Some(from) => effective >= from,
132 };
133 let before_end = match &ls.effective_to {
134 None => true,
135 Some(to) => effective < to,
136 };
137 after_start && before_end
138 };
139
140 let active_specs: Vec<&ListedSpec> = workspace.iter().filter(|ls| is_active(ls)).collect();
141
142 let unique_spec_names: std::collections::BTreeSet<&str> =
143 active_specs.iter().map(|ls| ls.name.as_str()).collect();
144 let unique_spec_names: Vec<String> = unique_spec_names.into_iter().map(String::from).collect();
145
146 paths.insert("/".to_string(), index_path_item(engine));
147
148 for ls in &active_specs {
149 let spec_name = &ls.name;
150 if let Ok(show) = engine.show(None, spec_name, Some(effective)) {
151 let artifacts = build_spec_openapi_artifacts(
152 spec_name,
153 &show,
154 (ls.effective_from.as_ref(), ls.effective_to.as_ref()),
155 explanations_enabled,
156 );
157 paths.insert(format!("/{spec_name}"), artifacts.path_item);
158 for (name, schema_value) in artifacts.component_schemas {
159 components_schemas.insert(name, schema_value);
160 }
161 }
162 }
163
164 let mut tags = vec![json!({
165 "name": "Specs",
166 "description": "Simple API to retrieve the list of Lemma specs"
167 })];
168 for spec_name in &unique_spec_names {
169 let safe_tag = spec_name.replace('.', "_");
170 tags.push(json!({
171 "name": safe_tag,
172 "x-displayName": spec_name,
173 "description": format!("GET show or POST evaluate for spec '{}'. Use ?rules= on POST to limit evaluated rules.", spec_name)
174 }));
175 }
176
177 let spec_tags: Vec<Value> = unique_spec_names
178 .iter()
179 .map(|n| Value::String(n.replace('.', "_")))
180 .collect();
181
182 let tag_groups = vec![
183 json!({ "name": "Overview", "tags": ["Specs"] }),
184 json!({ "name": "Specs", "tags": spec_tags }),
185 ];
186
187 let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
188
189 json!({
190 "openapi": "3.1.0",
191 "info": {
192 "title": "Lemma API",
193 "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.",
194 "version": version_label
195 },
196 "tags": tags,
197 "x-tagGroups": tag_groups,
198 "paths": Value::Object(paths),
199 "components": {
200 "schemas": Value::Object(components_schemas)
201 }
202 })
203}
204
205struct InputData {
207 name: String,
209 lemma_type: LemmaType,
211 prefilled: Option<lemma::RuleResultValue>,
213 suggestion: Option<lemma::RuleResultValue>,
215}
216
217fn collect_input_data_from_show(show: &lemma::Show) -> Vec<InputData> {
222 show.data
223 .iter()
224 .filter(|(name, _)| !name.contains('.'))
225 .map(|(name, entry)| InputData {
226 name: name.clone(),
227 lemma_type: entry.lemma_type.clone(),
228 prefilled: entry.prefilled.clone(),
229 suggestion: entry.suggestion.clone(),
230 })
231 .collect()
232}
233
234fn index_path_item(engine: &Engine) -> Value {
239 let list = engine.list();
240 let example = serde_json::to_value(&list).expect("BUG: Engine::list must serialize");
241
242 json!({
243 "get": {
244 "operationId": "list",
245 "summary": "List loaded repositories and specs",
246 "tags": ["Specs"],
247 "responses": {
248 "200": {
249 "description": "Same JSON as Engine.list() (metadata only: name, effective_from, effective_to per spec row)",
250 "content": {
251 "application/json": {
252 "schema": {
253 "type": "array",
254 "items": {
255 "type": "object",
256 "properties": {
257 "repository": { "type": ["string", "null"] },
258 "specs": {
259 "type": "array",
260 "items": {
261 "type": "object",
262 "properties": {
263 "name": { "type": "string" },
264 "effective_from": { "type": ["string", "null"] },
265 "effective_to": { "type": ["string", "null"] }
266 },
267 "required": ["name"]
268 }
269 }
270 },
271 "required": ["specs"]
272 }
273 },
274 "example": example
275 }
276 }
277 }
278 }
279 }
280 })
281}
282
283fn error_response_schema() -> Value {
288 json!({
289 "description": "Evaluation error",
290 "content": {
291 "application/json": {
292 "schema": {
293 "type": "object",
294 "properties": {
295 "error": { "type": "string" }
296 },
297 "required": ["error"]
298 }
299 }
300 }
301 })
302}
303
304fn not_found_response_schema() -> Value {
305 json!({
306 "description": "Spec not found",
307 "content": {
308 "application/json": {
309 "schema": {
310 "type": "object",
311 "properties": {
312 "error": { "type": "string" }
313 },
314 "required": ["error"]
315 }
316 }
317 }
318 })
319}
320
321fn memento_spec_response_headers() -> Value {
322 json!({
323 "Memento-Datetime": {
324 "description": "RFC 7089: datetime of the resolved spec version (absent for unversioned specs)",
325 "schema": { "type": "string" }
326 },
327 "Vary": {
328 "description": "Indicates negotiation on Accept-Datetime",
329 "schema": { "type": "string", "example": "Accept-Datetime" }
330 }
331 })
332}
333
334fn build_get_show_response() -> Value {
336 json!({
337 "type": "object",
338 "required": ["spec_set_id", "spec", "data", "rules", "meta", "start_line"],
339 "properties": {
340 "spec_set_id": {
341 "type": "string",
342 "description": "Spec set identifier (path segments, e.g. org/product/pricing)"
343 },
344 "spec": {
345 "type": "string",
346 "description": "Resolved spec name"
347 },
348 "commentary": {
349 "type": ["string", "null"],
350 "description": "Optional commentary from the spec source"
351 },
352 "effective_from": {
353 "type": ["string", "null"],
354 "description": "Effective-from of the resolved temporal version, if any"
355 },
356 "effective_to": {
357 "type": ["string", "null"],
358 "description": "Exclusive effective-to of the resolved temporal version, if any"
359 },
360 "start_line": {
361 "type": "integer",
362 "description": "1-based line number of the spec declaration in source"
363 },
364 "source_type": {
365 "description": "How this spec was loaded (path, inline, registry, etc.)"
366 },
367 "data": {
368 "type": "object",
369 "description": "Data used by the spec's rules, mapped to type metadata, optional prefilled, and optional suggestion",
370 "additionalProperties": true
371 },
372 "rules": {
373 "type": "object",
374 "description": "Local rule names mapped to result types (full planning-time interface)",
375 "additionalProperties": true
376 },
377 "meta": {
378 "type": "object",
379 "description": "Spec metadata key/value pairs",
380 "additionalProperties": true
381 },
382 "versions": {
383 "type": "array",
384 "description": "All loaded temporal versions for this spec name, each with a half-open [effective_from, effective_to) range",
385 "items": {
386 "type": "object",
387 "required": ["effective_from", "effective_to"],
388 "properties": {
389 "effective_from": {
390 "type": ["string", "null"],
391 "description": "Start of validity for this version; null when unbounded (no earlier version exists)"
392 },
393 "effective_to": {
394 "type": ["string", "null"],
395 "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"
396 }
397 }
398 }
399 }
400 }
401 })
402}
403
404fn build_rule_result_schema(explanations_enabled: bool) -> Value {
406 let mut explanation = json!({
407 "type": "object",
408 "description": "Structured explanation tree when explanations are enabled"
409 });
410 if explanations_enabled {
411 explanation["description"] = Value::String(
412 "Structured explanation tree (present when x-explanations is sent and server uses --explanations)"
413 .to_string(),
414 );
415 }
416
417 json!({
418 "type": "object",
419 "required": ["vetoed", "rule_type"],
420 "properties": {
421 "vetoed": { "type": "boolean" },
422 "display": {
423 "type": "string",
424 "description": "Human-readable formatted value when not vetoed"
425 },
426 "veto_reason": { "type": "string" },
427 "rule_type": {
428 "type": "string",
429 "description": "Result type name (e.g. number, boolean, money)"
430 },
431 "measure": {
432 "type": "object",
433 "additionalProperties": { "type": "string" },
434 "description": "Named measure rule: unit name to magnitude string"
435 },
436 "ratio": {
437 "type": "object",
438 "additionalProperties": { "type": "string" },
439 "description": "Named ratio rule: unit name to magnitude string"
440 },
441 "number": { "type": "string" },
442 "boolean": { "type": "boolean" },
443 "text": { "type": "string" },
444 "date": { "type": "object" },
445 "time": { "type": "object" },
446 "calendar": {
447 "type": "object",
448 "properties": {
449 "value": { "type": "string" },
450 "unit": { "type": "string" }
451 }
452 },
453 "range": { "type": "object" },
454 "missing_data": {
455 "type": "array",
456 "items": { "type": "string" },
457 "description": "Input keys still unbound for this rule after overlay-aware pruning (same keys as Show.data)"
458 },
459 "explanation": explanation
460 }
461 })
462}
463
464fn build_evaluate_response_schema(show: &lemma::Show, rule_names: &[String]) -> Value {
466 let mut result_props = Map::new();
467 for rule_name in rule_names {
468 if show.rules.contains_key(rule_name) {
469 result_props.insert(
470 rule_name.clone(),
471 json!({
472 "$ref": "#/components/schemas/LemmaRuleResult"
473 }),
474 );
475 }
476 }
477
478 json!({
479 "type": "object",
480 "required": ["spec", "effective", "results"],
481 "properties": {
482 "spec": {
483 "type": "string",
484 "description": "Spec set id that was evaluated"
485 },
486 "effective": {
487 "type": "string",
488 "description": "Evaluation instant used for temporal resolution (matches request instant unless overridden)"
489 },
490 "spec_effective_from": {
491 "type": "string",
492 "description": "Start of the resolved spec version's declared temporal window"
493 },
494 "spec_effective_to": {
495 "type": "string",
496 "description": "End of the resolved spec version's declared temporal window (absent if unbounded)"
497 },
498 "results": {
499 "type": "object",
500 "description": "Rule names to evaluation results (definition order in response; keys match ?rules= filter when set)",
501 "properties": Value::Object(result_props)
502 }
503 }
504 })
505}
506
507struct SpecOpenApiArtifacts {
512 path_item: Value,
513 component_schemas: Map<String, Value>,
514}
515
516fn spec_component_show_names(spec_name: &str) -> (String, String, String, String) {
517 let safe_name = spec_name.replace('.', "_");
518 (
519 format!("{safe_name}_get_show"),
520 format!("{safe_name}_evaluate_response"),
521 format!("{safe_name}_request"),
522 format!("{safe_name}_form_request"),
523 )
524}
525
526fn build_spec_openapi_artifacts(
536 spec_name: &str,
537 show: &lemma::Show,
538 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
539 explanations_enabled: bool,
540) -> SpecOpenApiArtifacts {
541 let data = collect_input_data_from_show(show);
542 let rule_names: Vec<String> = show.rules.keys().cloned().collect();
543 let (
544 get_show_component_name,
545 evaluate_response_schema_name,
546 post_body_schema_name,
547 post_form_body_schema_name,
548 ) = spec_component_show_names(spec_name);
549
550 let mut component_schemas = Map::new();
551 component_schemas.insert(get_show_component_name.clone(), build_get_show_response());
552 component_schemas.insert(
553 evaluate_response_schema_name.clone(),
554 build_evaluate_response_schema(show, &rule_names),
555 );
556 component_schemas.insert(
557 post_body_schema_name.clone(),
558 build_post_request_schema(&data),
559 );
560 component_schemas.insert(
561 post_form_body_schema_name.clone(),
562 build_post_form_request_schema(&data),
563 );
564
565 let path_item = build_spec_path_item_with_show_refs(
566 spec_name,
567 (
568 &get_show_component_name,
569 &evaluate_response_schema_name,
570 &post_body_schema_name,
571 &post_form_body_schema_name,
572 ),
573 &rule_names,
574 explanations_enabled,
575 effective_range,
576 );
577
578 SpecOpenApiArtifacts {
579 path_item,
580 component_schemas,
581 }
582}
583
584fn x_explanations_header_parameter() -> Value {
585 json!({
586 "name": "x-explanations",
587 "in": "header",
588 "required": false,
589 "description": "Set to request explanation objects in the response (server must be started with --explanations)",
590 "schema": { "type": "string", "default": "true" }
591 })
592}
593
594fn accept_datetime_header_parameter() -> Value {
595 json!({
596 "name": "Accept-Datetime",
597 "in": "header",
598 "required": false,
599 "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit to evaluate at the request instant (now).",
600 "schema": { "type": "string", "format": "date-time" },
601 "example": "Sat, 01 Jan 2025 00:00:00 GMT"
602 })
603}
604
605fn build_spec_path_item_with_show_refs(
607 spec_name: &str,
608 component_names: (&str, &str, &str, &str),
609 rule_names: &[String],
610 explanations_enabled: bool,
611 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
612) -> Value {
613 let (
614 get_show_component_name,
615 evaluate_response_schema_name,
616 post_body_schema_name,
617 post_form_body_schema_name,
618 ) = component_names;
619 let (effective_from, effective_to) = effective_range;
620
621 let get_show_ref = json!({
622 "$ref": format!("#/components/schemas/{}", get_show_component_name)
623 });
624 let evaluate_schema_ref = json!({
625 "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
626 });
627 let body_ref = json!({
628 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
629 });
630 let form_body_ref = json!({
631 "$ref": format!("#/components/schemas/{}", post_form_body_schema_name)
632 });
633
634 let tag = spec_name.replace('.', "_");
635
636 let rules_example = if rule_names.is_empty() {
637 String::new()
638 } else {
639 rule_names.join(",")
640 };
641
642 let rules_param = json!({
643 "name": "rules",
644 "in": "query",
645 "required": false,
646 "description": "Comma-separated list of rule names to evaluate; omit for all.",
647 "schema": { "type": "string" },
648 "example": rules_example
649 });
650
651 let mut get_parameters: Vec<Value> = Vec::new();
652 get_parameters.push(accept_datetime_header_parameter());
653 if explanations_enabled {
654 get_parameters.push(x_explanations_header_parameter());
655 }
656
657 let get_summary = "Show of resolved version (spec, data, rules, meta, versions)".to_string();
658 let post_summary = "Evaluate".to_string();
659 let get_operation_id = format!("get_{}", spec_name);
660 let post_operation_id = format!("post_{}", spec_name);
661
662 let mut post_parameters: Vec<Value> = vec![rules_param];
663 post_parameters.push(accept_datetime_header_parameter());
664 if explanations_enabled {
665 post_parameters.push(x_explanations_header_parameter());
666 }
667
668 let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
669 match dt {
670 Some(d) => Value::String(d.to_string()),
671 None => Value::Null,
672 }
673 };
674
675 json!({
676 "x-effective-from": datetime_or_null(effective_from),
677 "x-effective-to": datetime_or_null(effective_to),
678 "get": {
679 "operationId": get_operation_id,
680 "summary": get_summary,
681 "tags": [tag],
682 "parameters": get_parameters,
683 "responses": {
684 "200": {
685 "description": "Show of resolved version (spec_set_id, effective_from, data, rules, meta, versions).",
686 "headers": memento_spec_response_headers(),
687 "content": {
688 "application/json": {
689 "schema": get_show_ref
690 }
691 }
692 },
693 "400": error_response_schema(),
694 "404": not_found_response_schema()
695 }
696 },
697 "post": {
698 "operationId": post_operation_id,
699 "summary": post_summary,
700 "tags": [tag],
701 "parameters": post_parameters,
702 "requestBody": {
703 "required": true,
704 "content": {
705 "application/x-www-form-urlencoded": {
707 "schema": form_body_ref
708 },
709 "application/json": {
710 "schema": body_ref
711 }
712 }
713 },
714 "responses": {
715 "200": {
716 "description": "Evaluation envelope: spec, effective, result (per-rule RuleResultJson).",
717 "headers": memento_spec_response_headers(),
718 "content": {
719 "application/json": {
720 "schema": evaluate_schema_ref
721 }
722 }
723 },
724 "400": error_response_schema(),
725 "404": not_found_response_schema()
726 }
727 }
728 })
729}
730
731fn type_help(lemma_type: &LemmaType) -> String {
737 match &lemma_type.specifications {
738 TypeSpecification::Boolean { help, .. } => help.clone(),
739 TypeSpecification::Measure { help, .. } => help.clone(),
740 TypeSpecification::MeasureRange { help, .. } => help.clone(),
741 TypeSpecification::Number { help, .. } => help.clone(),
742 TypeSpecification::NumberRange { help, .. } => help.clone(),
743 TypeSpecification::Ratio { help, .. } => help.clone(),
744 TypeSpecification::RatioRange { help, .. } => help.clone(),
745 TypeSpecification::Text { help, .. } => help.clone(),
746 TypeSpecification::Date { help, .. } => help.clone(),
747 TypeSpecification::DateRange { help, .. } => help.clone(),
748 TypeSpecification::TimeRange { help, .. } => help.clone(),
749 TypeSpecification::Time { help, .. } => help.clone(),
750 TypeSpecification::Veto { .. } => String::new(),
751 TypeSpecification::Undetermined => unreachable!(
752 "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
753 ),
754 }
755}
756
757fn build_post_request_schema(data: &[InputData]) -> Value {
762 let mut properties = Map::new();
763 let mut required = Vec::new();
764
765 for data in data {
766 let default_for_docs = data.prefilled.as_ref().or(data.suggestion.as_ref());
767 properties.insert(
768 data.name.clone(),
769 build_post_property_schema(&data.lemma_type, default_for_docs),
770 );
771 if data.prefilled.is_none() {
772 required.push(Value::String(data.name.clone()));
773 }
774 }
775
776 let mut schema = json!({
777 "type": "object",
778 "properties": Value::Object(properties)
779 });
780 if !required.is_empty() {
781 schema["required"] = Value::Array(required);
782 }
783 schema
784}
785
786fn build_post_property_schema(
787 lemma_type: &LemmaType,
788 data_value: Option<&lemma::RuleResultValue>,
789) -> Value {
790 let mut schema = build_post_type_schema(lemma_type);
791
792 let help = type_help(lemma_type);
793 if !help.is_empty() {
794 schema["description"] = Value::String(help);
795 }
796
797 if let Some(v) = data_value {
798 schema["default"] = Value::String(v.to_literal(lemma_type).display_value());
799 }
800
801 schema
802}
803
804fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
805 match &lemma_type.specifications {
806 TypeSpecification::Text { options, .. } => {
807 let mut schema = json!({ "type": "string" });
808 if !options.is_empty() {
809 schema["enum"] =
810 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
811 }
812 schema
813 }
814 TypeSpecification::Boolean { .. } => {
815 json!({ "type": "boolean" })
816 }
817 _ => json!({ "type": "string" }),
818 }
819}
820
821fn build_post_form_request_schema(data: &[InputData]) -> Value {
822 let mut properties = Map::new();
823 let mut required = Vec::new();
824
825 for data in data {
826 let default_for_docs = data.prefilled.as_ref().or(data.suggestion.as_ref());
827 properties.insert(
828 data.name.clone(),
829 build_post_form_property_schema(&data.lemma_type, default_for_docs),
830 );
831 if data.prefilled.is_none() {
832 required.push(Value::String(data.name.clone()));
833 }
834 }
835
836 let mut schema = json!({
837 "type": "object",
838 "properties": Value::Object(properties)
839 });
840 if !required.is_empty() {
841 schema["required"] = Value::Array(required);
842 }
843 schema
844}
845
846fn build_post_form_property_schema(
847 lemma_type: &LemmaType,
848 data_value: Option<&lemma::RuleResultValue>,
849) -> Value {
850 let mut schema = build_post_form_type_schema(lemma_type);
851
852 let help = type_help(lemma_type);
853 if !help.is_empty() {
854 schema["description"] = Value::String(help);
855 }
856
857 if let Some(v) = data_value {
858 schema["default"] = Value::String(v.to_literal(lemma_type).display_value());
859 }
860
861 schema
862}
863
864fn build_post_form_type_schema(lemma_type: &LemmaType) -> Value {
865 match &lemma_type.specifications {
866 TypeSpecification::Text { options, .. } => {
867 let mut schema = json!({ "type": "string" });
868 if !options.is_empty() {
869 schema["enum"] =
870 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
871 }
872 schema
873 }
874 TypeSpecification::Boolean { .. } => {
875 json!({ "type": "string", "enum": ["true", "false"] })
876 }
877 _ => json!({ "type": "string" }),
878 }
879}
880
881#[cfg(test)]
886mod tests {
887 use super::*;
888 use lemma::{DateGranularity, DateTimeValue, SourceType};
889
890 fn create_engine_with_code(code: &str) -> Engine {
891 let mut engine = Engine::new();
892 engine
893 .load([(
894 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
895 code.to_string(),
896 )])
897 .expect("failed to parse lemma code");
898 engine
899 }
900
901 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
902 let mut engine = Engine::new();
903 for (name, code) in files {
904 engine
905 .load([(
906 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
907 code.to_string(),
908 )])
909 .expect("failed to parse lemma code");
910 }
911 engine
912 }
913
914 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
915 DateTimeValue {
916 year,
917 month,
918 day,
919 hour: 0,
920 minute: 0,
921 second: 0,
922 microsecond: 0,
923 timezone: None,
924 granularity: DateGranularity::Full,
925 }
926 }
927
928 fn has_param(params: &Value, name: &str) -> bool {
929 params
930 .as_array()
931 .map(|a| a.iter().any(|p| p["name"] == name))
932 .unwrap_or(false)
933 }
934
935 #[test]
940 fn test_generate_openapi_x_tag_groups() {
941 let engine = create_engine_with_code(
942 "spec pricing
943 data quantity: 10
944 rule total: quantity * 2",
945 );
946 let spec = generate_openapi(&engine, false);
947
948 let groups = spec["x-tagGroups"]
949 .as_array()
950 .expect("x-tagGroups should be array");
951 assert_eq!(groups.len(), 2);
952 assert_eq!(groups[0]["name"], "Overview");
953 assert_eq!(groups[0]["tags"], json!(["Specs"]));
954 assert_eq!(groups[1]["name"], "Specs");
955 assert_eq!(groups[1]["tags"], json!(["pricing"]));
956 }
957
958 #[test]
959 fn test_spec_path_has_get_and_post() {
960 let engine = create_engine_with_code(
961 "spec pricing
962 data quantity: 10
963 rule total: quantity * 2",
964 );
965 let spec = generate_openapi(&engine, false);
966
967 assert!(
968 spec["paths"]["/pricing"].is_object(),
969 "single spec path /pricing"
970 );
971 assert!(spec["paths"]["/pricing"]["get"].is_object());
972 assert!(spec["paths"]["/pricing"]["post"].is_object());
973
974 assert_eq!(
975 spec["paths"]["/pricing"]["get"]["operationId"],
976 "get_pricing"
977 );
978 assert_eq!(
979 spec["paths"]["/pricing"]["post"]["operationId"],
980 "post_pricing"
981 );
982 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
983
984 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
985 .as_array()
986 .expect("parameters array");
987 let param_names: Vec<&str> = get_params
988 .iter()
989 .map(|p| p["name"].as_str().unwrap())
990 .collect();
991 assert!(
992 !param_names.contains(&"rules"),
993 "GET must not have rules query param (show is full interface)"
994 );
995 let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
996 .as_array()
997 .expect("post parameters array");
998 let post_param_names: Vec<&str> = post_params
999 .iter()
1000 .map(|p| p["name"].as_str().unwrap())
1001 .collect();
1002 assert!(
1003 post_param_names.contains(&"rules"),
1004 "POST must have rules query param"
1005 );
1006 assert!(
1007 param_names.contains(&"Accept-Datetime"),
1008 "GET must have Accept-Datetime header"
1009 );
1010
1011 let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1012 ["application/json"]["schema"]["$ref"]
1013 .as_str()
1014 .unwrap();
1015 let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1016 ["application/json"]["schema"]["$ref"]
1017 .as_str()
1018 .unwrap();
1019 assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
1020 assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1021 assert_ne!(get_ref, post_ref);
1022
1023 let get_show = &spec["components"]["schemas"]["pricing_get_show"];
1024 assert!(get_show["properties"]["spec_set_id"]["type"] == "string");
1025 assert!(get_show["properties"]["versions"].is_object());
1026 assert!(get_show["properties"]["start_line"]["type"] == "integer");
1027
1028 let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1029 assert!(h200["headers"]["Memento-Datetime"].is_object());
1030 assert!(h200["headers"]["Vary"].is_object());
1031 }
1032
1033 #[test]
1034 fn suggestion_only_field_is_required_with_json_schema_default() {
1035 let engine = create_engine_with_code(
1036 r#"
1037spec age_check
1038data age: number -> suggest 18
1039rule adult: age >= 18
1040"#,
1041 );
1042 let spec = generate_openapi(&engine, false);
1043 let body = &spec["components"]["schemas"]["age_check_request"];
1044 let required = body["required"]
1045 .as_array()
1046 .expect("required array")
1047 .iter()
1048 .map(|v| v.as_str().unwrap())
1049 .collect::<Vec<_>>();
1050 assert!(
1051 required.contains(&"age"),
1052 "suggest-only fields must stay required, got {required:?}"
1053 );
1054 assert_eq!(body["properties"]["age"]["default"], "18");
1055 }
1056
1057 #[test]
1062 fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1063 let engine = create_engine_with_code(
1064 "spec pricing
1065 data quantity: 10
1066 rule total: quantity * 2",
1067 );
1068 let spec = generate_openapi(&engine, false);
1069
1070 let paths = spec["paths"].as_object().expect("paths object");
1071 assert!(paths.contains_key("/"));
1072 assert_eq!(paths["/"]["get"]["operationId"], "list");
1073 assert!(!paths.contains_key("/openapi.json"));
1074 assert!(!paths.contains_key("/health"));
1075 assert!(!paths.contains_key("/docs"));
1076 assert!(!paths.contains_key("/schema/pricing"));
1077 assert!(!paths.contains_key("/schema/pricing/{rules}"));
1078 assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1079 }
1080
1081 #[test]
1082 fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
1083 let engine = create_engine_with_code(
1084 "spec pricing
1085 data quantity: 10
1086 rule total: quantity * 2",
1087 );
1088 let spec = generate_openapi(&engine, true);
1089
1090 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1091 assert!(has_param(get_params, "x-explanations"));
1092
1093 let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
1094 assert!(rule_result["properties"]["explanation"].is_object());
1095 assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1096 assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1097
1098 let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1099 assert!(evaluate["required"]
1100 .as_array()
1101 .unwrap()
1102 .contains(&json!("spec")));
1103 assert!(evaluate["required"]
1104 .as_array()
1105 .unwrap()
1106 .contains(&json!("effective")));
1107 assert!(evaluate["required"]
1108 .as_array()
1109 .unwrap()
1110 .contains(&json!("results")));
1111 let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1112 .as_str()
1113 .unwrap();
1114 assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
1115 }
1116
1117 #[test]
1118 fn test_generate_openapi_multiple_specs() {
1119 let engine = create_engine_with_files(vec![
1120 (
1121 "pricing.lemma",
1122 "spec pricing
1123 data quantity: 10
1124 rule total: quantity * 2",
1125 ),
1126 (
1127 "shipping.lemma",
1128 "spec shipping
1129 data weight: 5
1130 rule cost: weight * 3",
1131 ),
1132 ]);
1133 let spec = generate_openapi(&engine, false);
1134
1135 assert!(spec["paths"]["/pricing"].is_object());
1136 assert!(spec["paths"]["/shipping"].is_object());
1137 }
1138
1139 #[test]
1140 fn test_nested_spec_path_schema_refs_are_valid() {
1141 let engine = create_engine_with_code(
1142 "spec bc
1143 data x: number
1144 rule result: x",
1145 );
1146 let spec = generate_openapi(&engine, false);
1147
1148 assert!(spec["paths"]["/bc"]["post"].is_object());
1149 let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1150 let content_keys: Vec<&str> = post_content
1151 .as_object()
1152 .expect("requestBody.content object")
1153 .keys()
1154 .map(|k| k.as_str())
1155 .collect();
1156 assert_eq!(
1157 content_keys.first().copied(),
1158 Some("application/x-www-form-urlencoded"),
1159 "form-urlencoded must be first so Scalar docs default to Form URL Encoded"
1160 );
1161 let body_ref = post_content["application/json"]["schema"]["$ref"]
1162 .as_str()
1163 .unwrap();
1164 let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1165 .as_str()
1166 .unwrap();
1167 assert_eq!(body_ref, "#/components/schemas/bc_request");
1168 assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1169 assert!(spec["components"]["schemas"]["bc_request"].is_object());
1170 assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1171 assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1172 assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1173 }
1174
1175 #[test]
1180 fn test_generate_openapi_effective_reflects_specific_time() {
1181 let engine = create_engine_with_code(
1182 "spec pricing
1183 data quantity: 10
1184 rule total: quantity * 2",
1185 );
1186 let effective = date(2025, 6, 15);
1187 let spec = generate_openapi_effective(&engine, false, &effective);
1188
1189 assert_eq!(spec["openapi"], "3.1.0");
1190 let version = spec["info"]["version"].as_str().unwrap();
1191 assert!(
1192 version.contains("2025-06-15"),
1193 "version string should contain the effective date, got: {}",
1194 version
1195 );
1196 }
1197
1198 #[test]
1199 fn test_effective_shows_correct_temporal_version_interface() {
1200 let engine = create_engine_with_files(vec![(
1201 "policy.lemma",
1202 r#"
1203spec policy
1204data base: 100
1205rule discount: 10
1206
1207spec policy 2025-06-01
1208data base: 200
1209data premium: boolean
1210rule discount: 20
1211rule surcharge:
1212 5
1213 unless premium then 10
1214"#,
1215 )]);
1216
1217 let before = date(2025, 3, 1);
1218 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1219
1220 assert!(spec_v1["paths"]["/policy"].is_object());
1221 let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1222 let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1223 assert_eq!(
1224 v1_result["discount"]["$ref"].as_str(),
1225 Some("#/components/schemas/LemmaRuleResult"),
1226 "v1 should have discount rule"
1227 );
1228 assert!(
1229 v1_result["surcharge"].is_null(),
1230 "v1 must NOT have surcharge rule"
1231 );
1232 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1233 assert!(
1234 v1_request["properties"]["premium"].is_null(),
1235 "v1 must NOT have premium data"
1236 );
1237
1238 let after = date(2025, 8, 1);
1239 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1240
1241 let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1242 let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1243 assert!(
1244 v2_result["discount"]["$ref"].is_string(),
1245 "v2 should have discount rule"
1246 );
1247 assert!(
1248 v2_result["surcharge"]["$ref"].is_string(),
1249 "v2 should have surcharge rule"
1250 );
1251 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1252 assert!(
1253 v2_request["properties"]["premium"].is_object(),
1254 "v2 should have premium data"
1255 );
1256 }
1257
1258 #[test]
1267 fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1268 let engine = create_engine_with_files(vec![(
1269 "policy.lemma",
1270 r#"
1271spec policy 2025-01-01
1272data base: 10
1273rule total: base
1274
1275spec policy 2026-01-01
1276data base: 99
1277rule total: base
1278"#,
1279 )]);
1280
1281 let at_earlier = date(2025, 6, 1);
1282 let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1283 let earlier_path = &earlier_doc["paths"]["/policy"];
1284 assert_eq!(
1285 earlier_path["x-effective-from"].as_str(),
1286 Some("2025-01-01"),
1287 "earlier version effective_from on PathItem"
1288 );
1289 assert_eq!(
1290 earlier_path["x-effective-to"].as_str(),
1291 Some("2026-01-01"),
1292 "earlier version effective_to equals next version's effective_from"
1293 );
1294
1295 let at_latest = date(2026, 6, 1);
1296 let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1297 let latest_path = &latest_doc["paths"]["/policy"];
1298 assert_eq!(
1299 latest_path["x-effective-from"].as_str(),
1300 Some("2026-01-01"),
1301 "latest version effective_from on PathItem"
1302 );
1303 assert!(
1304 latest_path["x-effective-to"].is_null(),
1305 "latest version has no successor; x-effective-to must be null: {latest_path}"
1306 );
1307 }
1308
1309 #[test]
1312 fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1313 let engine = create_engine_with_code(
1314 "spec pricing
1315 data quantity: 10
1316 rule total: quantity * 2",
1317 );
1318 let document = generate_openapi(&engine, false);
1319 let path_item = &document["paths"]["/pricing"];
1320 assert!(
1321 path_item["x-effective-from"].is_null(),
1322 "unversioned spec: x-effective-from must be null: {path_item}"
1323 );
1324 assert!(
1325 path_item["x-effective-to"].is_null(),
1326 "unversioned spec: x-effective-to must be null: {path_item}"
1327 );
1328 }
1329
1330 #[test]
1335 fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1336 let engine = create_engine_with_files(vec![(
1337 "policy.lemma",
1338 r#"
1339spec policy
1340data base: 100
1341rule discount: 10
1342
1343spec policy 2025-06-01
1344data base: 200
1345rule discount: 20
1346"#,
1347 )]);
1348
1349 let sources = temporal_api_sources(&engine);
1350
1351 assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1352
1353 assert_eq!(sources[0].title, "Now");
1354 assert_eq!(sources[0].slug, NOW_SLUG);
1355 assert_eq!(sources[0].url, "/openapi.json");
1356
1357 assert_eq!(sources[1].title, "Effective 2025-06-01");
1358 assert_eq!(sources[1].slug, "2025-06-01");
1359 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1360 }
1361
1362 #[test]
1363 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1364 let engine = create_engine_with_files(vec![
1365 (
1366 "policy.lemma",
1367 r#"
1368spec policy
1369data base: 100
1370rule discount: 10
1371
1372spec policy 2025-06-01
1373data base: 200
1374rule discount: 20
1375"#,
1376 ),
1377 (
1378 "rates.lemma",
1379 r#"
1380spec rates
1381data rate: 5
1382rule total: rate * 2
1383
1384spec rates 2025-03-01
1385data rate: 7
1386rule total: rate * 2
1387
1388spec rates 2025-06-01
1389data rate: 9
1390rule total: rate * 2
1391"#,
1392 ),
1393 ]);
1394
1395 let sources = temporal_api_sources(&engine);
1396
1397 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1398 assert!(
1399 slugs.contains(&"2025-03-01"),
1400 "should contain rates boundary"
1401 );
1402 assert!(
1403 slugs.contains(&"2025-06-01"),
1404 "should contain shared boundary"
1405 );
1406 assert!(slugs.contains(&NOW_SLUG), "should contain now");
1407 assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1408 }
1409
1410 #[test]
1411 fn test_temporal_sources_ordered_chronologically() {
1412 let engine = create_engine_with_files(vec![(
1413 "policy.lemma",
1414 r#"
1415spec policy
1416data base: 100
1417rule discount: 10
1418
1419spec policy 2024-01-01
1420data base: 50
1421rule discount: 5
1422
1423spec policy 2025-06-01
1424data base: 200
1425rule discount: 20
1426"#,
1427 )]);
1428
1429 let sources = temporal_api_sources(&engine);
1430 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1431 assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1432 }
1433
1434 #[test]
1439 fn test_post_schema_text_with_options_has_enum() {
1440 let engine = create_engine_with_code(
1441 "spec test
1442 data product: text -> option \"A\" -> option \"B\"
1443 rule result: product",
1444 );
1445 let spec = generate_openapi(&engine, false);
1446
1447 let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1448 assert!(product_prop["enum"].is_array());
1449 let enums = product_prop["enum"].as_array().unwrap();
1450 assert_eq!(enums.len(), 2);
1451 assert_eq!(enums[0], "A");
1452 assert_eq!(enums[1], "B");
1453 }
1454
1455 #[test]
1456 fn test_post_schema_boolean_is_json_boolean() {
1457 let engine = create_engine_with_code(
1458 "spec test
1459 data is_active: boolean
1460 rule result: is_active",
1461 );
1462 let spec = generate_openapi(&engine, false);
1463
1464 let schema = &spec["components"]["schemas"]["test_request"];
1465 let is_active = &schema["properties"]["is_active"];
1466 assert_eq!(is_active["type"], "boolean");
1467
1468 let form_schema = &spec["components"]["schemas"]["test_form_request"];
1469 let form_is_active = &form_schema["properties"]["is_active"];
1470 assert_eq!(form_is_active["type"], "string");
1471 assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1472 }
1473
1474 #[test]
1475 fn test_post_schema_number_is_string() {
1476 let engine = create_engine_with_code(
1477 "spec test
1478 data quantity: number
1479 rule result: quantity",
1480 );
1481 let spec = generate_openapi(&engine, false);
1482
1483 let schema = &spec["components"]["schemas"]["test_request"];
1484 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1485 }
1486
1487 #[test]
1488 fn test_data_with_default_is_not_required() {
1489 let engine = create_engine_with_code(
1490 "spec test
1491 data quantity: 10
1492 data name: text
1493 rule result: quantity
1494 rule label: name",
1495 );
1496 let spec = generate_openapi(&engine, false);
1497
1498 let schema = &spec["components"]["schemas"]["test_request"];
1499 let required = schema["required"]
1500 .as_array()
1501 .expect("required should be array");
1502
1503 assert!(required.contains(&Value::String("name".to_string())));
1504 assert!(!required.contains(&Value::String("quantity".to_string())));
1505 }
1506
1507 #[test]
1508 fn test_help_and_default_in_openapi() {
1509 let engine = create_engine_with_code(
1510 r#"spec test
1511data quantity: number -> help "Number of items to order" -> suggest 10
1512data active: boolean -> help "Whether the feature is enabled" -> suggest true
1513rule result:
1514 quantity
1515 unless active then 0
1516"#,
1517 );
1518 let spec = generate_openapi(&engine, false);
1519
1520 let req_schema = &spec["components"]["schemas"]["test_request"];
1521 assert!(req_schema["properties"]["quantity"]["description"]
1522 .as_str()
1523 .unwrap()
1524 .contains("Number of items to order"));
1525 assert_eq!(
1526 req_schema["properties"]["quantity"]["default"]
1527 .as_str()
1528 .unwrap(),
1529 "10"
1530 );
1531 assert!(req_schema["properties"]["active"]["description"]
1532 .as_str()
1533 .unwrap()
1534 .contains("Whether the feature is enabled"));
1535 assert_eq!(
1536 req_schema["properties"]["active"]["default"]
1537 .as_str()
1538 .unwrap(),
1539 "true"
1540 );
1541 let required = req_schema["required"]
1542 .as_array()
1543 .expect("required array")
1544 .iter()
1545 .map(|v| v.as_str().unwrap())
1546 .collect::<Vec<_>>();
1547 assert!(required.contains(&"quantity"));
1548 assert!(required.contains(&"active"));
1549 }
1550}