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