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