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