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