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 component_schemas.insert(
579 post_form_body_schema_name.clone(),
580 build_post_form_request_schema(document),
581 );
582 let mut row_schemas = Map::new();
583 if let Some(defs) = document.get("$defs") {
584 for (name, schema) in defs.as_object().expect("BUG: Show $defs is an object") {
585 let mut schema = schema.clone();
586 rewrite_defs_refs(&mut schema);
587 row_schemas.insert(name.clone(), schema);
588 }
589 }
590
591 let path_item = build_spec_path_item_with_show_refs(
592 spec_name,
593 (
594 &get_show_component_name,
595 &evaluate_response_schema_name,
596 &post_body_schema_name,
597 &post_form_body_schema_name,
598 ),
599 &rule_names,
600 explain,
601 effective_range,
602 );
603
604 SpecOpenApiArtifacts {
605 path_item,
606 component_schemas,
607 row_schemas,
608 }
609}
610
611fn x_explain_header_parameter() -> Value {
612 json!({
613 "name": "x-explain",
614 "in": "header",
615 "required": false,
616 "description": "Include explanations. Server needs `--explain`.",
617 "schema": { "type": "string", "default": "true" }
618 })
619}
620
621fn accept_datetime_header_parameter() -> Value {
622 json!({
623 "name": "Accept-Datetime",
624 "in": "header",
625 "required": false,
626 "description": "Evaluate at this datetime (Lemma forms: year, year-month, date, or datetime). Omit the header to evaluate at the current time.",
627 "schema": { "type": "string" },
628 "example": "2025-01-01"
629 })
630}
631
632fn build_spec_path_item_with_show_refs(
634 spec_name: &str,
635 component_names: (&str, &str, &str, &str),
636 rule_names: &[String],
637 explain: bool,
638 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
639) -> Value {
640 let (
641 get_show_component_name,
642 evaluate_response_schema_name,
643 post_body_schema_name,
644 post_form_body_schema_name,
645 ) = component_names;
646 let (effective_from, effective_to) = effective_range;
647
648 let get_show_ref = json!({
649 "$ref": format!("#/components/schemas/{}", get_show_component_name)
650 });
651 let evaluate_schema_ref = json!({
652 "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
653 });
654 let body_ref = json!({
655 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
656 });
657 let form_body_ref = json!({
658 "$ref": format!("#/components/schemas/{}", post_form_body_schema_name)
659 });
660
661 let tag = spec_name.replace('.', "_");
662
663 let rules_example = if rule_names.is_empty() {
664 String::new()
665 } else {
666 rule_names.join(",")
667 };
668
669 let rules_param = json!({
670 "name": "rules",
671 "in": "query",
672 "required": false,
673 "description": "Rules to evaluate, comma-separated. Omit for all.",
674 "schema": { "type": "string" },
675 "example": rules_example
676 });
677
678 let mut get_parameters: Vec<Value> = Vec::new();
679 get_parameters.push(accept_datetime_header_parameter());
680 if explain {
681 get_parameters.push(x_explain_header_parameter());
682 }
683
684 let get_operation_id = format!("get_{}", spec_name);
685 let post_operation_id = format!("post_{}", spec_name);
686
687 let mut post_parameters: Vec<Value> = vec![rules_param];
688 post_parameters.push(accept_datetime_header_parameter());
689 if explain {
690 post_parameters.push(x_explain_header_parameter());
691 }
692
693 let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
694 match dt {
695 Some(d) => Value::String(d.to_string()),
696 None => Value::Null,
697 }
698 };
699
700 json!({
701 "x-effective-from": datetime_or_null(effective_from),
702 "x-effective-to": datetime_or_null(effective_to),
703 "get": {
704 "operationId": get_operation_id,
705 "summary": "Show spec",
706 "tags": [tag],
707 "parameters": get_parameters,
708 "responses": {
709 "200": {
710 "description": "Spec interface",
711 "headers": memento_spec_response_headers(),
712 "content": {
713 "application/json": {
714 "schema": get_show_ref
715 }
716 }
717 },
718 "400": error_response_schema(),
719 "404": not_found_response_schema()
720 }
721 },
722 "post": {
723 "operationId": post_operation_id,
724 "summary": "Evaluate",
725 "tags": [tag],
726 "parameters": post_parameters,
727 "requestBody": {
728 "required": true,
729 "content": {
730 "application/x-www-form-urlencoded": {
732 "schema": form_body_ref
733 },
734 "application/json": {
735 "schema": body_ref
736 }
737 }
738 },
739 "responses": {
740 "200": {
741 "description": "Evaluation results",
742 "headers": memento_spec_response_headers(),
743 "content": {
744 "application/json": {
745 "schema": evaluate_schema_ref
746 }
747 }
748 },
749 "400": error_response_schema(),
750 "404": not_found_response_schema()
751 }
752 }
753 })
754}
755
756fn build_post_request_schema(document: &Value) -> Value {
763 let mut schema = json!({
764 "type": "object",
765 "additionalProperties": true,
766 "properties": document["properties"].clone()
767 });
768 if let Some(required) = document.get("required") {
769 schema["required"] = required.clone();
770 }
771 rewrite_defs_refs(&mut schema);
772 schema
773}
774
775fn rewrite_defs_refs(value: &mut Value) {
776 let mut pending: Vec<&mut Value> = vec![value];
777 while let Some(value) = pending.pop() {
778 match value {
779 Value::Object(map) => {
780 if let Some(Value::String(target)) = map.get_mut("$ref") {
781 if let Some(name) = target.strip_prefix("#/$defs/") {
782 *target = format!("#/components/schemas/{name}");
783 }
784 }
785 pending.extend(map.values_mut());
786 }
787 Value::Array(items) => pending.extend(items.iter_mut()),
788 _ => {}
789 }
790 }
791}
792
793const FORM_KEYWORDS: [&str; 5] = ["enum", "minLength", "maxLength", "description", "format"];
797
798fn build_post_form_request_schema(document: &Value) -> Value {
801 let mut properties = Map::new();
802 for (name, property) in document["properties"]
803 .as_object()
804 .expect("BUG: Show properties is an object")
805 {
806 let mut field = Map::new();
807 field.insert("type".to_string(), json!("string"));
808 let scalar = property
809 .get("oneOf")
810 .map_or(property, |alternatives| &alternatives[0]);
811 match scalar["type"].as_str() {
812 Some("array") => continue,
813 Some("boolean") => {
814 field.insert("enum".to_string(), json!(["true", "false"]));
815 }
816 Some("string") => {}
817 other => unreachable!("BUG: Show property '{name}' has type {other:?}"),
818 }
819 for keyword in FORM_KEYWORDS {
820 if let Some(value) = scalar.get(keyword).or_else(|| property.get(keyword)) {
821 field.insert(keyword.to_string(), value.clone());
822 }
823 }
824 if let Some(default) = property.get("default") {
825 field.insert("default".to_string(), form_literal(default));
826 }
827 if let Some(examples) = property.get("examples") {
828 let examples = examples
829 .as_array()
830 .expect("BUG: Show examples is an array")
831 .iter()
832 .map(form_literal)
833 .collect();
834 field.insert("examples".to_string(), Value::Array(examples));
835 }
836 properties.insert(name.clone(), Value::Object(field));
837 }
838 let mut schema = json!({
839 "type": "object",
840 "properties": Value::Object(properties)
841 });
842 if let Some(required) = document.get("required") {
843 schema["required"] = required.clone();
844 }
845 schema
846}
847
848fn form_literal(value: &Value) -> Value {
849 match value {
850 Value::String(_) => value.clone(),
851 Value::Bool(boolean) => Value::String(boolean.to_string()),
852 other => unreachable!("BUG: scalar Show literal is a string or boolean, got {other}"),
853 }
854}
855
856#[cfg(test)]
861mod tests {
862 use super::*;
863 use lemma::{DateGranularity, DateTimeValue, SourceType};
864
865 fn create_engine_with_code(code: &str) -> Engine {
866 let mut engine = Engine::new();
867 engine
868 .load([(
869 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
870 code.to_string(),
871 )])
872 .expect("failed to parse lemma code");
873 engine
874 }
875
876 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
877 let mut engine = Engine::new();
878 for (name, code) in files {
879 engine
880 .load([(
881 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
882 code.to_string(),
883 )])
884 .expect("failed to parse lemma code");
885 }
886 engine
887 }
888
889 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
890 DateTimeValue {
891 year,
892 month,
893 day,
894 hour: 0,
895 minute: 0,
896 second: 0,
897 microsecond: 0,
898 timezone: None,
899 granularity: DateGranularity::Full,
900 }
901 }
902
903 fn has_param(params: &Value, name: &str) -> bool {
904 params
905 .as_array()
906 .map(|a| a.iter().any(|p| p["name"] == name))
907 .unwrap_or(false)
908 }
909
910 #[test]
915 fn test_generate_openapi_x_tag_groups() {
916 let engine = create_engine_with_code(
917 "spec pricing
918 data quantity: 10
919 rule total: quantity * 2",
920 );
921 let spec = generate_openapi(&engine, false);
922
923 let groups = spec["x-tagGroups"]
924 .as_array()
925 .expect("x-tagGroups should be array");
926 assert_eq!(groups.len(), 2);
927 assert_eq!(groups[0]["name"], "Overview");
928 assert_eq!(groups[0]["tags"], json!(["Specs"]));
929 assert_eq!(groups[1]["name"], "Specs");
930 assert_eq!(groups[1]["tags"], json!(["pricing"]));
931 }
932
933 #[test]
934 fn test_tag_description_uses_spec_commentary_when_present() {
935 let engine = create_engine_with_code(
936 r#"spec artikel
937"""
938Artikel pricing for Dutch VAT bands.
939"""
940data price: 10
941rule total: price"#,
942 );
943 let spec = generate_openapi(&engine, false);
944 let tags = spec["tags"].as_array().expect("tags array");
945 let artikel = tags
946 .iter()
947 .find(|t| t["name"] == "artikel")
948 .expect("artikel tag");
949 assert_eq!(
950 artikel["description"],
951 "Artikel pricing for Dutch VAT bands."
952 );
953 }
954
955 #[test]
956 fn test_tag_description_falls_back_without_commentary() {
957 let engine = create_engine_with_code(
958 "spec pricing
959 data quantity: 10
960 rule total: quantity * 2",
961 );
962 let spec = generate_openapi(&engine, false);
963 let tags = spec["tags"].as_array().expect("tags array");
964 let pricing = tags
965 .iter()
966 .find(|t| t["name"] == "pricing")
967 .expect("pricing tag");
968 assert_eq!(pricing["description"], "Show and evaluate `pricing`");
969 }
970
971 #[test]
972 fn test_spec_path_has_get_and_post() {
973 let engine = create_engine_with_code(
974 "spec pricing
975 data quantity: 10
976 rule total: quantity * 2",
977 );
978 let spec = generate_openapi(&engine, false);
979
980 assert!(
981 spec["paths"]["/pricing"].is_object(),
982 "single spec path /pricing"
983 );
984 assert!(spec["paths"]["/pricing"]["get"].is_object());
985 assert!(spec["paths"]["/pricing"]["post"].is_object());
986
987 assert_eq!(
988 spec["paths"]["/pricing"]["get"]["operationId"],
989 "get_pricing"
990 );
991 assert_eq!(
992 spec["paths"]["/pricing"]["post"]["operationId"],
993 "post_pricing"
994 );
995 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
996
997 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
998 .as_array()
999 .expect("parameters array");
1000 let param_names: Vec<&str> = get_params
1001 .iter()
1002 .map(|p| p["name"].as_str().unwrap())
1003 .collect();
1004 assert!(
1005 !param_names.contains(&"rules"),
1006 "GET must not have rules query param (show is full interface)"
1007 );
1008 let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
1009 .as_array()
1010 .expect("post parameters array");
1011 let post_param_names: Vec<&str> = post_params
1012 .iter()
1013 .map(|p| p["name"].as_str().unwrap())
1014 .collect();
1015 assert!(
1016 post_param_names.contains(&"rules"),
1017 "POST must have rules query param"
1018 );
1019 assert!(
1020 param_names.contains(&"Accept-Datetime"),
1021 "GET must have Accept-Datetime header"
1022 );
1023
1024 let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1025 ["application/json"]["schema"]["$ref"]
1026 .as_str()
1027 .unwrap();
1028 let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1029 ["application/json"]["schema"]["$ref"]
1030 .as_str()
1031 .unwrap();
1032 assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
1033 assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1034 assert_ne!(get_ref, post_ref);
1035
1036 let get_show = &spec["components"]["schemas"]["pricing_get_show"];
1037 assert_eq!(get_show["$ref"], SHOW_SCHEMA_REF);
1038 assert_eq!(SHOW_SCHEMA_REF, "#/components/schemas/Show");
1039 let show = &spec["components"]["schemas"]["Show"];
1040 assert_eq!(show["type"], "object");
1041 let mut stack = vec![show];
1042 while let Some(node) = stack.pop() {
1043 match node {
1044 Value::Object(map) => {
1045 if let Some(Value::String(target)) = map.get("$ref") {
1046 assert!(
1047 !target.starts_with("http"),
1048 "Show schema must not $ref {target}"
1049 );
1050 }
1051 stack.extend(map.values());
1052 }
1053 Value::Array(items) => stack.extend(items),
1054 _ => {}
1055 }
1056 }
1057
1058 let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1059 assert!(h200["headers"]["Memento-Datetime"].is_object());
1060 assert!(h200["headers"]["Vary"].is_object());
1061 }
1062
1063 #[test]
1064 fn suggestion_only_field_is_required_with_json_schema_default() {
1065 let engine = create_engine_with_code(
1066 r#"
1067spec age_check
1068data age: number -> suggest 18
1069rule adult: age >= 18
1070"#,
1071 );
1072 let spec = generate_openapi(&engine, false);
1073 let body = &spec["components"]["schemas"]["age_check_request"];
1074 let required = body["required"]
1075 .as_array()
1076 .expect("required array")
1077 .iter()
1078 .map(|v| v.as_str().unwrap())
1079 .collect::<Vec<_>>();
1080 assert!(
1081 required.contains(&"age"),
1082 "suggest-only fields must stay required, got {required:?}"
1083 );
1084 assert!(body["properties"]["age"].get("default").is_none());
1085 assert_eq!(body["properties"]["age"]["examples"], json!(["18"]));
1086 }
1087
1088 #[test]
1093 fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1094 let engine = create_engine_with_code(
1095 "spec pricing
1096 data quantity: 10
1097 rule total: quantity * 2",
1098 );
1099 let spec = generate_openapi(&engine, false);
1100
1101 let paths = spec["paths"].as_object().expect("paths object");
1102 assert!(paths.contains_key("/"));
1103 assert_eq!(paths["/"]["get"]["operationId"], "specs");
1104 assert!(!paths.contains_key("/openapi.json"));
1105 assert!(!paths.contains_key("/health"));
1106 assert!(!paths.contains_key("/docs"));
1107 assert!(!paths.contains_key("/schema/pricing"));
1108 assert!(!paths.contains_key("/schema/pricing/{rules}"));
1109 assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1110 }
1111
1112 #[test]
1113 fn test_generate_openapi_explain_adds_x_explain_and_explanation_schema() {
1114 let engine = create_engine_with_code(
1115 "spec pricing
1116 data quantity: 10
1117 rule total: quantity * 2",
1118 );
1119 let spec = generate_openapi(&engine, true);
1120
1121 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1122 assert!(has_param(get_params, "x-explain"));
1123
1124 let rule_result = &spec["components"]["schemas"]["RuleResult"];
1125 assert_eq!(
1126 rule_result["properties"]["explanation"]["$ref"],
1127 "#/components/schemas/RuleNode"
1128 );
1129 assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1130 assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1131
1132 let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1133 assert!(evaluate["required"]
1134 .as_array()
1135 .unwrap()
1136 .contains(&json!("spec")));
1137 assert!(evaluate["required"]
1138 .as_array()
1139 .unwrap()
1140 .contains(&json!("effective")));
1141 assert!(evaluate["required"]
1142 .as_array()
1143 .unwrap()
1144 .contains(&json!("results")));
1145 let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1146 .as_str()
1147 .unwrap();
1148 assert_eq!(total_ref, "#/components/schemas/RuleResult");
1149 }
1150
1151 #[test]
1152 fn test_generate_openapi_multiple_specs() {
1153 let engine = create_engine_with_files(vec![
1154 (
1155 "pricing.lemma",
1156 "spec pricing
1157 data quantity: 10
1158 rule total: quantity * 2",
1159 ),
1160 (
1161 "shipping.lemma",
1162 "spec shipping
1163 data weight: 5
1164 rule cost: weight * 3",
1165 ),
1166 ]);
1167 let spec = generate_openapi(&engine, false);
1168
1169 assert!(spec["paths"]["/pricing"].is_object());
1170 assert!(spec["paths"]["/shipping"].is_object());
1171 }
1172
1173 #[test]
1174 fn test_nested_spec_path_schema_refs_are_valid() {
1175 let engine = create_engine_with_code(
1176 "spec bc
1177 data x: number
1178 rule result: x",
1179 );
1180 let spec = generate_openapi(&engine, false);
1181
1182 assert!(spec["paths"]["/bc"]["post"].is_object());
1183 let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1184 let content_keys: Vec<&str> = post_content
1185 .as_object()
1186 .expect("requestBody.content object")
1187 .keys()
1188 .map(|k| k.as_str())
1189 .collect();
1190 assert_eq!(
1191 content_keys.first().copied(),
1192 Some("application/x-www-form-urlencoded"),
1193 "form-urlencoded must be first so Scalar docs default to Form URL Encoded"
1194 );
1195 let body_ref = post_content["application/json"]["schema"]["$ref"]
1196 .as_str()
1197 .unwrap();
1198 let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1199 .as_str()
1200 .unwrap();
1201 assert_eq!(body_ref, "#/components/schemas/bc_request");
1202 assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1203 assert!(spec["components"]["schemas"]["bc_request"].is_object());
1204 assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1205 assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1206 assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1207 }
1208
1209 #[test]
1214 fn test_generate_openapi_effective_reflects_specific_time() {
1215 let engine = create_engine_with_code(
1216 "spec pricing
1217 data quantity: 10
1218 rule total: quantity * 2",
1219 );
1220 let effective = date(2025, 6, 15);
1221 let spec = generate_openapi_effective(&engine, false, &effective);
1222
1223 assert_eq!(spec["openapi"], "3.1.0");
1224 let version = spec["info"]["version"].as_str().unwrap();
1225 assert!(
1226 version.contains("2025-06-15"),
1227 "version string should contain the effective date, got: {}",
1228 version
1229 );
1230 }
1231
1232 #[test]
1233 fn test_effective_shows_correct_temporal_version_interface() {
1234 let engine = create_engine_with_files(vec![(
1235 "policy.lemma",
1236 r#"
1237spec policy
1238data base: 100
1239rule discount: 10
1240
1241spec policy 2025-06-01
1242data base: 200
1243data premium: boolean
1244rule discount: 20
1245rule surcharge:
1246 5
1247 unless premium then 10
1248"#,
1249 )]);
1250
1251 let before = date(2025, 3, 1);
1252 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1253
1254 assert!(spec_v1["paths"]["/policy"].is_object());
1255 let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1256 let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1257 assert_eq!(
1258 v1_result["discount"]["$ref"].as_str(),
1259 Some("#/components/schemas/RuleResult"),
1260 "v1 should have discount rule"
1261 );
1262 assert!(
1263 v1_result["surcharge"].is_null(),
1264 "v1 must NOT have surcharge rule"
1265 );
1266 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1267 assert!(
1268 v1_request["properties"]["premium"].is_null(),
1269 "v1 must NOT have premium data"
1270 );
1271
1272 let after = date(2025, 8, 1);
1273 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1274
1275 let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1276 let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1277 assert!(
1278 v2_result["discount"]["$ref"].is_string(),
1279 "v2 should have discount rule"
1280 );
1281 assert!(
1282 v2_result["surcharge"]["$ref"].is_string(),
1283 "v2 should have surcharge rule"
1284 );
1285 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1286 assert!(
1287 v2_request["properties"]["premium"].is_object(),
1288 "v2 should have premium data"
1289 );
1290 }
1291
1292 #[test]
1301 fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1302 let engine = create_engine_with_files(vec![(
1303 "policy.lemma",
1304 r#"
1305spec policy 2025-01-01
1306data base: 10
1307rule total: base
1308
1309spec policy 2026-01-01
1310data base: 99
1311rule total: base
1312"#,
1313 )]);
1314
1315 let at_earlier = date(2025, 6, 1);
1316 let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1317 let earlier_path = &earlier_doc["paths"]["/policy"];
1318 assert_eq!(
1319 earlier_path["x-effective-from"].as_str(),
1320 Some("2025-01-01"),
1321 "earlier version effective_from on PathItem"
1322 );
1323 assert_eq!(
1324 earlier_path["x-effective-to"].as_str(),
1325 Some("2026-01-01"),
1326 "earlier version effective_to equals next version's effective_from"
1327 );
1328
1329 let at_latest = date(2026, 6, 1);
1330 let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1331 let latest_path = &latest_doc["paths"]["/policy"];
1332 assert_eq!(
1333 latest_path["x-effective-from"].as_str(),
1334 Some("2026-01-01"),
1335 "latest version effective_from on PathItem"
1336 );
1337 assert!(
1338 latest_path["x-effective-to"].is_null(),
1339 "latest version has no successor; x-effective-to must be null: {latest_path}"
1340 );
1341 }
1342
1343 #[test]
1346 fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1347 let engine = create_engine_with_code(
1348 "spec pricing
1349 data quantity: 10
1350 rule total: quantity * 2",
1351 );
1352 let document = generate_openapi(&engine, false);
1353 let path_item = &document["paths"]["/pricing"];
1354 assert!(
1355 path_item["x-effective-from"].is_null(),
1356 "unversioned spec: x-effective-from must be null: {path_item}"
1357 );
1358 assert!(
1359 path_item["x-effective-to"].is_null(),
1360 "unversioned spec: x-effective-to must be null: {path_item}"
1361 );
1362 }
1363
1364 #[test]
1369 fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1370 let engine = create_engine_with_files(vec![(
1371 "policy.lemma",
1372 r#"
1373spec policy
1374data base: 100
1375rule discount: 10
1376
1377spec policy 2025-06-01
1378data base: 200
1379rule discount: 20
1380"#,
1381 )]);
1382
1383 let sources = temporal_api_sources(&engine);
1384
1385 assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1386
1387 assert_eq!(sources[0].title, "Now");
1388 assert_eq!(sources[0].slug, NOW_SLUG);
1389 assert_eq!(sources[0].url, "/openapi.json");
1390
1391 assert_eq!(sources[1].title, "Effective 2025-06-01");
1392 assert_eq!(sources[1].slug, "2025-06-01");
1393 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1394 }
1395
1396 #[test]
1397 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1398 let engine = create_engine_with_files(vec![
1399 (
1400 "policy.lemma",
1401 r#"
1402spec policy
1403data base: 100
1404rule discount: 10
1405
1406spec policy 2025-06-01
1407data base: 200
1408rule discount: 20
1409"#,
1410 ),
1411 (
1412 "rates.lemma",
1413 r#"
1414spec rates
1415data rate: 5
1416rule total: rate * 2
1417
1418spec rates 2025-03-01
1419data rate: 7
1420rule total: rate * 2
1421
1422spec rates 2025-06-01
1423data rate: 9
1424rule total: rate * 2
1425"#,
1426 ),
1427 ]);
1428
1429 let sources = temporal_api_sources(&engine);
1430
1431 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1432 assert!(
1433 slugs.contains(&"2025-03-01"),
1434 "should contain rates boundary"
1435 );
1436 assert!(
1437 slugs.contains(&"2025-06-01"),
1438 "should contain shared boundary"
1439 );
1440 assert!(slugs.contains(&NOW_SLUG), "should contain now");
1441 assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1442 }
1443
1444 #[test]
1445 fn test_temporal_sources_ordered_chronologically() {
1446 let engine = create_engine_with_files(vec![(
1447 "policy.lemma",
1448 r#"
1449spec policy
1450data base: 100
1451rule discount: 10
1452
1453spec policy 2024-01-01
1454data base: 50
1455rule discount: 5
1456
1457spec policy 2025-06-01
1458data base: 200
1459rule discount: 20
1460"#,
1461 )]);
1462
1463 let sources = temporal_api_sources(&engine);
1464 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1465 assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1466 }
1467
1468 #[test]
1473 fn test_post_schema_text_with_options_has_enum() {
1474 let engine = create_engine_with_code(
1475 "spec test
1476 data sku: text -> option \"A\" -> option \"B\"
1477 rule result: sku",
1478 );
1479 let spec = generate_openapi(&engine, false);
1480
1481 let sku_prop = &spec["components"]["schemas"]["test_request"]["properties"]["sku"];
1482 assert!(sku_prop["enum"].is_array());
1483 let enums = sku_prop["enum"].as_array().unwrap();
1484 assert_eq!(enums.len(), 2);
1485 assert_eq!(enums[0], "A");
1486 assert_eq!(enums[1], "B");
1487 }
1488
1489 #[test]
1490 fn test_post_schema_boolean_is_json_boolean() {
1491 let engine = create_engine_with_code(
1492 "spec test
1493 data is_active: boolean
1494 rule result: is_active",
1495 );
1496 let spec = generate_openapi(&engine, false);
1497
1498 let schema = &spec["components"]["schemas"]["test_request"];
1499 let is_active = &schema["properties"]["is_active"];
1500 assert_eq!(is_active["oneOf"][0]["type"], "boolean");
1501
1502 let form_schema = &spec["components"]["schemas"]["test_form_request"];
1503 let form_is_active = &form_schema["properties"]["is_active"];
1504 assert_eq!(form_is_active["type"], "string");
1505 assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1506 }
1507
1508 #[test]
1509 fn test_post_schema_number_is_string() {
1510 let engine = create_engine_with_code(
1511 "spec test
1512 data quantity: number
1513 rule result: quantity",
1514 );
1515 let spec = generate_openapi(&engine, false);
1516
1517 let schema = &spec["components"]["schemas"]["test_request"];
1518 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1519 assert!(schema["properties"]["quantity"].get("pattern").is_none());
1520 }
1521
1522 #[test]
1523 fn test_post_schema_date_and_time_use_standard_formats() {
1524 let engine = create_engine_with_code(
1525 "spec test
1526 data start: date
1527 data at: time
1528 rule result: start
1529 rule when: at",
1530 );
1531 let spec = generate_openapi(&engine, false);
1532
1533 let schema = &spec["components"]["schemas"]["test_request"];
1534 assert_eq!(
1535 schema["properties"]["start"]["oneOf"],
1536 json!([
1537 {"type": "string", "format": "date"},
1538 {"type": "string", "format": "date-time"}
1539 ])
1540 );
1541 assert_eq!(schema["properties"]["at"]["format"], "time");
1542 let form = &spec["components"]["schemas"]["test_form_request"];
1543 assert_eq!(form["properties"]["start"]["format"], "date");
1544 assert_eq!(form["properties"]["at"]["format"], "time");
1545 }
1546
1547 #[test]
1548 fn test_data_with_default_is_not_required() {
1549 let engine = create_engine_with_code(
1550 "spec test
1551 data quantity: 10
1552 data name: text
1553 rule result: quantity
1554 rule label: name",
1555 );
1556 let spec = generate_openapi(&engine, false);
1557
1558 let schema = &spec["components"]["schemas"]["test_request"];
1559 let required = schema["required"]
1560 .as_array()
1561 .expect("required should be array");
1562
1563 assert!(required.contains(&Value::String("name".to_string())));
1564 assert!(!required.contains(&Value::String("quantity".to_string())));
1565 }
1566
1567 #[test]
1568 fn test_help_and_default_in_openapi() {
1569 let engine = create_engine_with_code(
1570 r#"spec test
1571data quantity: number -> help "Number of items to order" -> suggest 10
1572data active: boolean -> help "Whether the feature is enabled" -> suggest true
1573rule result:
1574 quantity
1575 unless active then 0
1576"#,
1577 );
1578 let spec = generate_openapi(&engine, false);
1579
1580 let req_schema = &spec["components"]["schemas"]["test_request"];
1581 assert!(req_schema["properties"]["quantity"]["description"]
1582 .as_str()
1583 .unwrap()
1584 .contains("Number of items to order"));
1585 assert_eq!(
1586 req_schema["properties"]["quantity"]["examples"],
1587 json!(["10"])
1588 );
1589 assert!(req_schema["properties"]["active"]["description"]
1590 .as_str()
1591 .unwrap()
1592 .contains("Whether the feature is enabled"));
1593 assert_eq!(
1594 req_schema["properties"]["active"]["examples"],
1595 json!([true])
1596 );
1597 assert_eq!(
1598 spec["components"]["schemas"]["test_form_request"]["properties"]["active"]["examples"],
1599 json!(["true"])
1600 );
1601 let required = req_schema["required"]
1602 .as_array()
1603 .expect("required array")
1604 .iter()
1605 .map(|v| v.as_str().unwrap())
1606 .collect::<Vec<_>>();
1607 assert!(required.contains(&"quantity"));
1608 assert!(required.contains(&"active"));
1609 }
1610
1611 const LIST_CODE: &str = r#"spec line
1612data price: number
1613data qty: number -> suggest 1
1614rule cost: price * qty
1615
1616spec order
1617uses line
1618data lines: line list
1619data discounts: number list
1620 -> with 5, 10
1621rule total: lines -> sum cost
1622rule biggest: lines -> sort cost desc -> first
1623rule discount_total: discounts -> sum
1624
1625spec part
1626data own_cost: number
1627data components: part list
1628rule unit_cost:
1629 own_cost + (components -> sum components.unit_cost)
1630 unless components -> count is 0 then own_cost
1631"#;
1632
1633 fn row_ref(schema: &Value) -> &str {
1634 schema["items"]["$ref"]
1635 .as_str()
1636 .unwrap_or_else(|| panic!("array of row refs expected: {schema}"))
1637 }
1638
1639 #[test]
1640 fn instance_list_is_array_of_row_objects_and_never_required() {
1641 let engine = create_engine_with_code(LIST_CODE);
1642 let document = generate_openapi(&engine, false);
1643 let schemas = &document["components"]["schemas"];
1644 let request = &schemas["order_request"];
1645 let lines = &request["properties"]["lines"];
1646 assert_eq!(lines["type"], "array");
1647 assert_eq!(row_ref(lines), "#/components/schemas/LemmaRow_line");
1648 assert!(
1649 request.get("required").is_none(),
1650 "lists are empty when absent, never required: {request}"
1651 );
1652
1653 let row = &schemas["LemmaRow_line"];
1654 assert_eq!(row["type"], "object");
1655 assert_eq!(row["additionalProperties"], true);
1656 assert_eq!(row["title"], "line row");
1657 assert_eq!(row["properties"]["price"]["type"], "string");
1658 assert_eq!(row["properties"]["qty"]["examples"], json!(["1"]));
1659 assert_eq!(row["required"], json!(["price", "qty"]));
1660 }
1661
1662 #[test]
1663 fn value_list_is_array_of_literals_with_array_default() {
1664 let engine = create_engine_with_code(LIST_CODE);
1665 let document = generate_openapi(&engine, false);
1666 let discounts =
1667 &document["components"]["schemas"]["order_request"]["properties"]["discounts"];
1668 assert_eq!(discounts["type"], "array");
1669 assert_eq!(discounts["items"], json!({ "type": "string" }));
1670 assert_eq!(discounts["default"], json!(["5", "10"]));
1671 }
1672
1673 const SOURCE_ROWS_CODE: &str = r#"spec article
1674data price: number
1675
1676spec line
1677data qty: number
1678uses article: article
1679rule cost: qty * article.price
1680
1681spec order
1682uses line
1683data lines: line list
1684 -> with qty: 2, article.price: 5
1685 -> with qty: 1
1686rule total: lines -> sum cost
1687
1688spec bracket
1689data threshold: number
1690data income: number
1691rule applies: income > threshold
1692
1693spec tax
1694data income: number
1695uses bracket
1696data brackets: bracket list
1697 -> with threshold: 0, income: income
1698rule applicable: brackets -> where applies -> count
1699"#;
1700
1701 #[test]
1702 fn instance_list_default_is_source_rows_as_objects() {
1703 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1704 let document = generate_openapi(&engine, false);
1705 let lines = &document["components"]["schemas"]["order_request"]["properties"]["lines"];
1706 assert_eq!(
1707 lines["default"],
1708 json!([{ "qty": "2", "article.price": "5" }, { "qty": "1" }])
1709 );
1710 }
1711
1712 #[test]
1713 fn instance_list_with_referenced_fields_has_no_default() {
1714 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1715 let document = generate_openapi(&engine, false);
1716 let brackets = &document["components"]["schemas"]["tax_request"]["properties"]["brackets"];
1717 assert_eq!(brackets["type"], "array");
1718 assert!(brackets.get("default").is_none(), "{brackets}");
1719 assert!(
1720 brackets["x-lemma-value"]["list"][0]["fields"]["income"]["reference"] == "income",
1721 "{brackets}"
1722 );
1723 }
1724
1725 #[test]
1726 fn row_schema_accepts_fields_of_row_spec_uses() {
1727 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1728 let document = generate_openapi(&engine, false);
1729 let row = &document["components"]["schemas"]["LemmaRow_line"];
1730 assert_eq!(row["additionalProperties"], true);
1731 assert_eq!(row["properties"]["article.price"]["type"], "string");
1732 }
1733
1734 #[test]
1735 fn dotted_top_level_keys_are_request_properties() {
1736 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1737 let document = generate_openapi(&engine, false);
1738 let schemas = &document["components"]["schemas"];
1739 let request = &schemas["line_request"];
1740 assert_eq!(request["properties"]["article.price"]["type"], "string");
1741 assert_eq!(request["required"], json!(["article.price", "qty"]));
1742 assert_eq!(
1743 schemas["line_form_request"]["properties"]["article.price"]["type"],
1744 "string"
1745 );
1746 }
1747
1748 #[test]
1749 fn self_list_row_schema_refers_to_itself() {
1750 let engine = create_engine_with_code(LIST_CODE);
1751 let document = generate_openapi(&engine, false);
1752 let schemas = &document["components"]["schemas"];
1753 assert_eq!(
1754 row_ref(&schemas["part_request"]["properties"]["components"]),
1755 "#/components/schemas/LemmaRow_part"
1756 );
1757 assert_eq!(
1758 row_ref(&schemas["LemmaRow_part"]["properties"]["components"]),
1759 "#/components/schemas/LemmaRow_part"
1760 );
1761 }
1762
1763 #[test]
1764 fn lists_are_not_form_fields() {
1765 let engine = create_engine_with_code(LIST_CODE);
1766 let document = generate_openapi(&engine, false);
1767 let form = &document["components"]["schemas"]["order_form_request"]["properties"];
1768 assert_eq!(form, &json!({}));
1769 }
1770
1771 #[test]
1772 fn evaluate_response_describes_rows_and_list_values() {
1773 let engine = create_engine_with_code(LIST_CODE);
1774 let document = generate_openapi(&engine, false);
1775 let schemas = &document["components"]["schemas"];
1776 assert_eq!(
1777 schemas["order_evaluate_response"]["properties"]["rows"]["items"]["$ref"],
1778 "#/components/schemas/RowResult"
1779 );
1780 let rule_result = &schemas["RuleResult"]["properties"];
1781 assert_eq!(
1782 rule_result["row"]["$ref"],
1783 "#/components/schemas/RowReference"
1784 );
1785 assert_eq!(
1786 rule_result["list"]["items"]["$ref"],
1787 "#/components/schemas/RuleResultValue"
1788 );
1789 assert_eq!(
1790 rule_result["spec"]["$ref"],
1791 "#/components/schemas/SpecResult"
1792 );
1793 assert_eq!(
1794 schemas["RuleResultValue"]["properties"]["list"]["items"]["$ref"],
1795 "#/components/schemas/RuleResultValue"
1796 );
1797 }
1798
1799 #[test]
1800 fn shared_row_schema_across_parents_does_not_panic() {
1801 let code = r#"
1802spec article
1803data price: number
1804
1805spec line
1806uses article: article
1807data qty: number
1808rule cost: qty * article.price
1809
1810spec order
1811uses line
1812data lines: line list
1813rule total: lines -> sum cost
1814
1815spec cart
1816uses line
1817data lines: line list
1818rule subtotal: lines -> sum qty
1819"#;
1820 let engine = create_engine_with_code(code);
1821 let document = generate_openapi(&engine, false);
1822 let row = &document["components"]["schemas"]["LemmaRow_line"];
1823 assert_eq!(row["type"], "object");
1824 assert_eq!(
1825 row["properties"]["qty"]["x-lemma-needed-by-rules"],
1826 json!([])
1827 );
1828 assert!(document["paths"].get("/order").is_some());
1829 assert!(document["paths"].get("/cart").is_some());
1830 }
1831
1832 #[test]
1833 fn every_schema_ref_resolves() {
1834 let engine = create_engine_with_code(LIST_CODE);
1835 let document = generate_openapi(&engine, false);
1836 let schemas = document["components"]["schemas"]
1837 .as_object()
1838 .expect("components.schemas");
1839 let mut stack = vec![&document];
1840 let mut refs = 0;
1841 while let Some(node) = stack.pop() {
1842 match node {
1843 Value::Object(map) => {
1844 if let Some(Value::String(target)) = map.get("$ref") {
1845 let name = target
1846 .strip_prefix("#/components/schemas/")
1847 .unwrap_or_else(|| panic!("unexpected $ref {target}"));
1848 assert!(schemas.contains_key(name), "dangling $ref {target}");
1849 refs += 1;
1850 }
1851 stack.extend(map.values());
1852 }
1853 Value::Array(items) => stack.extend(items),
1854 _ => {}
1855 }
1856 }
1857 assert!(refs > 0);
1858 }
1859}