1use lemma::{DateTimeValue, Engine, SpecVersion};
19use serde_json::{json, Map, Value};
20use std::collections::BTreeSet;
21use std::sync::OnceLock;
22
23pub const NOW_SLUG: &str = "now";
25
26pub const SHOW_SCHEMA_REF: &str = "#/components/schemas/Show";
28
29#[derive(Debug, Clone, serde::Serialize)]
34pub struct ApiSource {
35 pub title: String,
36 pub slug: String,
37 pub url: String,
38}
39
40pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
50 let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
51 std::collections::BTreeSet::new();
52
53 for repo in engine.specs() {
54 for version in &repo.specs {
55 if let Some(af) = &version.effective_from {
56 all_boundaries.insert(af.clone());
57 }
58 }
59 }
60
61 if all_boundaries.is_empty() {
62 return vec![ApiSource {
63 title: "Now".to_string(),
64 slug: NOW_SLUG.to_string(),
65 url: "/openapi.json".to_string(),
66 }];
67 }
68
69 let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
70
71 sources.push(ApiSource {
72 title: "Now".to_string(),
73 slug: NOW_SLUG.to_string(),
74 url: "/openapi.json".to_string(),
75 });
76
77 for boundary in all_boundaries.iter().rev() {
78 let label = boundary.to_string();
79 sources.push(ApiSource {
80 title: format!("Effective {}", label),
81 slug: label.clone(),
82 url: format!("/openapi.json?effective={}", label),
83 });
84 }
85
86 sources
87}
88
89pub fn generate_openapi(engine: &Engine, explain: bool) -> Value {
94 generate_openapi_effective(engine, explain, &DateTimeValue::now())
95}
96
97pub fn generate_openapi_effective(
114 engine: &Engine,
115 explain: bool,
116 effective: &DateTimeValue,
117) -> Value {
118 let mut paths = Map::new();
119 let mut components_schemas = Map::new();
120
121 components_schemas.extend(hoisted_show_component_schemas().clone());
122 let mut row_schemas: std::collections::BTreeMap<String, Value> =
123 std::collections::BTreeMap::new();
124
125 let repositories = engine.specs();
126 let workspace = repositories
127 .iter()
128 .find(|r| r.repository.is_none())
129 .map(|r| r.specs.as_slice())
130 .expect("BUG: default repository must exist in specs()");
131
132 let is_active = |version: &SpecVersion| -> bool {
133 let after_start = match &version.effective_from {
134 None => true,
135 Some(from) => effective >= from,
136 };
137 let before_end = match &version.effective_to {
138 None => true,
139 Some(to) => effective < to,
140 };
141 after_start && before_end
142 };
143
144 let active_specs: Vec<&SpecVersion> = workspace
145 .iter()
146 .filter(|version| is_active(version))
147 .collect();
148
149 let unique_spec_names: std::collections::BTreeSet<&str> = active_specs
150 .iter()
151 .map(|version| version.name.as_str())
152 .collect();
153 let unique_spec_names: Vec<String> = unique_spec_names.into_iter().map(String::from).collect();
154
155 paths.insert("/".to_string(), index_path_item(engine));
156
157 let mut tag_descriptions: std::collections::BTreeMap<String, String> =
158 std::collections::BTreeMap::new();
159
160 for version in &active_specs {
161 let spec_name = &version.name;
162 if let Ok(show) = engine.show(None, spec_name, Some(effective)) {
163 let tag_description = show
164 .commentary
165 .clone()
166 .unwrap_or_else(|| format!("Show and evaluate `{spec_name}`"));
167 tag_descriptions.insert(spec_name.clone(), tag_description);
168
169 let artifacts = build_spec_openapi_artifacts(
170 spec_name,
171 &show,
172 &lemma::api::show_document(engine, &show),
173 (
174 version.effective_from.as_ref(),
175 version.effective_to.as_ref(),
176 ),
177 explain,
178 );
179 paths.insert(format!("/{spec_name}"), artifacts.path_item);
180 for (name, schema_value) in artifacts.component_schemas {
181 components_schemas.insert(name, schema_value);
182 }
183 for (name, schema_value) in artifacts.row_schemas {
184 match row_schemas.get(&name) {
185 Some(existing) => assert_eq!(
186 existing, &schema_value,
187 "BUG: row schema '{name}' names one row spec, so every Show document renders it identically"
188 ),
189 None => {
190 row_schemas.insert(name, schema_value);
191 }
192 }
193 }
194 }
195 }
196 components_schemas.extend(row_schemas);
197
198 let mut tags = vec![json!({
199 "name": "Specs",
200 "description": "Loaded specs"
201 })];
202 for spec_name in &unique_spec_names {
203 let safe_tag = spec_name.replace('.', "_");
204 let description = tag_descriptions
205 .get(spec_name)
206 .cloned()
207 .unwrap_or_else(|| format!("Show and evaluate `{spec_name}`"));
208 tags.push(json!({
209 "name": safe_tag,
210 "x-displayName": spec_name,
211 "description": description
212 }));
213 }
214
215 let spec_tags: Vec<Value> = unique_spec_names
216 .iter()
217 .map(|n| Value::String(n.replace('.', "_")))
218 .collect();
219
220 let tag_groups = vec![
221 json!({ "name": "Overview", "tags": ["Specs"] }),
222 json!({ "name": "Specs", "tags": spec_tags }),
223 ];
224
225 let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
226
227 json!({
228 "openapi": "3.1.0",
229 "info": {
230 "title": "Lemma API",
231 "description": "Lemma is a declarative language for business logic: pricing, tax, eligibility, contracts, and policies. This API returns, shows, and evaluates loaded specs. Learn more at [LemmaBase.com](https://lemmabase.com).",
232 "version": version_label
233 },
234 "tags": tags,
235 "x-tagGroups": tag_groups,
236 "paths": Value::Object(paths),
237 "components": {
238 "schemas": Value::Object(components_schemas)
239 }
240 })
241}
242
243fn index_path_item(engine: &Engine) -> Value {
248 let repositories = engine.specs();
249 let example = serde_json::to_value(&repositories).expect("BUG: Engine::specs must serialize");
250
251 json!({
252 "get": {
253 "operationId": "specs",
254 "summary": "Loaded specs",
255 "tags": ["Specs"],
256 "responses": {
257 "200": {
258 "description": "Loaded repositories and their specs",
259 "content": {
260 "application/json": {
261 "schema": {
262 "type": "array",
263 "items": {
264 "type": "object",
265 "properties": {
266 "repository": {
267 "type": ["string", "null"],
268 "description": "Repository name. Null for the default repository."
269 },
270 "specs": {
271 "type": "array",
272 "description": "Specs in this repository",
273 "items": {
274 "type": "object",
275 "properties": {
276 "name": {
277 "type": "string",
278 "description": "Spec name"
279 },
280 "effective_from": {
281 "type": ["string", "null"],
282 "description": "When this version starts. Null if unbounded."
283 },
284 "effective_to": {
285 "type": ["string", "null"],
286 "description": "When this version ends (exclusive). Null if this is the latest."
287 }
288 },
289 "required": ["name"]
290 }
291 }
292 },
293 "required": ["specs"]
294 }
295 },
296 "example": example
297 }
298 }
299 }
300 }
301 }
302 })
303}
304
305fn error_response_schema() -> Value {
310 json!({
311 "description": "Bad request",
312 "content": {
313 "application/json": {
314 "schema": {
315 "type": "object",
316 "properties": {
317 "error": { "type": "string" }
318 },
319 "required": ["error"]
320 }
321 }
322 }
323 })
324}
325
326fn not_found_response_schema() -> Value {
327 json!({
328 "description": "Spec not found",
329 "content": {
330 "application/json": {
331 "schema": {
332 "type": "object",
333 "properties": {
334 "error": { "type": "string" }
335 },
336 "required": ["error"]
337 }
338 }
339 }
340 })
341}
342
343fn memento_spec_response_headers() -> Value {
344 json!({
345 "Memento-Datetime": {
346 "description": "`effective_from` of the resolved version, when versioned",
347 "schema": { "type": "string" }
348 },
349 "Vary": {
350 "description": "Varies with Accept-Datetime",
351 "schema": { "type": "string", "example": "Accept-Datetime" }
352 }
353 })
354}
355
356const JSON_SCHEMA_METASCHEMA: &str = "https://json-schema.org/draft/2020-12/schema";
357const DEFS_REF_PREFIX: &str = "#/$defs/";
358
359fn hoisted_show_component_schemas() -> &'static Map<String, Value> {
363 static SCHEMAS: OnceLock<Map<String, Value>> = OnceLock::new();
364 SCHEMAS.get_or_init(build_hoisted_show_component_schemas)
365}
366
367const EVALUATE_DEF_SEEDS: &[&str] = &[
368 "RuleResult",
369 "RuleResultValue",
370 "SpecResult",
371 "SpecRuleResult",
372 "RowReference",
373 "RowResult",
374 "LemmaType",
375];
376
377fn build_hoisted_show_component_schemas() -> Map<String, Value> {
378 let document: Value = serde_json::from_str(lemma::API_V1_SCHEMA_JSON)
379 .expect("BUG: engine/schemas/api.v1.json must be JSON");
380 let defs = document["$defs"]
381 .as_object()
382 .expect("BUG: api.v1.json must have $defs");
383 let show = show_object_without_metaschema(defs);
384 let mut needed: BTreeSet<String> = BTreeSet::new();
385 collect_defs_ref_names(&show, &mut needed);
386 for seed in EVALUATE_DEF_SEEDS {
387 needed.insert((*seed).to_string());
388 }
389 let mut pending: Vec<String> = needed.iter().cloned().collect();
390 while let Some(name) = pending.pop() {
391 let def = defs
392 .get(&name)
393 .unwrap_or_else(|| panic!("BUG: api.v1.json $defs missing {name}"));
394 let mut more = BTreeSet::new();
395 collect_defs_ref_names(def, &mut more);
396 for extra in more {
397 if needed.insert(extra.clone()) {
398 pending.push(extra);
399 }
400 }
401 }
402 let mut schemas = Map::new();
403 let mut show = show;
404 rewrite_defs_refs(&mut show);
405 schemas.insert("Show".to_string(), show);
406 for name in needed {
407 let mut schema = defs
408 .get(&name)
409 .unwrap_or_else(|| panic!("BUG: api.v1.json $defs missing {name}"))
410 .clone();
411 rewrite_defs_refs(&mut schema);
412 schemas.insert(name, schema);
413 }
414 schemas
415}
416
417fn show_object_without_metaschema(defs: &Map<String, Value>) -> Value {
418 let show = defs
419 .get("Show")
420 .expect("BUG: api.v1.json $defs must define Show");
421 let all_of = show["allOf"]
422 .as_array()
423 .expect("BUG: $defs/Show must be allOf[metaschema, object]");
424 assert_eq!(
425 all_of.len(),
426 2,
427 "BUG: $defs/Show allOf must have metaschema $ref then object"
428 );
429 assert_eq!(
430 all_of[0]["$ref"].as_str(),
431 Some(JSON_SCHEMA_METASCHEMA),
432 "BUG: $defs/Show allOf[0] must $ref the JSON Schema 2020-12 metaschema"
433 );
434 let mut object = all_of[1].clone();
435 if let Some(description) = show.get("description") {
436 object["description"] = description.clone();
437 }
438 object
439}
440
441fn collect_defs_ref_names(value: &Value, names: &mut BTreeSet<String>) {
442 let mut pending: Vec<&Value> = vec![value];
443 while let Some(value) = pending.pop() {
444 match value {
445 Value::Object(map) => {
446 if let Some(Value::String(target)) = map.get("$ref") {
447 if let Some(name) = target.strip_prefix(DEFS_REF_PREFIX) {
448 assert!(
449 !name.contains('/'),
450 "BUG: nested JSON pointer $ref {target} is not a $defs name"
451 );
452 names.insert(name.to_string());
453 } else {
454 assert_eq!(
455 target, JSON_SCHEMA_METASCHEMA,
456 "BUG: unexpected $ref {target} in api.v1.json Show closure"
457 );
458 }
459 }
460 pending.extend(map.values());
461 }
462 Value::Array(items) => pending.extend(items.iter()),
463 _ => {}
464 }
465 }
466}
467
468fn build_get_show_response() -> Value {
470 json!({
471 "$ref": SHOW_SCHEMA_REF,
472 "description": "Draft 2020-12 JSON Schema for this spec's evaluation-data inputs (properties, required, $defs) with the rule graph under x-lemma-rules."
473 })
474}
475
476fn build_evaluate_response_schema(show: &lemma::Show, rule_names: &[String]) -> Value {
478 let mut result_props = Map::new();
479 for rule_name in rule_names {
480 if show.rules.contains_key(rule_name) {
481 result_props.insert(
482 rule_name.clone(),
483 json!({
484 "$ref": "#/components/schemas/RuleResult"
485 }),
486 );
487 }
488 }
489
490 json!({
491 "type": "object",
492 "required": ["spec", "effective", "results"],
493 "properties": {
494 "spec": {
495 "type": "string",
496 "description": "Spec that was evaluated"
497 },
498 "effective": {
499 "type": "string",
500 "description": "Datetime used to pick the version and evaluate"
501 },
502 "spec_effective_from": {
503 "type": "string",
504 "description": "When the resolved version starts"
505 },
506 "spec_effective_to": {
507 "type": "string",
508 "description": "When the resolved version ends (exclusive). Absent if unbounded."
509 },
510 "results": {
511 "type": "object",
512 "description": "Results by rule name. Honors `?rules=`.",
513 "properties": Value::Object(result_props)
514 },
515 "rows": {
516 "type": "array",
517 "description": "List rows the results reach, in first-reached order. Omitted when none.",
518 "items": { "$ref": "#/components/schemas/RowResult" }
519 }
520 }
521 })
522}
523
524struct SpecOpenApiArtifacts {
529 path_item: Value,
530 component_schemas: Map<String, Value>,
531 row_schemas: Map<String, Value>,
533}
534
535fn spec_component_show_names(spec_name: &str) -> (String, String, String, String) {
536 let safe_name = spec_name.replace('.', "_");
537 (
538 format!("{safe_name}_get_show"),
539 format!("{safe_name}_evaluate_response"),
540 format!("{safe_name}_request"),
541 format!("{safe_name}_form_request"),
542 )
543}
544
545fn build_spec_openapi_artifacts(
554 spec_name: &str,
555 show: &lemma::Show,
556 document: &Value,
557 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
558 explain: bool,
559) -> SpecOpenApiArtifacts {
560 let rule_names: Vec<String> = show.rules.keys().cloned().collect();
561 let (
562 get_show_component_name,
563 evaluate_response_schema_name,
564 post_body_schema_name,
565 post_form_body_schema_name,
566 ) = spec_component_show_names(spec_name);
567
568 let mut component_schemas = Map::new();
569 component_schemas.insert(get_show_component_name.clone(), build_get_show_response());
570 component_schemas.insert(
571 evaluate_response_schema_name.clone(),
572 build_evaluate_response_schema(show, &rule_names),
573 );
574 component_schemas.insert(
575 post_body_schema_name.clone(),
576 build_post_request_schema(document),
577 );
578 let form_body =
582 (!show_document_has_list_input(document)).then(|| build_post_form_request_schema(document));
583 if let Some(form_body) = &form_body {
584 component_schemas.insert(post_form_body_schema_name.clone(), form_body.clone());
585 }
586 let mut row_schemas = Map::new();
587 if let Some(defs) = document.get("$defs") {
588 for (name, schema) in defs.as_object().expect("BUG: Show $defs is an object") {
589 let mut schema = schema.clone();
590 rewrite_defs_refs(&mut schema);
591 row_schemas.insert(name.clone(), schema);
592 }
593 }
594
595 let path_item = build_spec_path_item_with_show_refs(
596 spec_name,
597 (
598 &get_show_component_name,
599 &evaluate_response_schema_name,
600 &post_body_schema_name,
601 form_body
602 .as_ref()
603 .map(|_| post_form_body_schema_name.as_str()),
604 ),
605 &rule_names,
606 explain,
607 effective_range,
608 );
609
610 SpecOpenApiArtifacts {
611 path_item,
612 component_schemas,
613 row_schemas,
614 }
615}
616
617fn x_explain_header_parameter() -> Value {
618 json!({
619 "name": "x-explain",
620 "in": "header",
621 "required": false,
622 "x-disabled": false,
624 "description": "Include explanations. Server needs `--explain`.",
625 "schema": { "type": "string", "default": "true" }
626 })
627}
628
629fn accept_datetime_header_parameter() -> Value {
630 json!({
631 "name": "Accept-Datetime",
632 "in": "header",
633 "required": false,
634 "x-disabled": false,
636 "description": "Evaluate at this datetime (Lemma forms: year, year-month, date, or datetime). Omit the header to evaluate at the current time.",
637 "schema": { "type": "string" },
638 "example": "2025-01-01"
639 })
640}
641
642fn build_spec_path_item_with_show_refs(
647 spec_name: &str,
648 component_names: (&str, &str, &str, Option<&str>),
649 rule_names: &[String],
650 explain: bool,
651 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
652) -> Value {
653 let (
654 get_show_component_name,
655 evaluate_response_schema_name,
656 post_body_schema_name,
657 post_form_body_schema_name,
658 ) = component_names;
659 let (effective_from, effective_to) = effective_range;
660
661 let get_show_ref = json!({
662 "$ref": format!("#/components/schemas/{}", get_show_component_name)
663 });
664 let evaluate_schema_ref = json!({
665 "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
666 });
667 let body_ref = json!({
668 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
669 });
670
671 let tag = spec_name.replace('.', "_");
672
673 let rules_example = if rule_names.is_empty() {
674 String::new()
675 } else {
676 rule_names.join(",")
677 };
678
679 let rules_param = json!({
680 "name": "rules",
681 "in": "query",
682 "required": false,
683 "x-disabled": false,
686 "description": "Rules to evaluate, comma-separated. Omit for all.",
687 "schema": { "type": "string" },
688 "example": rules_example
689 });
690
691 let mut get_parameters: Vec<Value> = Vec::new();
692 get_parameters.push(accept_datetime_header_parameter());
693 if explain {
694 get_parameters.push(x_explain_header_parameter());
695 }
696
697 let get_operation_id = format!("get_{}", spec_name);
698 let post_operation_id = format!("post_{}", spec_name);
699
700 let mut post_parameters: Vec<Value> = vec![rules_param];
701 post_parameters.push(accept_datetime_header_parameter());
702 if explain {
703 post_parameters.push(x_explain_header_parameter());
704 }
705
706 let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
707 match dt {
708 Some(d) => Value::String(d.to_string()),
709 None => Value::Null,
710 }
711 };
712
713 let request_content = match post_form_body_schema_name {
716 Some(form_name) => json!({
717 "application/x-www-form-urlencoded": {
718 "schema": { "$ref": format!("#/components/schemas/{form_name}") }
719 },
720 "application/json": {
721 "schema": body_ref
722 }
723 }),
724 None => json!({
725 "application/json": {
726 "schema": body_ref
727 }
728 }),
729 };
730
731 json!({
732 "x-effective-from": datetime_or_null(effective_from),
733 "x-effective-to": datetime_or_null(effective_to),
734 "get": {
735 "operationId": get_operation_id,
736 "summary": "Show spec",
737 "tags": [tag],
738 "parameters": get_parameters,
739 "responses": {
740 "200": {
741 "description": "Spec interface",
742 "headers": memento_spec_response_headers(),
743 "content": {
744 "application/json": {
745 "schema": get_show_ref
746 }
747 }
748 },
749 "400": error_response_schema(),
750 "404": not_found_response_schema()
751 }
752 },
753 "post": {
754 "operationId": post_operation_id,
755 "summary": "Evaluate",
756 "tags": [tag],
757 "parameters": post_parameters,
758 "requestBody": {
759 "required": true,
760 "content": request_content
761 },
762 "responses": {
763 "200": {
764 "description": "Evaluation results",
765 "headers": memento_spec_response_headers(),
766 "content": {
767 "application/json": {
768 "schema": evaluate_schema_ref
769 }
770 }
771 },
772 "400": error_response_schema(),
773 "404": not_found_response_schema()
774 }
775 }
776 })
777}
778
779fn show_document_has_list_input(document: &Value) -> bool {
781 document["properties"]
782 .as_object()
783 .expect("BUG: Show properties is an object")
784 .values()
785 .any(|property| {
786 let scalar = property
787 .get("oneOf")
788 .map_or(property, |alternatives| &alternatives[0]);
789 scalar["type"].as_str() == Some("array")
790 })
791}
792
793fn build_post_request_schema(document: &Value) -> Value {
800 let mut schema = json!({
801 "type": "object",
802 "additionalProperties": true,
803 "properties": document["properties"].clone()
804 });
805 if let Some(required) = document.get("required") {
806 schema["required"] = required.clone();
807 }
808 rewrite_defs_refs(&mut schema);
809 schema
810}
811
812fn rewrite_defs_refs(value: &mut Value) {
813 let mut pending: Vec<&mut Value> = vec![value];
814 while let Some(value) = pending.pop() {
815 match value {
816 Value::Object(map) => {
817 if let Some(Value::String(target)) = map.get_mut("$ref") {
818 if let Some(name) = target.strip_prefix("#/$defs/") {
819 *target = format!("#/components/schemas/{name}");
820 }
821 }
822 pending.extend(map.values_mut());
823 }
824 Value::Array(items) => pending.extend(items.iter_mut()),
825 _ => {}
826 }
827 }
828}
829
830const FORM_KEYWORDS: [&str; 5] = ["enum", "minLength", "maxLength", "description", "format"];
834
835fn build_post_form_request_schema(document: &Value) -> Value {
838 let mut properties = Map::new();
839 for (name, property) in document["properties"]
840 .as_object()
841 .expect("BUG: Show properties is an object")
842 {
843 let mut field = Map::new();
844 field.insert("type".to_string(), json!("string"));
845 let scalar = property
846 .get("oneOf")
847 .map_or(property, |alternatives| &alternatives[0]);
848 match scalar["type"].as_str() {
849 Some("array") => continue,
850 Some("boolean") => {
851 field.insert("enum".to_string(), json!(["true", "false"]));
852 }
853 Some("string") => {}
854 other => unreachable!("BUG: Show property '{name}' has type {other:?}"),
855 }
856 for keyword in FORM_KEYWORDS {
857 if let Some(value) = scalar.get(keyword).or_else(|| property.get(keyword)) {
858 field.insert(keyword.to_string(), value.clone());
859 }
860 }
861 if let Some(default) = property.get("default") {
862 field.insert("default".to_string(), form_literal(default));
863 }
864 if let Some(examples) = property.get("examples") {
865 let examples = examples
866 .as_array()
867 .expect("BUG: Show examples is an array")
868 .iter()
869 .map(form_literal)
870 .collect();
871 field.insert("examples".to_string(), Value::Array(examples));
872 }
873 properties.insert(name.clone(), Value::Object(field));
874 }
875 let mut schema = json!({
876 "type": "object",
877 "properties": Value::Object(properties)
878 });
879 if let Some(required) = document.get("required") {
880 schema["required"] = required.clone();
881 }
882 schema
883}
884
885fn form_literal(value: &Value) -> Value {
886 match value {
887 Value::String(_) => value.clone(),
888 Value::Bool(boolean) => Value::String(boolean.to_string()),
889 other => unreachable!("BUG: scalar Show literal is a string or boolean, got {other}"),
890 }
891}
892
893#[cfg(test)]
898mod tests {
899 use super::*;
900 use lemma::{DateGranularity, DateTimeValue, SourceType};
901
902 fn create_engine_with_code(code: &str) -> Engine {
903 let mut engine = Engine::new();
904 engine
905 .load([(
906 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
907 code.to_string(),
908 )])
909 .expect("failed to parse lemma code");
910 engine
911 }
912
913 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
914 let mut engine = Engine::new();
915 for (name, code) in files {
916 engine
917 .load([(
918 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
919 code.to_string(),
920 )])
921 .expect("failed to parse lemma code");
922 }
923 engine
924 }
925
926 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
927 DateTimeValue {
928 year,
929 month,
930 day,
931 hour: 0,
932 minute: 0,
933 second: 0,
934 microsecond: 0,
935 timezone: None,
936 granularity: DateGranularity::Full,
937 }
938 }
939
940 fn has_param(params: &Value, name: &str) -> bool {
941 params
942 .as_array()
943 .map(|a| a.iter().any(|p| p["name"] == name))
944 .unwrap_or(false)
945 }
946
947 #[test]
952 fn test_generate_openapi_x_tag_groups() {
953 let engine = create_engine_with_code(
954 "spec pricing
955 data quantity: 10
956 rule total: quantity * 2",
957 );
958 let spec = generate_openapi(&engine, false);
959
960 let groups = spec["x-tagGroups"]
961 .as_array()
962 .expect("x-tagGroups should be array");
963 assert_eq!(groups.len(), 2);
964 assert_eq!(groups[0]["name"], "Overview");
965 assert_eq!(groups[0]["tags"], json!(["Specs"]));
966 assert_eq!(groups[1]["name"], "Specs");
967 assert_eq!(groups[1]["tags"], json!(["pricing"]));
968 }
969
970 #[test]
971 fn test_tag_description_uses_spec_commentary_when_present() {
972 let engine = create_engine_with_code(
973 r#"spec artikel
974"""
975Artikel pricing for Dutch VAT bands.
976"""
977data price: 10
978rule total: price"#,
979 );
980 let spec = generate_openapi(&engine, false);
981 let tags = spec["tags"].as_array().expect("tags array");
982 let artikel = tags
983 .iter()
984 .find(|t| t["name"] == "artikel")
985 .expect("artikel tag");
986 assert_eq!(
987 artikel["description"],
988 "Artikel pricing for Dutch VAT bands."
989 );
990 }
991
992 #[test]
993 fn test_tag_description_falls_back_without_commentary() {
994 let engine = create_engine_with_code(
995 "spec pricing
996 data quantity: 10
997 rule total: quantity * 2",
998 );
999 let spec = generate_openapi(&engine, false);
1000 let tags = spec["tags"].as_array().expect("tags array");
1001 let pricing = tags
1002 .iter()
1003 .find(|t| t["name"] == "pricing")
1004 .expect("pricing tag");
1005 assert_eq!(pricing["description"], "Show and evaluate `pricing`");
1006 }
1007
1008 #[test]
1009 fn test_spec_path_has_get_and_post() {
1010 let engine = create_engine_with_code(
1011 "spec pricing
1012 data quantity: 10
1013 rule total: quantity * 2",
1014 );
1015 let spec = generate_openapi(&engine, false);
1016
1017 assert!(
1018 spec["paths"]["/pricing"].is_object(),
1019 "single spec path /pricing"
1020 );
1021 assert!(spec["paths"]["/pricing"]["get"].is_object());
1022 assert!(spec["paths"]["/pricing"]["post"].is_object());
1023
1024 assert_eq!(
1025 spec["paths"]["/pricing"]["get"]["operationId"],
1026 "get_pricing"
1027 );
1028 assert_eq!(
1029 spec["paths"]["/pricing"]["post"]["operationId"],
1030 "post_pricing"
1031 );
1032 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
1033
1034 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
1035 .as_array()
1036 .expect("parameters array");
1037 let param_names: Vec<&str> = get_params
1038 .iter()
1039 .map(|p| p["name"].as_str().unwrap())
1040 .collect();
1041 assert!(
1042 !param_names.contains(&"rules"),
1043 "GET must not have rules query param (show is full interface)"
1044 );
1045 let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
1046 .as_array()
1047 .expect("post parameters array");
1048 let post_param_names: Vec<&str> = post_params
1049 .iter()
1050 .map(|p| p["name"].as_str().unwrap())
1051 .collect();
1052 assert!(
1053 post_param_names.contains(&"rules"),
1054 "POST must have rules query param"
1055 );
1056 assert!(
1057 param_names.contains(&"Accept-Datetime"),
1058 "GET must have Accept-Datetime header"
1059 );
1060 let post_rules = post_params
1061 .iter()
1062 .find(|p| p["name"] == "rules")
1063 .expect("rules param");
1064 assert_eq!(
1065 post_rules["x-disabled"], false,
1066 "Scalar must enable optional rules by default or Try Request omits ?rules="
1067 );
1068 let accept = get_params
1069 .iter()
1070 .find(|p| p["name"] == "Accept-Datetime")
1071 .expect("Accept-Datetime param");
1072 assert_eq!(
1073 accept["x-disabled"], false,
1074 "Scalar must enable optional Accept-Datetime by default or Try Request omits it"
1075 );
1076
1077 let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1078 ["application/json"]["schema"]["$ref"]
1079 .as_str()
1080 .unwrap();
1081 let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1082 ["application/json"]["schema"]["$ref"]
1083 .as_str()
1084 .unwrap();
1085 assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
1086 assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1087 assert_ne!(get_ref, post_ref);
1088
1089 let get_show = &spec["components"]["schemas"]["pricing_get_show"];
1090 assert_eq!(get_show["$ref"], SHOW_SCHEMA_REF);
1091 assert_eq!(SHOW_SCHEMA_REF, "#/components/schemas/Show");
1092 let show = &spec["components"]["schemas"]["Show"];
1093 assert_eq!(show["type"], "object");
1094 let mut stack = vec![show];
1095 while let Some(node) = stack.pop() {
1096 match node {
1097 Value::Object(map) => {
1098 if let Some(Value::String(target)) = map.get("$ref") {
1099 assert!(
1100 !target.starts_with("http"),
1101 "Show schema must not $ref {target}"
1102 );
1103 }
1104 stack.extend(map.values());
1105 }
1106 Value::Array(items) => stack.extend(items),
1107 _ => {}
1108 }
1109 }
1110
1111 let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1112 assert!(h200["headers"]["Memento-Datetime"].is_object());
1113 assert!(h200["headers"]["Vary"].is_object());
1114 }
1115
1116 #[test]
1117 fn suggestion_only_field_is_required_with_json_schema_default() {
1118 let engine = create_engine_with_code(
1119 r#"
1120spec age_check
1121data age: number -> suggest 18
1122rule adult: age >= 18
1123"#,
1124 );
1125 let spec = generate_openapi(&engine, false);
1126 let body = &spec["components"]["schemas"]["age_check_request"];
1127 let required = body["required"]
1128 .as_array()
1129 .expect("required array")
1130 .iter()
1131 .map(|v| v.as_str().unwrap())
1132 .collect::<Vec<_>>();
1133 assert!(
1134 required.contains(&"age"),
1135 "suggest-only fields must stay required, got {required:?}"
1136 );
1137 assert!(body["properties"]["age"].get("default").is_none());
1138 assert_eq!(body["properties"]["age"]["examples"], json!(["18"]));
1139 }
1140
1141 #[test]
1146 fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1147 let engine = create_engine_with_code(
1148 "spec pricing
1149 data quantity: 10
1150 rule total: quantity * 2",
1151 );
1152 let spec = generate_openapi(&engine, false);
1153
1154 let paths = spec["paths"].as_object().expect("paths object");
1155 assert!(paths.contains_key("/"));
1156 assert_eq!(paths["/"]["get"]["operationId"], "specs");
1157 assert!(!paths.contains_key("/openapi.json"));
1158 assert!(!paths.contains_key("/health"));
1159 assert!(!paths.contains_key("/docs"));
1160 assert!(!paths.contains_key("/schema/pricing"));
1161 assert!(!paths.contains_key("/schema/pricing/{rules}"));
1162 assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1163 }
1164
1165 #[test]
1166 fn test_generate_openapi_explain_adds_x_explain_and_explanation_schema() {
1167 let engine = create_engine_with_code(
1168 "spec pricing
1169 data quantity: 10
1170 rule total: quantity * 2",
1171 );
1172 let spec = generate_openapi(&engine, true);
1173
1174 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1175 assert!(has_param(get_params, "x-explain"));
1176
1177 let rule_result = &spec["components"]["schemas"]["RuleResult"];
1178 assert_eq!(
1179 rule_result["properties"]["explanation"]["$ref"],
1180 "#/components/schemas/RuleNode"
1181 );
1182 assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1183 assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1184
1185 let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1186 assert!(evaluate["required"]
1187 .as_array()
1188 .unwrap()
1189 .contains(&json!("spec")));
1190 assert!(evaluate["required"]
1191 .as_array()
1192 .unwrap()
1193 .contains(&json!("effective")));
1194 assert!(evaluate["required"]
1195 .as_array()
1196 .unwrap()
1197 .contains(&json!("results")));
1198 let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1199 .as_str()
1200 .unwrap();
1201 assert_eq!(total_ref, "#/components/schemas/RuleResult");
1202 }
1203
1204 #[test]
1205 fn test_generate_openapi_multiple_specs() {
1206 let engine = create_engine_with_files(vec![
1207 (
1208 "pricing.lemma",
1209 "spec pricing
1210 data quantity: 10
1211 rule total: quantity * 2",
1212 ),
1213 (
1214 "shipping.lemma",
1215 "spec shipping
1216 data weight: 5
1217 rule cost: weight * 3",
1218 ),
1219 ]);
1220 let spec = generate_openapi(&engine, false);
1221
1222 assert!(spec["paths"]["/pricing"].is_object());
1223 assert!(spec["paths"]["/shipping"].is_object());
1224 }
1225
1226 #[test]
1227 fn test_nested_spec_path_schema_refs_are_valid() {
1228 let engine = create_engine_with_code(
1229 "spec bc
1230 data x: number
1231 rule result: x",
1232 );
1233 let spec = generate_openapi(&engine, false);
1234
1235 assert!(spec["paths"]["/bc"]["post"].is_object());
1236 let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1237 let content_keys: Vec<&str> = post_content
1238 .as_object()
1239 .expect("requestBody.content object")
1240 .keys()
1241 .map(|k| k.as_str())
1242 .collect();
1243 assert_eq!(
1244 content_keys.first().copied(),
1245 Some("application/x-www-form-urlencoded"),
1246 "form-urlencoded must be first so Scalar docs default to Form URL Encoded"
1247 );
1248 let body_ref = post_content["application/json"]["schema"]["$ref"]
1249 .as_str()
1250 .unwrap();
1251 let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1252 .as_str()
1253 .unwrap();
1254 assert_eq!(body_ref, "#/components/schemas/bc_request");
1255 assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1256 assert!(spec["components"]["schemas"]["bc_request"].is_object());
1257 assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1258 assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1259 assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1260 }
1261
1262 #[test]
1267 fn test_generate_openapi_effective_reflects_specific_time() {
1268 let engine = create_engine_with_code(
1269 "spec pricing
1270 data quantity: 10
1271 rule total: quantity * 2",
1272 );
1273 let effective = date(2025, 6, 15);
1274 let spec = generate_openapi_effective(&engine, false, &effective);
1275
1276 assert_eq!(spec["openapi"], "3.1.0");
1277 let version = spec["info"]["version"].as_str().unwrap();
1278 assert!(
1279 version.contains("2025-06-15"),
1280 "version string should contain the effective date, got: {}",
1281 version
1282 );
1283 }
1284
1285 #[test]
1286 fn test_effective_shows_correct_temporal_version_interface() {
1287 let engine = create_engine_with_files(vec![(
1288 "policy.lemma",
1289 r#"
1290spec policy
1291data base: 100
1292rule discount: 10
1293
1294spec policy 2025-06-01
1295data base: 200
1296data premium: boolean
1297rule discount: 20
1298rule surcharge:
1299 5
1300 unless premium then 10
1301"#,
1302 )]);
1303
1304 let before = date(2025, 3, 1);
1305 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1306
1307 assert!(spec_v1["paths"]["/policy"].is_object());
1308 let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1309 let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1310 assert_eq!(
1311 v1_result["discount"]["$ref"].as_str(),
1312 Some("#/components/schemas/RuleResult"),
1313 "v1 should have discount rule"
1314 );
1315 assert!(
1316 v1_result["surcharge"].is_null(),
1317 "v1 must NOT have surcharge rule"
1318 );
1319 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1320 assert!(
1321 v1_request["properties"]["premium"].is_null(),
1322 "v1 must NOT have premium data"
1323 );
1324
1325 let after = date(2025, 8, 1);
1326 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1327
1328 let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1329 let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1330 assert!(
1331 v2_result["discount"]["$ref"].is_string(),
1332 "v2 should have discount rule"
1333 );
1334 assert!(
1335 v2_result["surcharge"]["$ref"].is_string(),
1336 "v2 should have surcharge rule"
1337 );
1338 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1339 assert!(
1340 v2_request["properties"]["premium"].is_object(),
1341 "v2 should have premium data"
1342 );
1343 }
1344
1345 #[test]
1354 fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1355 let engine = create_engine_with_files(vec![(
1356 "policy.lemma",
1357 r#"
1358spec policy 2025-01-01
1359data base: 10
1360rule total: base
1361
1362spec policy 2026-01-01
1363data base: 99
1364rule total: base
1365"#,
1366 )]);
1367
1368 let at_earlier = date(2025, 6, 1);
1369 let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1370 let earlier_path = &earlier_doc["paths"]["/policy"];
1371 assert_eq!(
1372 earlier_path["x-effective-from"].as_str(),
1373 Some("2025-01-01"),
1374 "earlier version effective_from on PathItem"
1375 );
1376 assert_eq!(
1377 earlier_path["x-effective-to"].as_str(),
1378 Some("2026-01-01"),
1379 "earlier version effective_to equals next version's effective_from"
1380 );
1381
1382 let at_latest = date(2026, 6, 1);
1383 let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1384 let latest_path = &latest_doc["paths"]["/policy"];
1385 assert_eq!(
1386 latest_path["x-effective-from"].as_str(),
1387 Some("2026-01-01"),
1388 "latest version effective_from on PathItem"
1389 );
1390 assert!(
1391 latest_path["x-effective-to"].is_null(),
1392 "latest version has no successor; x-effective-to must be null: {latest_path}"
1393 );
1394 }
1395
1396 #[test]
1399 fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1400 let engine = create_engine_with_code(
1401 "spec pricing
1402 data quantity: 10
1403 rule total: quantity * 2",
1404 );
1405 let document = generate_openapi(&engine, false);
1406 let path_item = &document["paths"]["/pricing"];
1407 assert!(
1408 path_item["x-effective-from"].is_null(),
1409 "unversioned spec: x-effective-from must be null: {path_item}"
1410 );
1411 assert!(
1412 path_item["x-effective-to"].is_null(),
1413 "unversioned spec: x-effective-to must be null: {path_item}"
1414 );
1415 }
1416
1417 #[test]
1422 fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1423 let engine = create_engine_with_files(vec![(
1424 "policy.lemma",
1425 r#"
1426spec policy
1427data base: 100
1428rule discount: 10
1429
1430spec policy 2025-06-01
1431data base: 200
1432rule discount: 20
1433"#,
1434 )]);
1435
1436 let sources = temporal_api_sources(&engine);
1437
1438 assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1439
1440 assert_eq!(sources[0].title, "Now");
1441 assert_eq!(sources[0].slug, NOW_SLUG);
1442 assert_eq!(sources[0].url, "/openapi.json");
1443
1444 assert_eq!(sources[1].title, "Effective 2025-06-01");
1445 assert_eq!(sources[1].slug, "2025-06-01");
1446 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1447 }
1448
1449 #[test]
1450 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1451 let engine = create_engine_with_files(vec![
1452 (
1453 "policy.lemma",
1454 r#"
1455spec policy
1456data base: 100
1457rule discount: 10
1458
1459spec policy 2025-06-01
1460data base: 200
1461rule discount: 20
1462"#,
1463 ),
1464 (
1465 "rates.lemma",
1466 r#"
1467spec rates
1468data rate: 5
1469rule total: rate * 2
1470
1471spec rates 2025-03-01
1472data rate: 7
1473rule total: rate * 2
1474
1475spec rates 2025-06-01
1476data rate: 9
1477rule total: rate * 2
1478"#,
1479 ),
1480 ]);
1481
1482 let sources = temporal_api_sources(&engine);
1483
1484 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1485 assert!(
1486 slugs.contains(&"2025-03-01"),
1487 "should contain rates boundary"
1488 );
1489 assert!(
1490 slugs.contains(&"2025-06-01"),
1491 "should contain shared boundary"
1492 );
1493 assert!(slugs.contains(&NOW_SLUG), "should contain now");
1494 assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1495 }
1496
1497 #[test]
1498 fn test_temporal_sources_ordered_chronologically() {
1499 let engine = create_engine_with_files(vec![(
1500 "policy.lemma",
1501 r#"
1502spec policy
1503data base: 100
1504rule discount: 10
1505
1506spec policy 2024-01-01
1507data base: 50
1508rule discount: 5
1509
1510spec policy 2025-06-01
1511data base: 200
1512rule discount: 20
1513"#,
1514 )]);
1515
1516 let sources = temporal_api_sources(&engine);
1517 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1518 assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1519 }
1520
1521 #[test]
1526 fn test_post_schema_text_with_options_has_enum() {
1527 let engine = create_engine_with_code(
1528 "spec test
1529 data sku: text -> option \"A\" -> option \"B\"
1530 rule result: sku",
1531 );
1532 let spec = generate_openapi(&engine, false);
1533
1534 let sku_prop = &spec["components"]["schemas"]["test_request"]["properties"]["sku"];
1535 assert!(sku_prop["enum"].is_array());
1536 let enums = sku_prop["enum"].as_array().unwrap();
1537 assert_eq!(enums.len(), 2);
1538 assert_eq!(enums[0], "A");
1539 assert_eq!(enums[1], "B");
1540 }
1541
1542 #[test]
1543 fn test_post_schema_boolean_is_json_boolean() {
1544 let engine = create_engine_with_code(
1545 "spec test
1546 data is_active: boolean
1547 rule result: is_active",
1548 );
1549 let spec = generate_openapi(&engine, false);
1550
1551 let schema = &spec["components"]["schemas"]["test_request"];
1552 let is_active = &schema["properties"]["is_active"];
1553 assert_eq!(is_active["oneOf"][0]["type"], "boolean");
1554
1555 let form_schema = &spec["components"]["schemas"]["test_form_request"];
1556 let form_is_active = &form_schema["properties"]["is_active"];
1557 assert_eq!(form_is_active["type"], "string");
1558 assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1559 }
1560
1561 #[test]
1562 fn test_post_schema_number_is_string() {
1563 let engine = create_engine_with_code(
1564 "spec test
1565 data quantity: number
1566 rule result: quantity",
1567 );
1568 let spec = generate_openapi(&engine, false);
1569
1570 let schema = &spec["components"]["schemas"]["test_request"];
1571 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1572 assert!(schema["properties"]["quantity"].get("pattern").is_none());
1573 }
1574
1575 #[test]
1576 fn test_post_schema_date_and_time_use_standard_formats() {
1577 let engine = create_engine_with_code(
1578 "spec test
1579 data start: date
1580 data at: time
1581 rule result: start
1582 rule when: at",
1583 );
1584 let spec = generate_openapi(&engine, false);
1585
1586 let schema = &spec["components"]["schemas"]["test_request"];
1587 assert_eq!(
1588 schema["properties"]["start"]["oneOf"],
1589 json!([
1590 {"type": "string", "format": "date"},
1591 {"type": "string", "format": "date-time"}
1592 ])
1593 );
1594 assert_eq!(schema["properties"]["at"]["format"], "time");
1595 let form = &spec["components"]["schemas"]["test_form_request"];
1596 assert_eq!(form["properties"]["start"]["format"], "date");
1597 assert_eq!(form["properties"]["at"]["format"], "time");
1598 }
1599
1600 #[test]
1601 fn test_data_with_default_is_not_required() {
1602 let engine = create_engine_with_code(
1603 "spec test
1604 data quantity: 10
1605 data name: text
1606 rule result: quantity
1607 rule label: name",
1608 );
1609 let spec = generate_openapi(&engine, false);
1610
1611 let schema = &spec["components"]["schemas"]["test_request"];
1612 let required = schema["required"]
1613 .as_array()
1614 .expect("required should be array");
1615
1616 assert!(required.contains(&Value::String("name".to_string())));
1617 assert!(!required.contains(&Value::String("quantity".to_string())));
1618 }
1619
1620 #[test]
1621 fn test_help_and_default_in_openapi() {
1622 let engine = create_engine_with_code(
1623 r#"spec test
1624data quantity: number -> help "Number of items to order" -> suggest 10
1625data active: boolean -> help "Whether the feature is enabled" -> suggest true
1626rule result:
1627 quantity
1628 unless active then 0
1629"#,
1630 );
1631 let spec = generate_openapi(&engine, false);
1632
1633 let req_schema = &spec["components"]["schemas"]["test_request"];
1634 assert!(req_schema["properties"]["quantity"]["description"]
1635 .as_str()
1636 .unwrap()
1637 .contains("Number of items to order"));
1638 assert_eq!(
1639 req_schema["properties"]["quantity"]["examples"],
1640 json!(["10"])
1641 );
1642 assert!(req_schema["properties"]["active"]["description"]
1643 .as_str()
1644 .unwrap()
1645 .contains("Whether the feature is enabled"));
1646 assert_eq!(
1647 req_schema["properties"]["active"]["examples"],
1648 json!([true])
1649 );
1650 assert_eq!(
1651 spec["components"]["schemas"]["test_form_request"]["properties"]["active"]["examples"],
1652 json!(["true"])
1653 );
1654 let required = req_schema["required"]
1655 .as_array()
1656 .expect("required array")
1657 .iter()
1658 .map(|v| v.as_str().unwrap())
1659 .collect::<Vec<_>>();
1660 assert!(required.contains(&"quantity"));
1661 assert!(required.contains(&"active"));
1662 }
1663
1664 const LIST_CODE: &str = r#"spec line
1665data price: number
1666data qty: number -> suggest 1
1667rule cost: price * qty
1668
1669spec order
1670uses line
1671data lines: line list
1672data discounts: number list
1673 -> with 5, 10
1674rule total: lines -> sum cost
1675rule biggest: lines -> sort cost desc -> first
1676rule discount_total: discounts -> sum
1677
1678spec part
1679data own_cost: number
1680data components: part list
1681rule unit_cost:
1682 own_cost + (components -> sum components.unit_cost)
1683 unless components -> count is 0 then own_cost
1684"#;
1685
1686 fn row_ref(schema: &Value) -> &str {
1687 schema["items"]["$ref"]
1688 .as_str()
1689 .unwrap_or_else(|| panic!("array of row refs expected: {schema}"))
1690 }
1691
1692 #[test]
1693 fn instance_list_is_array_of_row_objects_and_never_required() {
1694 let engine = create_engine_with_code(LIST_CODE);
1695 let document = generate_openapi(&engine, false);
1696 let schemas = &document["components"]["schemas"];
1697 let request = &schemas["order_request"];
1698 let lines = &request["properties"]["lines"];
1699 assert_eq!(lines["type"], "array");
1700 assert_eq!(row_ref(lines), "#/components/schemas/LemmaRow_line");
1701 assert!(
1702 request.get("required").is_none(),
1703 "lists are empty when absent, never required: {request}"
1704 );
1705
1706 let row = &schemas["LemmaRow_line"];
1707 assert_eq!(row["type"], "object");
1708 assert_eq!(row["additionalProperties"], true);
1709 assert_eq!(row["title"], "line row");
1710 assert_eq!(row["properties"]["price"]["type"], "string");
1711 assert_eq!(row["properties"]["qty"]["examples"], json!(["1"]));
1712 assert_eq!(row["required"], json!(["price", "qty"]));
1713 }
1714
1715 #[test]
1716 fn value_list_is_array_of_literals_with_array_default() {
1717 let engine = create_engine_with_code(LIST_CODE);
1718 let document = generate_openapi(&engine, false);
1719 let discounts =
1720 &document["components"]["schemas"]["order_request"]["properties"]["discounts"];
1721 assert_eq!(discounts["type"], "array");
1722 assert_eq!(discounts["items"], json!({ "type": "string" }));
1723 assert_eq!(discounts["default"], json!(["5", "10"]));
1724 }
1725
1726 const SOURCE_ROWS_CODE: &str = r#"spec article
1727data price: number
1728
1729spec line
1730data qty: number
1731uses article: article
1732rule cost: qty * article.price
1733
1734spec order
1735uses line
1736data lines: line list
1737 -> with qty: 2, article.price: 5
1738 -> with qty: 1
1739rule total: lines -> sum cost
1740
1741spec bracket
1742data threshold: number
1743data income: number
1744rule applies: income > threshold
1745
1746spec tax
1747data income: number
1748uses bracket
1749data brackets: bracket list
1750 -> with threshold: 0, income: income
1751rule applicable: brackets -> where applies -> count
1752"#;
1753
1754 #[test]
1755 fn instance_list_default_is_source_rows_as_objects() {
1756 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1757 let document = generate_openapi(&engine, false);
1758 let lines = &document["components"]["schemas"]["order_request"]["properties"]["lines"];
1759 assert_eq!(
1760 lines["default"],
1761 json!([{ "qty": "2", "article.price": "5" }, { "qty": "1" }])
1762 );
1763 }
1764
1765 #[test]
1766 fn instance_list_with_referenced_fields_has_no_default() {
1767 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1768 let document = generate_openapi(&engine, false);
1769 let brackets = &document["components"]["schemas"]["tax_request"]["properties"]["brackets"];
1770 assert_eq!(brackets["type"], "array");
1771 assert!(brackets.get("default").is_none(), "{brackets}");
1772 assert!(
1773 brackets["x-lemma-value"]["list"][0]["fields"]["income"]["reference"] == "income",
1774 "{brackets}"
1775 );
1776 }
1777
1778 #[test]
1779 fn row_schema_accepts_fields_of_row_spec_uses() {
1780 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1781 let document = generate_openapi(&engine, false);
1782 let row = &document["components"]["schemas"]["LemmaRow_line"];
1783 assert_eq!(row["additionalProperties"], true);
1784 assert_eq!(row["properties"]["article.price"]["type"], "string");
1785 }
1786
1787 #[test]
1788 fn dotted_top_level_keys_are_request_properties() {
1789 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1790 let document = generate_openapi(&engine, false);
1791 let schemas = &document["components"]["schemas"];
1792 let request = &schemas["line_request"];
1793 assert_eq!(request["properties"]["article.price"]["type"], "string");
1794 assert_eq!(request["required"], json!(["article.price", "qty"]));
1795 assert_eq!(
1796 schemas["line_form_request"]["properties"]["article.price"]["type"],
1797 "string"
1798 );
1799 }
1800
1801 #[test]
1802 fn self_list_row_schema_refers_to_itself() {
1803 let engine = create_engine_with_code(LIST_CODE);
1804 let document = generate_openapi(&engine, false);
1805 let schemas = &document["components"]["schemas"];
1806 assert_eq!(
1807 row_ref(&schemas["part_request"]["properties"]["components"]),
1808 "#/components/schemas/LemmaRow_part"
1809 );
1810 assert_eq!(
1811 row_ref(&schemas["LemmaRow_part"]["properties"]["components"]),
1812 "#/components/schemas/LemmaRow_part"
1813 );
1814 }
1815
1816 #[test]
1817 fn list_inputs_omit_form_body_so_docs_keep_lists() {
1818 let engine = create_engine_with_code(LIST_CODE);
1819 let document = generate_openapi(&engine, false);
1820 let content = &document["paths"]["/order"]["post"]["requestBody"]["content"];
1821 let content_keys: Vec<&str> = content
1822 .as_object()
1823 .expect("requestBody.content object")
1824 .keys()
1825 .map(|k| k.as_str())
1826 .collect();
1827 assert_eq!(
1828 content_keys,
1829 ["application/json"],
1830 "form would drop list inputs; JSON-only keeps them in /docs: {content}"
1831 );
1832 assert!(
1833 document["components"]["schemas"]
1834 .get("order_form_request")
1835 .is_none(),
1836 "no stripped form schema when lists are present"
1837 );
1838 assert_eq!(
1839 document["components"]["schemas"]["order_request"]["properties"]["lines"]["type"],
1840 "array"
1841 );
1842 }
1843
1844 #[test]
1845 fn nested_uses_self_list_is_on_json_body_not_form() {
1846 let engine = create_engine_with_code(
1847 r#"
1848spec ingredient
1849data percentage: 10%
1850data componenten: ingredient list
1851rule total:
1852 percentage
1853 unless componenten -> count > 0 then componenten -> sum total
1854
1855spec artikel
1856uses ingredient
1857rule prijs: ingredient.total
1858"#,
1859 );
1860 let document = generate_openapi(&engine, false);
1861 let content = &document["paths"]["/artikel"]["post"]["requestBody"]["content"];
1862 assert!(content.get("application/x-www-form-urlencoded").is_none());
1863 assert_eq!(
1864 content["application/json"]["schema"]["$ref"],
1865 "#/components/schemas/artikel_request"
1866 );
1867 let request = &document["components"]["schemas"]["artikel_request"]["properties"];
1868 assert_eq!(request["ingredient.componenten"]["type"], "array");
1869 assert_eq!(
1870 row_ref(&request["ingredient.componenten"]),
1871 "#/components/schemas/LemmaRow_ingredient"
1872 );
1873 assert!(request["ingredient.percentage"].is_object());
1874 }
1875
1876 #[test]
1877 fn evaluate_response_describes_rows_and_list_values() {
1878 let engine = create_engine_with_code(LIST_CODE);
1879 let document = generate_openapi(&engine, false);
1880 let schemas = &document["components"]["schemas"];
1881 assert_eq!(
1882 schemas["order_evaluate_response"]["properties"]["rows"]["items"]["$ref"],
1883 "#/components/schemas/RowResult"
1884 );
1885 let rule_result = &schemas["RuleResult"]["properties"];
1886 assert_eq!(
1887 rule_result["row"]["$ref"],
1888 "#/components/schemas/RowReference"
1889 );
1890 assert_eq!(
1891 rule_result["list"]["items"]["$ref"],
1892 "#/components/schemas/RuleResultValue"
1893 );
1894 assert_eq!(
1895 rule_result["spec"]["$ref"],
1896 "#/components/schemas/SpecResult"
1897 );
1898 assert_eq!(
1899 schemas["RuleResultValue"]["properties"]["list"]["items"]["$ref"],
1900 "#/components/schemas/RuleResultValue"
1901 );
1902 }
1903
1904 #[test]
1905 fn shared_row_schema_across_parents_does_not_panic() {
1906 let code = r#"
1907spec article
1908data price: number
1909
1910spec line
1911uses article: article
1912data qty: number
1913rule cost: qty * article.price
1914
1915spec order
1916uses line
1917data lines: line list
1918rule total: lines -> sum cost
1919
1920spec cart
1921uses line
1922data lines: line list
1923rule subtotal: lines -> sum qty
1924"#;
1925 let engine = create_engine_with_code(code);
1926 let document = generate_openapi(&engine, false);
1927 let row = &document["components"]["schemas"]["LemmaRow_line"];
1928 assert_eq!(row["type"], "object");
1929 assert_eq!(
1930 row["properties"]["qty"]["x-lemma-needed-by-rules"],
1931 json!([])
1932 );
1933 assert!(document["paths"].get("/order").is_some());
1934 assert!(document["paths"].get("/cart").is_some());
1935 }
1936
1937 #[test]
1938 fn every_schema_ref_resolves() {
1939 let engine = create_engine_with_code(LIST_CODE);
1940 let document = generate_openapi(&engine, false);
1941 let schemas = document["components"]["schemas"]
1942 .as_object()
1943 .expect("components.schemas");
1944 let mut stack = vec![&document];
1945 let mut refs = 0;
1946 while let Some(node) = stack.pop() {
1947 match node {
1948 Value::Object(map) => {
1949 if let Some(Value::String(target)) = map.get("$ref") {
1950 let name = target
1951 .strip_prefix("#/components/schemas/")
1952 .unwrap_or_else(|| panic!("unexpected $ref {target}"));
1953 assert!(schemas.contains_key(name), "dangling $ref {target}");
1954 refs += 1;
1955 }
1956 stack.extend(map.values());
1957 }
1958 Value::Array(items) => stack.extend(items),
1959 _ => {}
1960 }
1961 }
1962 assert!(refs > 0);
1963 }
1964}