Skip to main content

lemma_openapi/
lib.rs

1//! OpenAPI 3.1 specification generator for the Lemma HTTP surface.
2//!
3//! Takes a Lemma `Engine` and produces a complete OpenAPI specification as JSON.
4//! Used by both `lemma server` (CLI) and LemmaBase.com for consistent API docs.
5//!
6//! ## Temporal versioning
7//!
8//! Specs can have multiple temporal versions (e.g. `spec pricing 2024-01-01`
9//! and `spec pricing 2025-01-01`) with potentially different interfaces (data, rules,
10//! types). The OpenAPI document reflects the interface active at a specific point in
11//! time. Use [`generate_openapi_effective`] with an explicit `DateTimeValue` to get the
12//! document for a given instant. [`generate_openapi`] is a convenience wrapper that uses
13//! the current time.
14//!
15//! For Scalar multi-source rendering, [`temporal_api_sources`] returns the list of
16//! temporal version boundaries so the Scalar UI can offer a source selector.
17
18use lemma::{DateTimeValue, Engine, SpecVersion};
19use serde_json::{json, Map, Value};
20use std::collections::BTreeSet;
21use std::sync::OnceLock;
22
23/// Query slug for the default temporal view (request-time instant). OpenAPI URLs use no `?effective=`.
24pub const NOW_SLUG: &str = "now";
25
26/// The Show document definition, hoisted from `api.v1.json` `$defs/Show`.
27pub const SHOW_SCHEMA_REF: &str = "#/components/schemas/Show";
28
29/// A single Scalar API reference source entry.
30///
31/// Each temporal version boundary gets its own source so Scalar renders a
32/// version switcher in the UI.
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct ApiSource {
35    pub title: String,
36    pub slug: String,
37    pub url: String,
38}
39
40/// Compute the list of Scalar multi-source entries for temporal versioning.
41///
42/// Returns one [`ApiSource`] per distinct temporal version boundary across all
43/// loaded specs, plus one **now** source (slug [`NOW_SLUG`]) that uses no `effective`
44/// query (evaluation instant = request time). That entry is first (Scalar default),
45/// then boundaries in descending chronological order (newest first).
46///
47/// If there are no temporal version boundaries (all specs are unversioned),
48/// returns a single **now** entry.
49pub 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
89/// Generate a complete OpenAPI 3.1 specification using the current time.
90///
91/// Convenience wrapper around [`generate_openapi_effective`]. The document reflects
92/// only the specs and interfaces active at `DateTimeValue::now()`.
93pub fn generate_openapi(engine: &Engine, explain: bool) -> Value {
94    generate_openapi_effective(engine, explain, &DateTimeValue::now())
95}
96
97/// Generate a complete OpenAPI 3.1 specification for a specific point in time.
98///
99/// The specification includes:
100/// - `GET /` — loaded specs (`Engine::specs`)
101/// - `/{spec}` GET (show: `Show` from `api.v1.json`) and POST (evaluate: `Response` /
102///   `RuleResult` from `api.v1.json`) with optional `Accept-Datetime` header
103/// - `?rules=` on POST only, to limit evaluated rules
104/// - `x-effective-from` / `x-effective-to` vendor extensions on each PathItem
105///   exposing the half-open `[effective_from, effective_to)` range of the version
106///   resolved at the document's effective instant (both `null` when unbounded)
107///
108/// CLI `lemma server` also exposes shell routes (`/openapi.json`, `/health`, `/docs`) that are
109/// intentionally omitted from the generated document.
110///
111/// When `explain` is true, the document adds the `x-explain` header parameter
112/// to evaluation operations and describes the optional `explanation` field on rule results.
113pub 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
230// ---------------------------------------------------------------------------
231// Index path (loaded specs)
232// ---------------------------------------------------------------------------
233
234fn 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
292// ---------------------------------------------------------------------------
293// Shared response schemas
294// ---------------------------------------------------------------------------
295
296fn 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
346/// `$defs/Show` and evaluate `RuleResult` / `RuleResultValue` / row-spec defs, plus
347/// each local `$ref` closure, rewritten to `#/components/schemas/...`.
348/// Drops the JSON Schema metaschema `allOf` `$ref` so OpenAPI UIs do not fetch it.
349fn 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
455/// GET `/{spec}` body: the Show document, a JSON Schema for the spec's evaluation-data inputs.
456fn 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
463/// POST evaluate body: matches engine [`lemma::Response`] JSON shape.
464fn 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
511// ---------------------------------------------------------------------------
512// Spec path items
513// ---------------------------------------------------------------------------
514
515struct SpecOpenApiArtifacts {
516    path_item: Value,
517    component_schemas: Map<String, Value>,
518    /// The Show document's `$defs`, refs rewritten to `#/components/schemas/`.
519    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
532/// Build the PathItem and per-spec component schemas for `/{spec_name}`.
533///
534/// `effective_range` is the half-open `[effective_from, effective_to)`
535/// validity range of the temporal version resolved at the OpenAPI document's
536/// effective instant. Both bounds are emitted as the `x-effective-from` /
537/// `x-effective-to` vendor extensions on the PathItem so tooling can render
538/// the active version's window. `None` in either position (unbounded start for the first row,
539/// unbounded end for the latest row) is serialised as JSON `null`.
540fn 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
624/// Build the PathItem for `/{spec_name}` (GET show + POST evaluate).
625fn 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                    // First media type is Scalar's default body encoding.
723                    "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
748// ---------------------------------------------------------------------------
749// POST request bodies, derived from the Show document
750// ---------------------------------------------------------------------------
751
752/// JSON body: the Show document's `properties` and `required`, without the spec-level
753/// annotations. Row `$ref`s point at the hoisted `LemmaRow_*` components.
754fn 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
785/// Form keywords copied from a Show property: a form field is one literal string.
786const FORM_KEYWORDS: [&str; 5] = ["pattern", "enum", "minLength", "maxLength", "description"];
787
788/// Lists are JSON-only: a form field is one literal, and a boolean is the string
789/// `true`/`false`.
790fn 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// ---------------------------------------------------------------------------
847// Helpers
848// ---------------------------------------------------------------------------
849
850#[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    // =======================================================================
901    // Basic spec structure (pre-existing, adapted)
902    // =======================================================================
903
904    #[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    /// The generated OpenAPI document describes the public spec surface only.
1041    /// Server shell routes (`/openapi.json`, `/health`, `/docs`) are
1042    /// intentionally omitted; consumers must not rely on them for code
1043    /// generation or contract inspection.
1044    #[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    // =======================================================================
1162    // generate_openapi_effective with explicit timestamp
1163    // =======================================================================
1164
1165    #[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    /// Each spec PathItem carries `x-effective-from` and `x-effective-to`
1245    /// describing the half-open `[effective_from, effective_to)` validity
1246    /// range of the version resolved at the document's effective instant.
1247    ///
1248    /// - Earlier row: `x-effective-to` = next row's `effective_from`.
1249    /// - Latest row: `x-effective-to` = `null` (no successor).
1250    /// - Unversioned spec (no declared `effective_from`): both extensions are
1251    ///   `null`.
1252    #[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    /// Unversioned specs (no declared `effective_from`) have both extensions
1296    /// serialised as JSON `null`, not omitted.
1297    #[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    // =======================================================================
1317    // temporal_api_sources
1318    // =======================================================================
1319
1320    #[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    // =======================================================================
1421    // Type-specific parameter tests
1422    // =======================================================================
1423
1424    #[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}