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 for version in &active_specs {
158 let spec_name = &version.name;
159 if let Ok(show) = engine.show(None, spec_name, Some(effective)) {
160 let artifacts = build_spec_openapi_artifacts(
161 spec_name,
162 &show,
163 &lemma::api::show_document(engine, &show),
164 (
165 version.effective_from.as_ref(),
166 version.effective_to.as_ref(),
167 ),
168 explain,
169 );
170 paths.insert(format!("/{spec_name}"), artifacts.path_item);
171 for (name, schema_value) in artifacts.component_schemas {
172 components_schemas.insert(name, schema_value);
173 }
174 for (name, schema_value) in artifacts.row_schemas {
175 match row_schemas.get(&name) {
176 Some(existing) => assert_eq!(
177 existing, &schema_value,
178 "BUG: row schema '{name}' names one row spec, so every Show document renders it identically"
179 ),
180 None => {
181 row_schemas.insert(name, schema_value);
182 }
183 }
184 }
185 }
186 }
187 components_schemas.extend(row_schemas);
188
189 let mut tags = vec![json!({
190 "name": "Specs",
191 "description": "Loaded specs"
192 })];
193 for spec_name in &unique_spec_names {
194 let safe_tag = spec_name.replace('.', "_");
195 tags.push(json!({
196 "name": safe_tag,
197 "x-displayName": spec_name,
198 "description": format!("Show and evaluate `{spec_name}`")
199 }));
200 }
201
202 let spec_tags: Vec<Value> = unique_spec_names
203 .iter()
204 .map(|n| Value::String(n.replace('.', "_")))
205 .collect();
206
207 let tag_groups = vec![
208 json!({ "name": "Overview", "tags": ["Specs"] }),
209 json!({ "name": "Specs", "tags": spec_tags }),
210 ];
211
212 let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
213
214 json!({
215 "openapi": "3.1.0",
216 "info": {
217 "title": "Lemma API",
218 "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).",
219 "version": version_label
220 },
221 "tags": tags,
222 "x-tagGroups": tag_groups,
223 "paths": Value::Object(paths),
224 "components": {
225 "schemas": Value::Object(components_schemas)
226 }
227 })
228}
229
230fn index_path_item(engine: &Engine) -> Value {
235 let repositories = engine.specs();
236 let example = serde_json::to_value(&repositories).expect("BUG: Engine::specs must serialize");
237
238 json!({
239 "get": {
240 "operationId": "specs",
241 "summary": "Loaded specs",
242 "tags": ["Specs"],
243 "responses": {
244 "200": {
245 "description": "Loaded repositories and their specs",
246 "content": {
247 "application/json": {
248 "schema": {
249 "type": "array",
250 "items": {
251 "type": "object",
252 "properties": {
253 "repository": {
254 "type": ["string", "null"],
255 "description": "Repository name. Null for the default repository."
256 },
257 "specs": {
258 "type": "array",
259 "description": "Specs in this repository",
260 "items": {
261 "type": "object",
262 "properties": {
263 "name": {
264 "type": "string",
265 "description": "Spec name"
266 },
267 "effective_from": {
268 "type": ["string", "null"],
269 "description": "When this version starts. Null if unbounded."
270 },
271 "effective_to": {
272 "type": ["string", "null"],
273 "description": "When this version ends (exclusive). Null if this is the latest."
274 }
275 },
276 "required": ["name"]
277 }
278 }
279 },
280 "required": ["specs"]
281 }
282 },
283 "example": example
284 }
285 }
286 }
287 }
288 }
289 })
290}
291
292fn error_response_schema() -> Value {
297 json!({
298 "description": "Bad request",
299 "content": {
300 "application/json": {
301 "schema": {
302 "type": "object",
303 "properties": {
304 "error": { "type": "string" }
305 },
306 "required": ["error"]
307 }
308 }
309 }
310 })
311}
312
313fn not_found_response_schema() -> Value {
314 json!({
315 "description": "Spec not found",
316 "content": {
317 "application/json": {
318 "schema": {
319 "type": "object",
320 "properties": {
321 "error": { "type": "string" }
322 },
323 "required": ["error"]
324 }
325 }
326 }
327 })
328}
329
330fn memento_spec_response_headers() -> Value {
331 json!({
332 "Memento-Datetime": {
333 "description": "`effective_from` of the resolved version, when versioned",
334 "schema": { "type": "string" }
335 },
336 "Vary": {
337 "description": "Varies with Accept-Datetime",
338 "schema": { "type": "string", "example": "Accept-Datetime" }
339 }
340 })
341}
342
343const JSON_SCHEMA_METASCHEMA: &str = "https://json-schema.org/draft/2020-12/schema";
344const DEFS_REF_PREFIX: &str = "#/$defs/";
345
346fn hoisted_show_component_schemas() -> &'static Map<String, Value> {
350 static SCHEMAS: OnceLock<Map<String, Value>> = OnceLock::new();
351 SCHEMAS.get_or_init(build_hoisted_show_component_schemas)
352}
353
354const EVALUATE_DEF_SEEDS: &[&str] = &[
355 "RuleResult",
356 "RuleResultValue",
357 "SpecResult",
358 "SpecRuleResult",
359 "RowReference",
360 "RowResult",
361 "LemmaType",
362];
363
364fn build_hoisted_show_component_schemas() -> Map<String, Value> {
365 let document: Value = serde_json::from_str(lemma::API_V1_SCHEMA_JSON)
366 .expect("BUG: engine/schemas/api.v1.json must be JSON");
367 let defs = document["$defs"]
368 .as_object()
369 .expect("BUG: api.v1.json must have $defs");
370 let show = show_object_without_metaschema(defs);
371 let mut needed: BTreeSet<String> = BTreeSet::new();
372 collect_defs_ref_names(&show, &mut needed);
373 for seed in EVALUATE_DEF_SEEDS {
374 needed.insert((*seed).to_string());
375 }
376 let mut pending: Vec<String> = needed.iter().cloned().collect();
377 while let Some(name) = pending.pop() {
378 let def = defs
379 .get(&name)
380 .unwrap_or_else(|| panic!("BUG: api.v1.json $defs missing {name}"));
381 let mut more = BTreeSet::new();
382 collect_defs_ref_names(def, &mut more);
383 for extra in more {
384 if needed.insert(extra.clone()) {
385 pending.push(extra);
386 }
387 }
388 }
389 let mut schemas = Map::new();
390 let mut show = show;
391 rewrite_defs_refs(&mut show);
392 schemas.insert("Show".to_string(), show);
393 for name in needed {
394 let mut schema = defs
395 .get(&name)
396 .unwrap_or_else(|| panic!("BUG: api.v1.json $defs missing {name}"))
397 .clone();
398 rewrite_defs_refs(&mut schema);
399 schemas.insert(name, schema);
400 }
401 schemas
402}
403
404fn show_object_without_metaschema(defs: &Map<String, Value>) -> Value {
405 let show = defs
406 .get("Show")
407 .expect("BUG: api.v1.json $defs must define Show");
408 let all_of = show["allOf"]
409 .as_array()
410 .expect("BUG: $defs/Show must be allOf[metaschema, object]");
411 assert_eq!(
412 all_of.len(),
413 2,
414 "BUG: $defs/Show allOf must have metaschema $ref then object"
415 );
416 assert_eq!(
417 all_of[0]["$ref"].as_str(),
418 Some(JSON_SCHEMA_METASCHEMA),
419 "BUG: $defs/Show allOf[0] must $ref the JSON Schema 2020-12 metaschema"
420 );
421 let mut object = all_of[1].clone();
422 if let Some(description) = show.get("description") {
423 object["description"] = description.clone();
424 }
425 object
426}
427
428fn collect_defs_ref_names(value: &Value, names: &mut BTreeSet<String>) {
429 let mut pending: Vec<&Value> = vec![value];
430 while let Some(value) = pending.pop() {
431 match value {
432 Value::Object(map) => {
433 if let Some(Value::String(target)) = map.get("$ref") {
434 if let Some(name) = target.strip_prefix(DEFS_REF_PREFIX) {
435 assert!(
436 !name.contains('/'),
437 "BUG: nested JSON pointer $ref {target} is not a $defs name"
438 );
439 names.insert(name.to_string());
440 } else {
441 assert_eq!(
442 target, JSON_SCHEMA_METASCHEMA,
443 "BUG: unexpected $ref {target} in api.v1.json Show closure"
444 );
445 }
446 }
447 pending.extend(map.values());
448 }
449 Value::Array(items) => pending.extend(items.iter()),
450 _ => {}
451 }
452 }
453}
454
455fn build_get_show_response() -> Value {
457 json!({
458 "$ref": SHOW_SCHEMA_REF,
459 "description": "Draft 2020-12 JSON Schema for this spec's evaluation-data inputs (properties, required, $defs) with the rule graph under x-lemma-rules."
460 })
461}
462
463fn build_evaluate_response_schema(show: &lemma::Show, rule_names: &[String]) -> Value {
465 let mut result_props = Map::new();
466 for rule_name in rule_names {
467 if show.rules.contains_key(rule_name) {
468 result_props.insert(
469 rule_name.clone(),
470 json!({
471 "$ref": "#/components/schemas/RuleResult"
472 }),
473 );
474 }
475 }
476
477 json!({
478 "type": "object",
479 "required": ["spec", "effective", "results"],
480 "properties": {
481 "spec": {
482 "type": "string",
483 "description": "Spec that was evaluated"
484 },
485 "effective": {
486 "type": "string",
487 "description": "Datetime used to pick the version and evaluate"
488 },
489 "spec_effective_from": {
490 "type": "string",
491 "description": "When the resolved version starts"
492 },
493 "spec_effective_to": {
494 "type": "string",
495 "description": "When the resolved version ends (exclusive). Absent if unbounded."
496 },
497 "results": {
498 "type": "object",
499 "description": "Results by rule name. Honors `?rules=`.",
500 "properties": Value::Object(result_props)
501 },
502 "rows": {
503 "type": "array",
504 "description": "List rows the results reach, in first-reached order. Omitted when none.",
505 "items": { "$ref": "#/components/schemas/RowResult" }
506 }
507 }
508 })
509}
510
511struct SpecOpenApiArtifacts {
516 path_item: Value,
517 component_schemas: Map<String, Value>,
518 row_schemas: Map<String, Value>,
520}
521
522fn spec_component_show_names(spec_name: &str) -> (String, String, String, String) {
523 let safe_name = spec_name.replace('.', "_");
524 (
525 format!("{safe_name}_get_show"),
526 format!("{safe_name}_evaluate_response"),
527 format!("{safe_name}_request"),
528 format!("{safe_name}_form_request"),
529 )
530}
531
532fn build_spec_openapi_artifacts(
541 spec_name: &str,
542 show: &lemma::Show,
543 document: &Value,
544 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
545 explain: bool,
546) -> SpecOpenApiArtifacts {
547 let rule_names: Vec<String> = show
548 .rules
549 .iter()
550 .filter(|(_, rule)| rule.path.is_empty())
551 .map(|(name, _)| name.clone())
552 .collect();
553 let (
554 get_show_component_name,
555 evaluate_response_schema_name,
556 post_body_schema_name,
557 post_form_body_schema_name,
558 ) = spec_component_show_names(spec_name);
559
560 let mut component_schemas = Map::new();
561 component_schemas.insert(get_show_component_name.clone(), build_get_show_response());
562 component_schemas.insert(
563 evaluate_response_schema_name.clone(),
564 build_evaluate_response_schema(show, &rule_names),
565 );
566 component_schemas.insert(
567 post_body_schema_name.clone(),
568 build_post_request_schema(document),
569 );
570 component_schemas.insert(
571 post_form_body_schema_name.clone(),
572 build_post_form_request_schema(document),
573 );
574 let mut row_schemas = Map::new();
575 if let Some(defs) = document.get("$defs") {
576 for (name, schema) in defs.as_object().expect("BUG: Show $defs is an object") {
577 let mut schema = schema.clone();
578 rewrite_defs_refs(&mut schema);
579 row_schemas.insert(name.clone(), schema);
580 }
581 }
582
583 let path_item = build_spec_path_item_with_show_refs(
584 spec_name,
585 (
586 &get_show_component_name,
587 &evaluate_response_schema_name,
588 &post_body_schema_name,
589 &post_form_body_schema_name,
590 ),
591 &rule_names,
592 explain,
593 effective_range,
594 );
595
596 SpecOpenApiArtifacts {
597 path_item,
598 component_schemas,
599 row_schemas,
600 }
601}
602
603fn x_explain_header_parameter() -> Value {
604 json!({
605 "name": "x-explain",
606 "in": "header",
607 "required": false,
608 "description": "Include explanations. Server needs `--explain`.",
609 "schema": { "type": "string", "default": "true" }
610 })
611}
612
613fn accept_datetime_header_parameter() -> Value {
614 json!({
615 "name": "Accept-Datetime",
616 "in": "header",
617 "required": false,
618 "description": "Evaluate at this datetime. Omit for now.",
619 "schema": { "type": "string", "format": "date-time" },
620 "example": "Sat, 01 Jan 2025 00:00:00 GMT"
621 })
622}
623
624fn build_spec_path_item_with_show_refs(
626 spec_name: &str,
627 component_names: (&str, &str, &str, &str),
628 rule_names: &[String],
629 explain: bool,
630 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
631) -> Value {
632 let (
633 get_show_component_name,
634 evaluate_response_schema_name,
635 post_body_schema_name,
636 post_form_body_schema_name,
637 ) = component_names;
638 let (effective_from, effective_to) = effective_range;
639
640 let get_show_ref = json!({
641 "$ref": format!("#/components/schemas/{}", get_show_component_name)
642 });
643 let evaluate_schema_ref = json!({
644 "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
645 });
646 let body_ref = json!({
647 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
648 });
649 let form_body_ref = json!({
650 "$ref": format!("#/components/schemas/{}", post_form_body_schema_name)
651 });
652
653 let tag = spec_name.replace('.', "_");
654
655 let rules_example = if rule_names.is_empty() {
656 String::new()
657 } else {
658 rule_names.join(",")
659 };
660
661 let rules_param = json!({
662 "name": "rules",
663 "in": "query",
664 "required": false,
665 "description": "Rules to evaluate, comma-separated. Omit for all.",
666 "schema": { "type": "string" },
667 "example": rules_example
668 });
669
670 let mut get_parameters: Vec<Value> = Vec::new();
671 get_parameters.push(accept_datetime_header_parameter());
672 if explain {
673 get_parameters.push(x_explain_header_parameter());
674 }
675
676 let get_operation_id = format!("get_{}", spec_name);
677 let post_operation_id = format!("post_{}", spec_name);
678
679 let mut post_parameters: Vec<Value> = vec![rules_param];
680 post_parameters.push(accept_datetime_header_parameter());
681 if explain {
682 post_parameters.push(x_explain_header_parameter());
683 }
684
685 let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
686 match dt {
687 Some(d) => Value::String(d.to_string()),
688 None => Value::Null,
689 }
690 };
691
692 json!({
693 "x-effective-from": datetime_or_null(effective_from),
694 "x-effective-to": datetime_or_null(effective_to),
695 "get": {
696 "operationId": get_operation_id,
697 "summary": "Show spec",
698 "tags": [tag],
699 "parameters": get_parameters,
700 "responses": {
701 "200": {
702 "description": "Spec interface",
703 "headers": memento_spec_response_headers(),
704 "content": {
705 "application/json": {
706 "schema": get_show_ref
707 }
708 }
709 },
710 "400": error_response_schema(),
711 "404": not_found_response_schema()
712 }
713 },
714 "post": {
715 "operationId": post_operation_id,
716 "summary": "Evaluate",
717 "tags": [tag],
718 "parameters": post_parameters,
719 "requestBody": {
720 "required": true,
721 "content": {
722 "application/x-www-form-urlencoded": {
724 "schema": form_body_ref
725 },
726 "application/json": {
727 "schema": body_ref
728 }
729 }
730 },
731 "responses": {
732 "200": {
733 "description": "Evaluation results",
734 "headers": memento_spec_response_headers(),
735 "content": {
736 "application/json": {
737 "schema": evaluate_schema_ref
738 }
739 }
740 },
741 "400": error_response_schema(),
742 "404": not_found_response_schema()
743 }
744 }
745 })
746}
747
748fn build_post_request_schema(document: &Value) -> Value {
755 let mut schema = json!({
756 "type": "object",
757 "additionalProperties": true,
758 "properties": document["properties"].clone()
759 });
760 if let Some(required) = document.get("required") {
761 schema["required"] = required.clone();
762 }
763 rewrite_defs_refs(&mut schema);
764 schema
765}
766
767fn rewrite_defs_refs(value: &mut Value) {
768 let mut pending: Vec<&mut Value> = vec![value];
769 while let Some(value) = pending.pop() {
770 match value {
771 Value::Object(map) => {
772 if let Some(Value::String(target)) = map.get_mut("$ref") {
773 if let Some(name) = target.strip_prefix("#/$defs/") {
774 *target = format!("#/components/schemas/{name}");
775 }
776 }
777 pending.extend(map.values_mut());
778 }
779 Value::Array(items) => pending.extend(items.iter_mut()),
780 _ => {}
781 }
782 }
783}
784
785const FORM_KEYWORDS: [&str; 5] = ["pattern", "enum", "minLength", "maxLength", "description"];
787
788fn build_post_form_request_schema(document: &Value) -> Value {
791 let mut properties = Map::new();
792 for (name, property) in document["properties"]
793 .as_object()
794 .expect("BUG: Show properties is an object")
795 {
796 let mut field = Map::new();
797 field.insert("type".to_string(), json!("string"));
798 let scalar = property
799 .get("oneOf")
800 .map_or(property, |alternatives| &alternatives[0]);
801 match scalar["type"].as_str() {
802 Some("array") => continue,
803 Some("boolean") => {
804 field.insert("enum".to_string(), json!(["true", "false"]));
805 }
806 Some("string") => {}
807 other => unreachable!("BUG: Show property '{name}' has type {other:?}"),
808 }
809 for keyword in FORM_KEYWORDS {
810 if let Some(value) = scalar.get(keyword).or_else(|| property.get(keyword)) {
811 field.insert(keyword.to_string(), value.clone());
812 }
813 }
814 if let Some(default) = property.get("default") {
815 field.insert("default".to_string(), form_literal(default));
816 }
817 if let Some(examples) = property.get("examples") {
818 let examples = examples
819 .as_array()
820 .expect("BUG: Show examples is an array")
821 .iter()
822 .map(form_literal)
823 .collect();
824 field.insert("examples".to_string(), Value::Array(examples));
825 }
826 properties.insert(name.clone(), Value::Object(field));
827 }
828 let mut schema = json!({
829 "type": "object",
830 "properties": Value::Object(properties)
831 });
832 if let Some(required) = document.get("required") {
833 schema["required"] = required.clone();
834 }
835 schema
836}
837
838fn form_literal(value: &Value) -> Value {
839 match value {
840 Value::String(_) => value.clone(),
841 Value::Bool(boolean) => Value::String(boolean.to_string()),
842 other => unreachable!("BUG: scalar Show literal is a string or boolean, got {other}"),
843 }
844}
845
846#[cfg(test)]
851mod tests {
852 use super::*;
853 use lemma::{DateGranularity, DateTimeValue, SourceType};
854
855 fn create_engine_with_code(code: &str) -> Engine {
856 let mut engine = Engine::new();
857 engine
858 .load([(
859 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
860 code.to_string(),
861 )])
862 .expect("failed to parse lemma code");
863 engine
864 }
865
866 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
867 let mut engine = Engine::new();
868 for (name, code) in files {
869 engine
870 .load([(
871 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
872 code.to_string(),
873 )])
874 .expect("failed to parse lemma code");
875 }
876 engine
877 }
878
879 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
880 DateTimeValue {
881 year,
882 month,
883 day,
884 hour: 0,
885 minute: 0,
886 second: 0,
887 microsecond: 0,
888 timezone: None,
889 granularity: DateGranularity::Full,
890 }
891 }
892
893 fn has_param(params: &Value, name: &str) -> bool {
894 params
895 .as_array()
896 .map(|a| a.iter().any(|p| p["name"] == name))
897 .unwrap_or(false)
898 }
899
900 #[test]
905 fn test_generate_openapi_x_tag_groups() {
906 let engine = create_engine_with_code(
907 "spec pricing
908 data quantity: 10
909 rule total: quantity * 2",
910 );
911 let spec = generate_openapi(&engine, false);
912
913 let groups = spec["x-tagGroups"]
914 .as_array()
915 .expect("x-tagGroups should be array");
916 assert_eq!(groups.len(), 2);
917 assert_eq!(groups[0]["name"], "Overview");
918 assert_eq!(groups[0]["tags"], json!(["Specs"]));
919 assert_eq!(groups[1]["name"], "Specs");
920 assert_eq!(groups[1]["tags"], json!(["pricing"]));
921 }
922
923 #[test]
924 fn test_spec_path_has_get_and_post() {
925 let engine = create_engine_with_code(
926 "spec pricing
927 data quantity: 10
928 rule total: quantity * 2",
929 );
930 let spec = generate_openapi(&engine, false);
931
932 assert!(
933 spec["paths"]["/pricing"].is_object(),
934 "single spec path /pricing"
935 );
936 assert!(spec["paths"]["/pricing"]["get"].is_object());
937 assert!(spec["paths"]["/pricing"]["post"].is_object());
938
939 assert_eq!(
940 spec["paths"]["/pricing"]["get"]["operationId"],
941 "get_pricing"
942 );
943 assert_eq!(
944 spec["paths"]["/pricing"]["post"]["operationId"],
945 "post_pricing"
946 );
947 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
948
949 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
950 .as_array()
951 .expect("parameters array");
952 let param_names: Vec<&str> = get_params
953 .iter()
954 .map(|p| p["name"].as_str().unwrap())
955 .collect();
956 assert!(
957 !param_names.contains(&"rules"),
958 "GET must not have rules query param (show is full interface)"
959 );
960 let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
961 .as_array()
962 .expect("post parameters array");
963 let post_param_names: Vec<&str> = post_params
964 .iter()
965 .map(|p| p["name"].as_str().unwrap())
966 .collect();
967 assert!(
968 post_param_names.contains(&"rules"),
969 "POST must have rules query param"
970 );
971 assert!(
972 param_names.contains(&"Accept-Datetime"),
973 "GET must have Accept-Datetime header"
974 );
975
976 let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
977 ["application/json"]["schema"]["$ref"]
978 .as_str()
979 .unwrap();
980 let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
981 ["application/json"]["schema"]["$ref"]
982 .as_str()
983 .unwrap();
984 assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
985 assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
986 assert_ne!(get_ref, post_ref);
987
988 let get_show = &spec["components"]["schemas"]["pricing_get_show"];
989 assert_eq!(get_show["$ref"], SHOW_SCHEMA_REF);
990 assert_eq!(SHOW_SCHEMA_REF, "#/components/schemas/Show");
991 let show = &spec["components"]["schemas"]["Show"];
992 assert_eq!(show["type"], "object");
993 let mut stack = vec![show];
994 while let Some(node) = stack.pop() {
995 match node {
996 Value::Object(map) => {
997 if let Some(Value::String(target)) = map.get("$ref") {
998 assert!(
999 !target.starts_with("http"),
1000 "Show schema must not $ref {target}"
1001 );
1002 }
1003 stack.extend(map.values());
1004 }
1005 Value::Array(items) => stack.extend(items),
1006 _ => {}
1007 }
1008 }
1009
1010 let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1011 assert!(h200["headers"]["Memento-Datetime"].is_object());
1012 assert!(h200["headers"]["Vary"].is_object());
1013 }
1014
1015 #[test]
1016 fn suggestion_only_field_is_required_with_json_schema_default() {
1017 let engine = create_engine_with_code(
1018 r#"
1019spec age_check
1020data age: number -> suggest 18
1021rule adult: age >= 18
1022"#,
1023 );
1024 let spec = generate_openapi(&engine, false);
1025 let body = &spec["components"]["schemas"]["age_check_request"];
1026 let required = body["required"]
1027 .as_array()
1028 .expect("required array")
1029 .iter()
1030 .map(|v| v.as_str().unwrap())
1031 .collect::<Vec<_>>();
1032 assert!(
1033 required.contains(&"age"),
1034 "suggest-only fields must stay required, got {required:?}"
1035 );
1036 assert!(body["properties"]["age"].get("default").is_none());
1037 assert_eq!(body["properties"]["age"]["examples"], json!(["18"]));
1038 }
1039
1040 #[test]
1045 fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1046 let engine = create_engine_with_code(
1047 "spec pricing
1048 data quantity: 10
1049 rule total: quantity * 2",
1050 );
1051 let spec = generate_openapi(&engine, false);
1052
1053 let paths = spec["paths"].as_object().expect("paths object");
1054 assert!(paths.contains_key("/"));
1055 assert_eq!(paths["/"]["get"]["operationId"], "specs");
1056 assert!(!paths.contains_key("/openapi.json"));
1057 assert!(!paths.contains_key("/health"));
1058 assert!(!paths.contains_key("/docs"));
1059 assert!(!paths.contains_key("/schema/pricing"));
1060 assert!(!paths.contains_key("/schema/pricing/{rules}"));
1061 assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1062 }
1063
1064 #[test]
1065 fn test_generate_openapi_explain_adds_x_explain_and_explanation_schema() {
1066 let engine = create_engine_with_code(
1067 "spec pricing
1068 data quantity: 10
1069 rule total: quantity * 2",
1070 );
1071 let spec = generate_openapi(&engine, true);
1072
1073 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1074 assert!(has_param(get_params, "x-explain"));
1075
1076 let rule_result = &spec["components"]["schemas"]["RuleResult"];
1077 assert_eq!(
1078 rule_result["properties"]["explanation"]["$ref"],
1079 "#/components/schemas/RuleNode"
1080 );
1081 assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1082 assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1083
1084 let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1085 assert!(evaluate["required"]
1086 .as_array()
1087 .unwrap()
1088 .contains(&json!("spec")));
1089 assert!(evaluate["required"]
1090 .as_array()
1091 .unwrap()
1092 .contains(&json!("effective")));
1093 assert!(evaluate["required"]
1094 .as_array()
1095 .unwrap()
1096 .contains(&json!("results")));
1097 let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1098 .as_str()
1099 .unwrap();
1100 assert_eq!(total_ref, "#/components/schemas/RuleResult");
1101 }
1102
1103 #[test]
1104 fn test_generate_openapi_multiple_specs() {
1105 let engine = create_engine_with_files(vec![
1106 (
1107 "pricing.lemma",
1108 "spec pricing
1109 data quantity: 10
1110 rule total: quantity * 2",
1111 ),
1112 (
1113 "shipping.lemma",
1114 "spec shipping
1115 data weight: 5
1116 rule cost: weight * 3",
1117 ),
1118 ]);
1119 let spec = generate_openapi(&engine, false);
1120
1121 assert!(spec["paths"]["/pricing"].is_object());
1122 assert!(spec["paths"]["/shipping"].is_object());
1123 }
1124
1125 #[test]
1126 fn test_nested_spec_path_schema_refs_are_valid() {
1127 let engine = create_engine_with_code(
1128 "spec bc
1129 data x: number
1130 rule result: x",
1131 );
1132 let spec = generate_openapi(&engine, false);
1133
1134 assert!(spec["paths"]["/bc"]["post"].is_object());
1135 let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1136 let content_keys: Vec<&str> = post_content
1137 .as_object()
1138 .expect("requestBody.content object")
1139 .keys()
1140 .map(|k| k.as_str())
1141 .collect();
1142 assert_eq!(
1143 content_keys.first().copied(),
1144 Some("application/x-www-form-urlencoded"),
1145 "form-urlencoded must be first so Scalar docs default to Form URL Encoded"
1146 );
1147 let body_ref = post_content["application/json"]["schema"]["$ref"]
1148 .as_str()
1149 .unwrap();
1150 let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1151 .as_str()
1152 .unwrap();
1153 assert_eq!(body_ref, "#/components/schemas/bc_request");
1154 assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1155 assert!(spec["components"]["schemas"]["bc_request"].is_object());
1156 assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1157 assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1158 assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1159 }
1160
1161 #[test]
1166 fn test_generate_openapi_effective_reflects_specific_time() {
1167 let engine = create_engine_with_code(
1168 "spec pricing
1169 data quantity: 10
1170 rule total: quantity * 2",
1171 );
1172 let effective = date(2025, 6, 15);
1173 let spec = generate_openapi_effective(&engine, false, &effective);
1174
1175 assert_eq!(spec["openapi"], "3.1.0");
1176 let version = spec["info"]["version"].as_str().unwrap();
1177 assert!(
1178 version.contains("2025-06-15"),
1179 "version string should contain the effective date, got: {}",
1180 version
1181 );
1182 }
1183
1184 #[test]
1185 fn test_effective_shows_correct_temporal_version_interface() {
1186 let engine = create_engine_with_files(vec![(
1187 "policy.lemma",
1188 r#"
1189spec policy
1190data base: 100
1191rule discount: 10
1192
1193spec policy 2025-06-01
1194data base: 200
1195data premium: boolean
1196rule discount: 20
1197rule surcharge:
1198 5
1199 unless premium then 10
1200"#,
1201 )]);
1202
1203 let before = date(2025, 3, 1);
1204 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1205
1206 assert!(spec_v1["paths"]["/policy"].is_object());
1207 let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1208 let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1209 assert_eq!(
1210 v1_result["discount"]["$ref"].as_str(),
1211 Some("#/components/schemas/RuleResult"),
1212 "v1 should have discount rule"
1213 );
1214 assert!(
1215 v1_result["surcharge"].is_null(),
1216 "v1 must NOT have surcharge rule"
1217 );
1218 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1219 assert!(
1220 v1_request["properties"]["premium"].is_null(),
1221 "v1 must NOT have premium data"
1222 );
1223
1224 let after = date(2025, 8, 1);
1225 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1226
1227 let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1228 let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1229 assert!(
1230 v2_result["discount"]["$ref"].is_string(),
1231 "v2 should have discount rule"
1232 );
1233 assert!(
1234 v2_result["surcharge"]["$ref"].is_string(),
1235 "v2 should have surcharge rule"
1236 );
1237 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1238 assert!(
1239 v2_request["properties"]["premium"].is_object(),
1240 "v2 should have premium data"
1241 );
1242 }
1243
1244 #[test]
1253 fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1254 let engine = create_engine_with_files(vec![(
1255 "policy.lemma",
1256 r#"
1257spec policy 2025-01-01
1258data base: 10
1259rule total: base
1260
1261spec policy 2026-01-01
1262data base: 99
1263rule total: base
1264"#,
1265 )]);
1266
1267 let at_earlier = date(2025, 6, 1);
1268 let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1269 let earlier_path = &earlier_doc["paths"]["/policy"];
1270 assert_eq!(
1271 earlier_path["x-effective-from"].as_str(),
1272 Some("2025-01-01"),
1273 "earlier version effective_from on PathItem"
1274 );
1275 assert_eq!(
1276 earlier_path["x-effective-to"].as_str(),
1277 Some("2026-01-01"),
1278 "earlier version effective_to equals next version's effective_from"
1279 );
1280
1281 let at_latest = date(2026, 6, 1);
1282 let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1283 let latest_path = &latest_doc["paths"]["/policy"];
1284 assert_eq!(
1285 latest_path["x-effective-from"].as_str(),
1286 Some("2026-01-01"),
1287 "latest version effective_from on PathItem"
1288 );
1289 assert!(
1290 latest_path["x-effective-to"].is_null(),
1291 "latest version has no successor; x-effective-to must be null: {latest_path}"
1292 );
1293 }
1294
1295 #[test]
1298 fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1299 let engine = create_engine_with_code(
1300 "spec pricing
1301 data quantity: 10
1302 rule total: quantity * 2",
1303 );
1304 let document = generate_openapi(&engine, false);
1305 let path_item = &document["paths"]["/pricing"];
1306 assert!(
1307 path_item["x-effective-from"].is_null(),
1308 "unversioned spec: x-effective-from must be null: {path_item}"
1309 );
1310 assert!(
1311 path_item["x-effective-to"].is_null(),
1312 "unversioned spec: x-effective-to must be null: {path_item}"
1313 );
1314 }
1315
1316 #[test]
1321 fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1322 let engine = create_engine_with_files(vec![(
1323 "policy.lemma",
1324 r#"
1325spec policy
1326data base: 100
1327rule discount: 10
1328
1329spec policy 2025-06-01
1330data base: 200
1331rule discount: 20
1332"#,
1333 )]);
1334
1335 let sources = temporal_api_sources(&engine);
1336
1337 assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1338
1339 assert_eq!(sources[0].title, "Now");
1340 assert_eq!(sources[0].slug, NOW_SLUG);
1341 assert_eq!(sources[0].url, "/openapi.json");
1342
1343 assert_eq!(sources[1].title, "Effective 2025-06-01");
1344 assert_eq!(sources[1].slug, "2025-06-01");
1345 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1346 }
1347
1348 #[test]
1349 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1350 let engine = create_engine_with_files(vec![
1351 (
1352 "policy.lemma",
1353 r#"
1354spec policy
1355data base: 100
1356rule discount: 10
1357
1358spec policy 2025-06-01
1359data base: 200
1360rule discount: 20
1361"#,
1362 ),
1363 (
1364 "rates.lemma",
1365 r#"
1366spec rates
1367data rate: 5
1368rule total: rate * 2
1369
1370spec rates 2025-03-01
1371data rate: 7
1372rule total: rate * 2
1373
1374spec rates 2025-06-01
1375data rate: 9
1376rule total: rate * 2
1377"#,
1378 ),
1379 ]);
1380
1381 let sources = temporal_api_sources(&engine);
1382
1383 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1384 assert!(
1385 slugs.contains(&"2025-03-01"),
1386 "should contain rates boundary"
1387 );
1388 assert!(
1389 slugs.contains(&"2025-06-01"),
1390 "should contain shared boundary"
1391 );
1392 assert!(slugs.contains(&NOW_SLUG), "should contain now");
1393 assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1394 }
1395
1396 #[test]
1397 fn test_temporal_sources_ordered_chronologically() {
1398 let engine = create_engine_with_files(vec![(
1399 "policy.lemma",
1400 r#"
1401spec policy
1402data base: 100
1403rule discount: 10
1404
1405spec policy 2024-01-01
1406data base: 50
1407rule discount: 5
1408
1409spec policy 2025-06-01
1410data base: 200
1411rule discount: 20
1412"#,
1413 )]);
1414
1415 let sources = temporal_api_sources(&engine);
1416 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1417 assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1418 }
1419
1420 #[test]
1425 fn test_post_schema_text_with_options_has_enum() {
1426 let engine = create_engine_with_code(
1427 "spec test
1428 data sku: text -> option \"A\" -> option \"B\"
1429 rule result: sku",
1430 );
1431 let spec = generate_openapi(&engine, false);
1432
1433 let sku_prop = &spec["components"]["schemas"]["test_request"]["properties"]["sku"];
1434 assert!(sku_prop["enum"].is_array());
1435 let enums = sku_prop["enum"].as_array().unwrap();
1436 assert_eq!(enums.len(), 2);
1437 assert_eq!(enums[0], "A");
1438 assert_eq!(enums[1], "B");
1439 }
1440
1441 #[test]
1442 fn test_post_schema_boolean_is_json_boolean() {
1443 let engine = create_engine_with_code(
1444 "spec test
1445 data is_active: boolean
1446 rule result: is_active",
1447 );
1448 let spec = generate_openapi(&engine, false);
1449
1450 let schema = &spec["components"]["schemas"]["test_request"];
1451 let is_active = &schema["properties"]["is_active"];
1452 assert_eq!(is_active["oneOf"][0]["type"], "boolean");
1453
1454 let form_schema = &spec["components"]["schemas"]["test_form_request"];
1455 let form_is_active = &form_schema["properties"]["is_active"];
1456 assert_eq!(form_is_active["type"], "string");
1457 assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1458 }
1459
1460 #[test]
1461 fn test_post_schema_number_is_string() {
1462 let engine = create_engine_with_code(
1463 "spec test
1464 data quantity: number
1465 rule result: quantity",
1466 );
1467 let spec = generate_openapi(&engine, false);
1468
1469 let schema = &spec["components"]["schemas"]["test_request"];
1470 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1471 }
1472
1473 #[test]
1474 fn test_data_with_default_is_not_required() {
1475 let engine = create_engine_with_code(
1476 "spec test
1477 data quantity: 10
1478 data name: text
1479 rule result: quantity
1480 rule label: name",
1481 );
1482 let spec = generate_openapi(&engine, false);
1483
1484 let schema = &spec["components"]["schemas"]["test_request"];
1485 let required = schema["required"]
1486 .as_array()
1487 .expect("required should be array");
1488
1489 assert!(required.contains(&Value::String("name".to_string())));
1490 assert!(!required.contains(&Value::String("quantity".to_string())));
1491 }
1492
1493 #[test]
1494 fn test_help_and_default_in_openapi() {
1495 let engine = create_engine_with_code(
1496 r#"spec test
1497data quantity: number -> help "Number of items to order" -> suggest 10
1498data active: boolean -> help "Whether the feature is enabled" -> suggest true
1499rule result:
1500 quantity
1501 unless active then 0
1502"#,
1503 );
1504 let spec = generate_openapi(&engine, false);
1505
1506 let req_schema = &spec["components"]["schemas"]["test_request"];
1507 assert!(req_schema["properties"]["quantity"]["description"]
1508 .as_str()
1509 .unwrap()
1510 .contains("Number of items to order"));
1511 assert_eq!(
1512 req_schema["properties"]["quantity"]["examples"],
1513 json!(["10"])
1514 );
1515 assert!(req_schema["properties"]["active"]["description"]
1516 .as_str()
1517 .unwrap()
1518 .contains("Whether the feature is enabled"));
1519 assert_eq!(
1520 req_schema["properties"]["active"]["examples"],
1521 json!([true])
1522 );
1523 assert_eq!(
1524 spec["components"]["schemas"]["test_form_request"]["properties"]["active"]["examples"],
1525 json!(["true"])
1526 );
1527 let required = req_schema["required"]
1528 .as_array()
1529 .expect("required array")
1530 .iter()
1531 .map(|v| v.as_str().unwrap())
1532 .collect::<Vec<_>>();
1533 assert!(required.contains(&"quantity"));
1534 assert!(required.contains(&"active"));
1535 }
1536
1537 const LIST_CODE: &str = r#"spec line
1538data price: number
1539data qty: number -> suggest 1
1540rule cost: price * qty
1541
1542spec order
1543uses lines: line list
1544data discounts: number list
1545 -> with 5, 10
1546rule total: lines -> sum cost
1547rule biggest: lines -> sort cost desc -> first
1548rule discount_total: discounts -> sum
1549
1550spec part
1551data own_cost: number
1552uses components: part list
1553rule unit_cost:
1554 own_cost + (components -> sum components.unit_cost)
1555 unless components -> count is 0 then own_cost
1556"#;
1557
1558 fn row_ref(schema: &Value) -> &str {
1559 schema["items"]["$ref"]
1560 .as_str()
1561 .unwrap_or_else(|| panic!("array of row refs expected: {schema}"))
1562 }
1563
1564 #[test]
1565 fn instance_list_is_array_of_row_objects_and_never_required() {
1566 let engine = create_engine_with_code(LIST_CODE);
1567 let document = generate_openapi(&engine, false);
1568 let schemas = &document["components"]["schemas"];
1569 let request = &schemas["order_request"];
1570 let lines = &request["properties"]["lines"];
1571 assert_eq!(lines["type"], "array");
1572 assert_eq!(row_ref(lines), "#/components/schemas/LemmaRow_line");
1573 assert!(
1574 request.get("required").is_none(),
1575 "lists are empty when absent, never required: {request}"
1576 );
1577
1578 let row = &schemas["LemmaRow_line"];
1579 assert_eq!(row["type"], "object");
1580 assert_eq!(row["additionalProperties"], true);
1581 assert_eq!(row["title"], "line row");
1582 assert_eq!(row["properties"]["price"]["type"], "string");
1583 assert_eq!(row["properties"]["qty"]["examples"], json!(["1"]));
1584 assert_eq!(row["required"], json!(["price", "qty"]));
1585 }
1586
1587 #[test]
1588 fn value_list_is_array_of_literals_with_array_default() {
1589 let engine = create_engine_with_code(LIST_CODE);
1590 let document = generate_openapi(&engine, false);
1591 let discounts =
1592 &document["components"]["schemas"]["order_request"]["properties"]["discounts"];
1593 assert_eq!(discounts["type"], "array");
1594 assert_eq!(
1595 discounts["items"],
1596 json!({ "type": "string", "pattern": lemma::api::number_pattern() })
1597 );
1598 assert_eq!(discounts["default"], json!(["5", "10"]));
1599 }
1600
1601 const SOURCE_ROWS_CODE: &str = r#"spec article
1602data price: number
1603
1604spec line
1605data qty: number
1606uses article: article
1607rule cost: qty * article.price
1608
1609spec order
1610uses lines: line list
1611 -> with qty: 2, article.price: 5
1612 -> with qty: 1
1613rule total: lines -> sum cost
1614
1615spec bracket
1616data threshold: number
1617data income: number
1618rule applies: income > threshold
1619
1620spec tax
1621data income: number
1622uses brackets: bracket list
1623 -> with threshold: 0, income: income
1624rule applicable: brackets -> where applies -> count
1625"#;
1626
1627 #[test]
1628 fn instance_list_default_is_source_rows_as_objects() {
1629 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1630 let document = generate_openapi(&engine, false);
1631 let lines = &document["components"]["schemas"]["order_request"]["properties"]["lines"];
1632 assert_eq!(
1633 lines["default"],
1634 json!([{ "qty": "2", "article.price": "5" }, { "qty": "1" }])
1635 );
1636 }
1637
1638 #[test]
1639 fn instance_list_with_referenced_fields_has_no_default() {
1640 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1641 let document = generate_openapi(&engine, false);
1642 let brackets = &document["components"]["schemas"]["tax_request"]["properties"]["brackets"];
1643 assert_eq!(brackets["type"], "array");
1644 assert!(brackets.get("default").is_none(), "{brackets}");
1645 assert!(
1646 brackets["x-lemma-value"]["list"][0]["fields"]["income"]["reference"] == "income",
1647 "{brackets}"
1648 );
1649 }
1650
1651 #[test]
1652 fn row_schema_accepts_fields_of_row_spec_uses() {
1653 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1654 let document = generate_openapi(&engine, false);
1655 let row = &document["components"]["schemas"]["LemmaRow_line"];
1656 assert_eq!(row["additionalProperties"], true);
1657 assert_eq!(row["properties"]["article.price"]["type"], "string");
1658 }
1659
1660 #[test]
1661 fn dotted_top_level_keys_are_request_properties() {
1662 let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1663 let document = generate_openapi(&engine, false);
1664 let schemas = &document["components"]["schemas"];
1665 let request = &schemas["line_request"];
1666 assert_eq!(request["properties"]["article.price"]["type"], "string");
1667 assert_eq!(request["required"], json!(["article.price", "qty"]));
1668 assert_eq!(
1669 schemas["line_form_request"]["properties"]["article.price"]["type"],
1670 "string"
1671 );
1672 }
1673
1674 #[test]
1675 fn self_list_row_schema_refers_to_itself() {
1676 let engine = create_engine_with_code(LIST_CODE);
1677 let document = generate_openapi(&engine, false);
1678 let schemas = &document["components"]["schemas"];
1679 assert_eq!(
1680 row_ref(&schemas["part_request"]["properties"]["components"]),
1681 "#/components/schemas/LemmaRow_part"
1682 );
1683 assert_eq!(
1684 row_ref(&schemas["LemmaRow_part"]["properties"]["components"]),
1685 "#/components/schemas/LemmaRow_part"
1686 );
1687 }
1688
1689 #[test]
1690 fn lists_are_not_form_fields() {
1691 let engine = create_engine_with_code(LIST_CODE);
1692 let document = generate_openapi(&engine, false);
1693 let form = &document["components"]["schemas"]["order_form_request"]["properties"];
1694 assert_eq!(form, &json!({}));
1695 }
1696
1697 #[test]
1698 fn evaluate_response_describes_rows_and_list_values() {
1699 let engine = create_engine_with_code(LIST_CODE);
1700 let document = generate_openapi(&engine, false);
1701 let schemas = &document["components"]["schemas"];
1702 assert_eq!(
1703 schemas["order_evaluate_response"]["properties"]["rows"]["items"]["$ref"],
1704 "#/components/schemas/RowResult"
1705 );
1706 let rule_result = &schemas["RuleResult"]["properties"];
1707 assert_eq!(
1708 rule_result["row"]["$ref"],
1709 "#/components/schemas/RowReference"
1710 );
1711 assert_eq!(
1712 rule_result["list"]["items"]["$ref"],
1713 "#/components/schemas/RuleResultValue"
1714 );
1715 assert_eq!(
1716 rule_result["spec"]["$ref"],
1717 "#/components/schemas/SpecResult"
1718 );
1719 assert_eq!(
1720 schemas["RuleResultValue"]["properties"]["list"]["items"]["$ref"],
1721 "#/components/schemas/RuleResultValue"
1722 );
1723 }
1724
1725 #[test]
1726 fn every_schema_ref_resolves() {
1727 let engine = create_engine_with_code(LIST_CODE);
1728 let document = generate_openapi(&engine, false);
1729 let schemas = document["components"]["schemas"]
1730 .as_object()
1731 .expect("components.schemas");
1732 let mut stack = vec![&document];
1733 let mut refs = 0;
1734 while let Some(node) = stack.pop() {
1735 match node {
1736 Value::Object(map) => {
1737 if let Some(Value::String(target)) = map.get("$ref") {
1738 let name = target
1739 .strip_prefix("#/components/schemas/")
1740 .unwrap_or_else(|| panic!("unexpected $ref {target}"));
1741 assert!(schemas.contains_key(name), "dangling $ref {target}");
1742 refs += 1;
1743 }
1744 stack.extend(map.values());
1745 }
1746 Value::Array(items) => stack.extend(items),
1747 _ => {}
1748 }
1749 }
1750 assert!(refs > 0);
1751 }
1752}