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, LemmaType, ListedSpec, TypeSpecification};
19use serde_json::{json, Map, Value};
20
21/// Query slug for the default temporal view (request-time instant). OpenAPI URLs use no `?effective=`.
22pub const NOW_SLUG: &str = "now";
23
24/// A single Scalar API reference source entry.
25///
26/// Each temporal version boundary gets its own source so Scalar renders a
27/// version switcher in the UI.
28#[derive(Debug, Clone, serde::Serialize)]
29pub struct ApiSource {
30    pub title: String,
31    pub slug: String,
32    pub url: String,
33}
34
35/// Compute the list of Scalar multi-source entries for temporal versioning.
36///
37/// Returns one [`ApiSource`] per distinct temporal version boundary across all
38/// loaded specs, plus one **now** source (slug [`NOW_SLUG`]) that uses no `effective`
39/// query (evaluation instant = request time). That entry is first (Scalar default),
40/// then boundaries in descending chronological order (newest first).
41///
42/// If there are no temporal version boundaries (all specs are unversioned),
43/// returns a single **now** entry.
44pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
45    let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
46        std::collections::BTreeSet::new();
47
48    for repo in engine.list() {
49        for ls in &repo.specs {
50            if let Some(af) = &ls.effective_from {
51                all_boundaries.insert(af.clone());
52            }
53        }
54    }
55
56    if all_boundaries.is_empty() {
57        return vec![ApiSource {
58            title: "Now".to_string(),
59            slug: NOW_SLUG.to_string(),
60            url: "/openapi.json".to_string(),
61        }];
62    }
63
64    let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
65
66    sources.push(ApiSource {
67        title: "Now".to_string(),
68        slug: NOW_SLUG.to_string(),
69        url: "/openapi.json".to_string(),
70    });
71
72    for boundary in all_boundaries.iter().rev() {
73        let label = boundary.to_string();
74        sources.push(ApiSource {
75            title: format!("Effective {}", label),
76            slug: label.clone(),
77            url: format!("/openapi.json?effective={}", label),
78        });
79    }
80
81    sources
82}
83
84/// Generate a complete OpenAPI 3.1 specification using the current time.
85///
86/// Convenience wrapper around [`generate_openapi_effective`]. The document reflects
87/// only the specs and interfaces active at `DateTimeValue::now()`.
88pub fn generate_openapi(engine: &Engine, explain: bool) -> Value {
89    generate_openapi_effective(engine, explain, &DateTimeValue::now())
90}
91
92/// Generate a complete OpenAPI 3.1 specification for a specific point in time.
93///
94/// The specification includes:
95/// - `GET /` — list loaded specs (name, data/rule counts)
96/// - `/{spec}` GET (show: `spec`, `effective_from`, `data`, `rules`, `meta`, `versions`) and
97///   POST (evaluate: envelope `spec`, `effective`, `result`) with optional `Accept-Datetime` header
98/// - `?rules=` on POST only, to limit evaluated rules
99/// - `x-effective-from` / `x-effective-to` vendor extensions on each PathItem
100///   exposing the half-open `[effective_from, effective_to)` range of the version
101///   resolved at the document's effective instant (both `null` when unbounded)
102///
103/// CLI `lemma server` also exposes shell routes (`/openapi.json`, `/health`, `/docs`) that are
104/// intentionally omitted from the generated document.
105///
106/// When `explain` is true, the document adds the `x-explain` header parameter
107/// to evaluation operations and describes the optional `explanation` field on rule results.
108pub fn generate_openapi_effective(
109    engine: &Engine,
110    explain: bool,
111    effective: &DateTimeValue,
112) -> Value {
113    let mut paths = Map::new();
114    let mut components_schemas = Map::new();
115
116    components_schemas.insert("LemmaRuleResult".to_string(), build_rule_result_schema());
117
118    let repositories = engine.list();
119    let workspace = repositories
120        .iter()
121        .find(|r| r.repository.is_none())
122        .map(|r| r.specs.as_slice())
123        .expect("BUG: default repository must exist in list()");
124
125    let is_active = |ls: &ListedSpec| -> bool {
126        let after_start = match &ls.effective_from {
127            None => true,
128            Some(from) => effective >= from,
129        };
130        let before_end = match &ls.effective_to {
131            None => true,
132            Some(to) => effective < to,
133        };
134        after_start && before_end
135    };
136
137    let active_specs: Vec<&ListedSpec> = workspace.iter().filter(|ls| is_active(ls)).collect();
138
139    let unique_spec_names: std::collections::BTreeSet<&str> =
140        active_specs.iter().map(|ls| ls.name.as_str()).collect();
141    let unique_spec_names: Vec<String> = unique_spec_names.into_iter().map(String::from).collect();
142
143    paths.insert("/".to_string(), index_path_item(engine));
144
145    for ls in &active_specs {
146        let spec_name = &ls.name;
147        if let Ok(show) = engine.show(None, spec_name, Some(effective)) {
148            let artifacts = build_spec_openapi_artifacts(
149                spec_name,
150                &show,
151                (ls.effective_from.as_ref(), ls.effective_to.as_ref()),
152                explain,
153            );
154            paths.insert(format!("/{spec_name}"), artifacts.path_item);
155            for (name, schema_value) in artifacts.component_schemas {
156                components_schemas.insert(name, schema_value);
157            }
158        }
159    }
160
161    let mut tags = vec![json!({
162        "name": "Specs",
163        "description": "List loaded specs"
164    })];
165    for spec_name in &unique_spec_names {
166        let safe_tag = spec_name.replace('.', "_");
167        tags.push(json!({
168            "name": safe_tag,
169            "x-displayName": spec_name,
170            "description": format!("Show and evaluate `{spec_name}`")
171        }));
172    }
173
174    let spec_tags: Vec<Value> = unique_spec_names
175        .iter()
176        .map(|n| Value::String(n.replace('.', "_")))
177        .collect();
178
179    let tag_groups = vec![
180        json!({ "name": "Overview", "tags": ["Specs"] }),
181        json!({ "name": "Specs", "tags": spec_tags }),
182    ];
183
184    let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
185
186    json!({
187        "openapi": "3.1.0",
188        "info": {
189            "title": "Lemma API",
190            "description": "Lemma is a declarative language for business logic: pricing, tax, eligibility, contracts, and policies. This API lists, shows, and evaluates loaded specs. Learn more at [LemmaBase.com](https://lemmabase.com).",
191            "version": version_label
192        },
193        "tags": tags,
194        "x-tagGroups": tag_groups,
195        "paths": Value::Object(paths),
196        "components": {
197            "schemas": Value::Object(components_schemas)
198        }
199    })
200}
201
202/// Information about a single input data for OpenAPI generation.
203struct InputData {
204    /// The data name as it appears in the API (e.g. "measure", "is_member").
205    name: String,
206    /// The resolved Lemma type for this data.
207    lemma_type: LemmaType,
208    /// Spec literal or literal `with` binding.
209    fill: Option<lemma::RuleResultValue>,
210    /// Suggestion from `-> suggest ...` (UI hint only; never commits at evaluation).
211    suggestion: Option<lemma::RuleResultValue>,
212}
213
214/// Collect local eval inputs from a pre-built show.
215///
216/// Only includes data local to the spec (no dot-separated cross-spec
217/// paths like `calc.price`) that at least one remaining rule needs
218/// (`needed_by_rules` non-empty). Reuse-only catalog slots stay out of
219/// generated request bodies. Already sorted by `show()`.
220fn collect_input_data_from_show(show: &lemma::Show) -> Vec<InputData> {
221    show.data
222        .iter()
223        .filter(|(name, entry)| !name.contains('.') && !entry.needed_by_rules.is_empty())
224        .map(|(name, entry)| InputData {
225            name: name.clone(),
226            lemma_type: entry.lemma_type.clone(),
227            fill: entry.fill.clone(),
228            suggestion: entry.suggestion.clone(),
229        })
230        .collect()
231}
232
233// ---------------------------------------------------------------------------
234// Index path (list specs)
235// ---------------------------------------------------------------------------
236
237fn index_path_item(engine: &Engine) -> Value {
238    let list = engine.list();
239    let example = serde_json::to_value(&list).expect("BUG: Engine::list must serialize");
240
241    json!({
242        "get": {
243            "operationId": "list",
244            "summary": "List specs",
245            "tags": ["Specs"],
246            "responses": {
247                "200": {
248                    "description": "Loaded repositories and their specs",
249                    "content": {
250                        "application/json": {
251                            "schema": {
252                                "type": "array",
253                                "items": {
254                                    "type": "object",
255                                    "properties": {
256                                        "repository": {
257                                            "type": ["string", "null"],
258                                            "description": "Repository name. Null for the default repository."
259                                        },
260                                        "specs": {
261                                            "type": "array",
262                                            "description": "Specs in this repository",
263                                            "items": {
264                                                "type": "object",
265                                                "properties": {
266                                                    "name": {
267                                                        "type": "string",
268                                                        "description": "Spec name"
269                                                    },
270                                                    "effective_from": {
271                                                        "type": ["string", "null"],
272                                                        "description": "When this version starts. Null if unbounded."
273                                                    },
274                                                    "effective_to": {
275                                                        "type": ["string", "null"],
276                                                        "description": "When this version ends (exclusive). Null if this is the latest."
277                                                    }
278                                                },
279                                                "required": ["name"]
280                                            }
281                                        }
282                                    },
283                                    "required": ["specs"]
284                                }
285                            },
286                            "example": example
287                        }
288                    }
289                }
290            }
291        }
292    })
293}
294
295// ---------------------------------------------------------------------------
296// Shared response schemas
297// ---------------------------------------------------------------------------
298
299fn error_response_schema() -> Value {
300    json!({
301        "description": "Bad request",
302        "content": {
303            "application/json": {
304                "schema": {
305                    "type": "object",
306                    "properties": {
307                        "error": { "type": "string" }
308                    },
309                    "required": ["error"]
310                }
311            }
312        }
313    })
314}
315
316fn not_found_response_schema() -> Value {
317    json!({
318        "description": "Spec not found",
319        "content": {
320            "application/json": {
321                "schema": {
322                    "type": "object",
323                    "properties": {
324                        "error": { "type": "string" }
325                    },
326                    "required": ["error"]
327                }
328            }
329        }
330    })
331}
332
333fn memento_spec_response_headers() -> Value {
334    json!({
335        "Memento-Datetime": {
336            "description": "`effective_from` of the resolved version, when versioned",
337            "schema": { "type": "string" }
338        },
339        "Vary": {
340            "description": "Varies with Accept-Datetime",
341            "schema": { "type": "string", "example": "Accept-Datetime" }
342        }
343    })
344}
345
346/// GET `/{spec}` body: [`lemma::Show`].
347fn build_get_show_response() -> Value {
348    json!({
349        "type": "object",
350        "required": ["spec", "data", "rules", "meta", "start_line"],
351        "properties": {
352            "repository": {
353                "type": "string",
354                "description": "Interned repository name. Omitted for the unnamed workspace."
355            },
356            "spec": {
357                "type": "string",
358                "description": "Spec name"
359            },
360            "commentary": {
361                "type": ["string", "null"],
362                "description": "Docstring after the spec line"
363            },
364            "effective_from": {
365                "type": ["string", "null"],
366                "description": "When this version starts. Null if unbounded."
367            },
368            "effective_to": {
369                "type": ["string", "null"],
370                "description": "When this version ends (exclusive). Null if this is the latest."
371            },
372            "start_line": {
373                "type": "integer",
374                "description": "Line of the spec declaration (1-based)"
375            },
376            "source_type": {
377                "description": "How this spec was loaded"
378            },
379            "data": {
380                "type": "object",
381                "description": "Inputs: types, path identity, filled values, and suggestions",
382                "additionalProperties": true
383            },
384            "rules": {
385                "type": "object",
386                "description": "This spec's rule graph (local plus reachable imports)",
387                "additionalProperties": true
388            },
389            "meta": {
390                "type": "object",
391                "description": "Spec metadata",
392                "additionalProperties": true
393            },
394            "versions": {
395                "type": "array",
396                "description": "All versions of this spec. Ranges are `[effective_from, effective_to)`.",
397                "items": {
398                    "type": "object",
399                    "required": ["effective_from", "effective_to"],
400                    "properties": {
401                        "effective_from": {
402                            "type": ["string", "null"],
403                            "description": "When this version starts. Null if unbounded."
404                        },
405                        "effective_to": {
406                            "type": ["string", "null"],
407                            "description": "When this version ends (exclusive). Null if this is the latest."
408                        }
409                    }
410                }
411            }
412        }
413    })
414}
415
416/// Single rule output: flat fields matching engine [`lemma::RuleResult`].
417fn build_rule_result_schema() -> Value {
418    json!({
419        "type": "object",
420        "required": ["vetoed", "rule_type"],
421        "properties": {
422            "vetoed": {
423                "type": "boolean",
424                "description": "True when the rule has no value"
425            },
426            "result": {
427                "type": "string",
428                "description": "Formatted value when not vetoed"
429            },
430            "veto_reason": {
431                "type": "string",
432                "description": "Why the rule vetoed"
433            },
434            "rule_type": {
435                "type": "string",
436                "description": "Result type (number, boolean, money, ...)"
437            },
438            "measure": {
439                "type": "object",
440                "additionalProperties": { "type": "string" },
441                "description": "Measure value: unit name to magnitude"
442            },
443            "ratio": {
444                "type": "object",
445                "additionalProperties": { "type": "string" },
446                "description": "Ratio value: unit name to magnitude"
447            },
448            "number": {
449                "type": "string",
450                "description": "Value when the result is this type"
451            },
452            "boolean": {
453                "type": "boolean",
454                "description": "Value when the result is this type"
455            },
456            "text": {
457                "type": "string",
458                "description": "Value when the result is this type"
459            },
460            "date": {
461                "type": "object",
462                "description": "Value when the result is this type"
463            },
464            "time": {
465                "type": "object",
466                "description": "Value when the result is this type"
467            },
468            "calendar": {
469                "type": "object",
470                "description": "Value when the result is this type",
471                "properties": {
472                    "value": { "type": "string" },
473                    "unit": { "type": "string" }
474                }
475            },
476            "range": {
477                "type": "object",
478                "description": "Value when the result is this type"
479            },
480            "missing_data": {
481                "type": "array",
482                "items": { "type": "string" },
483                "description": "Unbound input names for this rule"
484            },
485            "explanation": {
486                "type": "object",
487                "description": "How this result was computed. Needs `--explain` and `x-explain`."
488            }
489        }
490    })
491}
492
493/// POST evaluate body: matches engine [`lemma::Response`] JSON shape.
494fn build_evaluate_response_schema(show: &lemma::Show, rule_names: &[String]) -> Value {
495    let mut result_props = Map::new();
496    for rule_name in rule_names {
497        if show.rules.contains_key(rule_name) {
498            result_props.insert(
499                rule_name.clone(),
500                json!({
501                    "$ref": "#/components/schemas/LemmaRuleResult"
502                }),
503            );
504        }
505    }
506
507    json!({
508        "type": "object",
509        "required": ["spec", "effective", "results"],
510        "properties": {
511            "spec": {
512                "type": "string",
513                "description": "Spec that was evaluated"
514            },
515            "effective": {
516                "type": "string",
517                "description": "Datetime used to pick the version and evaluate"
518            },
519            "spec_effective_from": {
520                "type": "string",
521                "description": "When the resolved version starts"
522            },
523            "spec_effective_to": {
524                "type": "string",
525                "description": "When the resolved version ends (exclusive). Absent if unbounded."
526            },
527            "results": {
528                "type": "object",
529                "description": "Results by rule name. Honors `?rules=`.",
530                "properties": Value::Object(result_props)
531            }
532        }
533    })
534}
535
536// ---------------------------------------------------------------------------
537// Spec path items
538// ---------------------------------------------------------------------------
539
540struct SpecOpenApiArtifacts {
541    path_item: Value,
542    component_schemas: Map<String, Value>,
543}
544
545fn spec_component_show_names(spec_name: &str) -> (String, String, String, String) {
546    let safe_name = spec_name.replace('.', "_");
547    (
548        format!("{safe_name}_get_show"),
549        format!("{safe_name}_evaluate_response"),
550        format!("{safe_name}_request"),
551        format!("{safe_name}_form_request"),
552    )
553}
554
555/// Build the PathItem and per-spec component schemas for `/{spec_name}`.
556///
557/// `effective_range` is the half-open `[effective_from, effective_to)`
558/// validity range of the temporal version resolved at the OpenAPI document's
559/// effective instant. Both bounds are emitted as the `x-effective-from` /
560/// `x-effective-to` vendor extensions on the PathItem so tooling can render
561/// the active version's window without having to inspect the `versions`
562/// array. `None` in either position (unbounded start for the first row,
563/// unbounded end for the latest row) is serialised as JSON `null`.
564fn build_spec_openapi_artifacts(
565    spec_name: &str,
566    show: &lemma::Show,
567    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
568    explain: bool,
569) -> SpecOpenApiArtifacts {
570    let data = collect_input_data_from_show(show);
571    let rule_names: Vec<String> = show
572        .rules
573        .iter()
574        .filter(|(_, rule)| rule.path.is_empty())
575        .map(|(name, _)| name.clone())
576        .collect();
577    let (
578        get_show_component_name,
579        evaluate_response_schema_name,
580        post_body_schema_name,
581        post_form_body_schema_name,
582    ) = spec_component_show_names(spec_name);
583
584    let mut component_schemas = Map::new();
585    component_schemas.insert(get_show_component_name.clone(), build_get_show_response());
586    component_schemas.insert(
587        evaluate_response_schema_name.clone(),
588        build_evaluate_response_schema(show, &rule_names),
589    );
590    component_schemas.insert(
591        post_body_schema_name.clone(),
592        build_post_request_schema(&data),
593    );
594    component_schemas.insert(
595        post_form_body_schema_name.clone(),
596        build_post_form_request_schema(&data),
597    );
598
599    let path_item = build_spec_path_item_with_show_refs(
600        spec_name,
601        (
602            &get_show_component_name,
603            &evaluate_response_schema_name,
604            &post_body_schema_name,
605            &post_form_body_schema_name,
606        ),
607        &rule_names,
608        explain,
609        effective_range,
610    );
611
612    SpecOpenApiArtifacts {
613        path_item,
614        component_schemas,
615    }
616}
617
618fn x_explain_header_parameter() -> Value {
619    json!({
620        "name": "x-explain",
621        "in": "header",
622        "required": false,
623        "description": "Include explanations. Server needs `--explain`.",
624        "schema": { "type": "string", "default": "true" }
625    })
626}
627
628fn accept_datetime_header_parameter() -> Value {
629    json!({
630        "name": "Accept-Datetime",
631        "in": "header",
632        "required": false,
633        "description": "Evaluate at this datetime. Omit for now.",
634        "schema": { "type": "string", "format": "date-time" },
635        "example": "Sat, 01 Jan 2025 00:00:00 GMT"
636    })
637}
638
639/// Build the PathItem for `/{spec_name}` (GET show + POST evaluate).
640fn build_spec_path_item_with_show_refs(
641    spec_name: &str,
642    component_names: (&str, &str, &str, &str),
643    rule_names: &[String],
644    explain: bool,
645    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
646) -> Value {
647    let (
648        get_show_component_name,
649        evaluate_response_schema_name,
650        post_body_schema_name,
651        post_form_body_schema_name,
652    ) = component_names;
653    let (effective_from, effective_to) = effective_range;
654
655    let get_show_ref = json!({
656        "$ref": format!("#/components/schemas/{}", get_show_component_name)
657    });
658    let evaluate_schema_ref = json!({
659        "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
660    });
661    let body_ref = json!({
662        "$ref": format!("#/components/schemas/{}", post_body_schema_name)
663    });
664    let form_body_ref = json!({
665        "$ref": format!("#/components/schemas/{}", post_form_body_schema_name)
666    });
667
668    let tag = spec_name.replace('.', "_");
669
670    let rules_example = if rule_names.is_empty() {
671        String::new()
672    } else {
673        rule_names.join(",")
674    };
675
676    let rules_param = json!({
677        "name": "rules",
678        "in": "query",
679        "required": false,
680        "description": "Rules to evaluate, comma-separated. Omit for all.",
681        "schema": { "type": "string" },
682        "example": rules_example
683    });
684
685    let mut get_parameters: Vec<Value> = Vec::new();
686    get_parameters.push(accept_datetime_header_parameter());
687    if explain {
688        get_parameters.push(x_explain_header_parameter());
689    }
690
691    let get_operation_id = format!("get_{}", spec_name);
692    let post_operation_id = format!("post_{}", spec_name);
693
694    let mut post_parameters: Vec<Value> = vec![rules_param];
695    post_parameters.push(accept_datetime_header_parameter());
696    if explain {
697        post_parameters.push(x_explain_header_parameter());
698    }
699
700    let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
701        match dt {
702            Some(d) => Value::String(d.to_string()),
703            None => Value::Null,
704        }
705    };
706
707    json!({
708        "x-effective-from": datetime_or_null(effective_from),
709        "x-effective-to": datetime_or_null(effective_to),
710        "get": {
711            "operationId": get_operation_id,
712            "summary": "Show spec",
713            "tags": [tag],
714            "parameters": get_parameters,
715            "responses": {
716                "200": {
717                    "description": "Spec interface",
718                    "headers": memento_spec_response_headers(),
719                    "content": {
720                        "application/json": {
721                            "schema": get_show_ref
722                        }
723                    }
724                },
725                "400": error_response_schema(),
726                "404": not_found_response_schema()
727            }
728        },
729        "post": {
730            "operationId": post_operation_id,
731            "summary": "Evaluate",
732            "tags": [tag],
733            "parameters": post_parameters,
734            "requestBody": {
735                "required": true,
736                "content": {
737                    // First media type is Scalar's default body encoding.
738                    "application/x-www-form-urlencoded": {
739                        "schema": form_body_ref
740                    },
741                    "application/json": {
742                        "schema": body_ref
743                    }
744                }
745            },
746            "responses": {
747                "200": {
748                    "description": "Evaluation results",
749                    "headers": memento_spec_response_headers(),
750                    "content": {
751                        "application/json": {
752                            "schema": evaluate_schema_ref
753                        }
754                    }
755                },
756                "400": error_response_schema(),
757                "404": not_found_response_schema()
758            }
759        }
760    })
761}
762
763// ---------------------------------------------------------------------------
764// Help and default from Lemma types
765// ---------------------------------------------------------------------------
766
767/// Extract the type's help text for use as description. Always has a value for non-Veto types.
768fn type_help(lemma_type: &LemmaType) -> String {
769    match &lemma_type.specifications {
770        TypeSpecification::Boolean { help, .. } => help.clone(),
771        TypeSpecification::Measure { help, .. } => help.clone(),
772        TypeSpecification::MeasureRange { help, .. } => help.clone(),
773        TypeSpecification::Number { help, .. } => help.clone(),
774        TypeSpecification::NumberRange { help, .. } => help.clone(),
775        TypeSpecification::Ratio { help, .. } => help.clone(),
776        TypeSpecification::RatioRange { help, .. } => help.clone(),
777        TypeSpecification::Text { help, .. } => help.clone(),
778        TypeSpecification::Date { help, .. } => help.clone(),
779        TypeSpecification::DateRange { help, .. } => help.clone(),
780        TypeSpecification::TimeRange { help, .. } => help.clone(),
781        TypeSpecification::Time { help, .. } => help.clone(),
782        TypeSpecification::Veto { .. } => String::new(),
783        TypeSpecification::Spec { spec } => spec.to_string(),
784        TypeSpecification::Undetermined => unreachable!(
785            "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
786        ),
787    }
788}
789
790// ---------------------------------------------------------------------------
791// POST request body schema generation (JSON object keyed by data field names)
792// ---------------------------------------------------------------------------
793
794fn build_post_request_schema(data: &[InputData]) -> Value {
795    let mut properties = Map::new();
796    let mut required = Vec::new();
797
798    for data in data {
799        let default_for_docs = data.fill.as_ref().or(data.suggestion.as_ref());
800        properties.insert(
801            data.name.clone(),
802            build_post_property_schema(&data.lemma_type, default_for_docs),
803        );
804        if data.fill.is_none() {
805            required.push(Value::String(data.name.clone()));
806        }
807    }
808
809    let mut schema = json!({
810        "type": "object",
811        "properties": Value::Object(properties)
812    });
813    if !required.is_empty() {
814        schema["required"] = Value::Array(required);
815    }
816    schema
817}
818
819fn build_post_property_schema(
820    lemma_type: &LemmaType,
821    data_value: Option<&lemma::RuleResultValue>,
822) -> Value {
823    let mut schema = build_post_type_schema(lemma_type);
824
825    let help = type_help(lemma_type);
826    if !help.is_empty() {
827        schema["description"] = Value::String(help);
828    }
829
830    if let Some(v) = data_value {
831        schema["default"] = Value::String(v.to_literal(lemma_type).display_value());
832    }
833
834    schema
835}
836
837fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
838    match &lemma_type.specifications {
839        TypeSpecification::Text { options, .. } => {
840            let mut schema = json!({ "type": "string" });
841            if !options.is_empty() {
842                schema["enum"] =
843                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
844            }
845            schema
846        }
847        TypeSpecification::Boolean { .. } => {
848            json!({ "type": "boolean" })
849        }
850        _ => json!({ "type": "string" }),
851    }
852}
853
854fn build_post_form_request_schema(data: &[InputData]) -> Value {
855    let mut properties = Map::new();
856    let mut required = Vec::new();
857
858    for data in data {
859        let default_for_docs = data.fill.as_ref().or(data.suggestion.as_ref());
860        properties.insert(
861            data.name.clone(),
862            build_post_form_property_schema(&data.lemma_type, default_for_docs),
863        );
864        if data.fill.is_none() {
865            required.push(Value::String(data.name.clone()));
866        }
867    }
868
869    let mut schema = json!({
870        "type": "object",
871        "properties": Value::Object(properties)
872    });
873    if !required.is_empty() {
874        schema["required"] = Value::Array(required);
875    }
876    schema
877}
878
879fn build_post_form_property_schema(
880    lemma_type: &LemmaType,
881    data_value: Option<&lemma::RuleResultValue>,
882) -> Value {
883    let mut schema = build_post_form_type_schema(lemma_type);
884
885    let help = type_help(lemma_type);
886    if !help.is_empty() {
887        schema["description"] = Value::String(help);
888    }
889
890    if let Some(v) = data_value {
891        schema["default"] = Value::String(v.to_literal(lemma_type).display_value());
892    }
893
894    schema
895}
896
897fn build_post_form_type_schema(lemma_type: &LemmaType) -> Value {
898    match &lemma_type.specifications {
899        TypeSpecification::Text { options, .. } => {
900            let mut schema = json!({ "type": "string" });
901            if !options.is_empty() {
902                schema["enum"] =
903                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
904            }
905            schema
906        }
907        TypeSpecification::Boolean { .. } => {
908            json!({ "type": "string", "enum": ["true", "false"] })
909        }
910        _ => json!({ "type": "string" }),
911    }
912}
913
914// ---------------------------------------------------------------------------
915// Helpers
916// ---------------------------------------------------------------------------
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use lemma::{DateGranularity, DateTimeValue, SourceType};
922
923    fn create_engine_with_code(code: &str) -> Engine {
924        let mut engine = Engine::new();
925        engine
926            .load([(
927                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
928                code.to_string(),
929            )])
930            .expect("failed to parse lemma code");
931        engine
932    }
933
934    fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
935        let mut engine = Engine::new();
936        for (name, code) in files {
937            engine
938                .load([(
939                    SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
940                    code.to_string(),
941                )])
942                .expect("failed to parse lemma code");
943        }
944        engine
945    }
946
947    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
948        DateTimeValue {
949            year,
950            month,
951            day,
952            hour: 0,
953            minute: 0,
954            second: 0,
955            microsecond: 0,
956            timezone: None,
957            granularity: DateGranularity::Full,
958        }
959    }
960
961    fn has_param(params: &Value, name: &str) -> bool {
962        params
963            .as_array()
964            .map(|a| a.iter().any(|p| p["name"] == name))
965            .unwrap_or(false)
966    }
967
968    // =======================================================================
969    // Basic spec structure (pre-existing, adapted)
970    // =======================================================================
971
972    #[test]
973    fn test_generate_openapi_x_tag_groups() {
974        let engine = create_engine_with_code(
975            "spec pricing
976            data quantity: 10
977            rule total: quantity * 2",
978        );
979        let spec = generate_openapi(&engine, false);
980
981        let groups = spec["x-tagGroups"]
982            .as_array()
983            .expect("x-tagGroups should be array");
984        assert_eq!(groups.len(), 2);
985        assert_eq!(groups[0]["name"], "Overview");
986        assert_eq!(groups[0]["tags"], json!(["Specs"]));
987        assert_eq!(groups[1]["name"], "Specs");
988        assert_eq!(groups[1]["tags"], json!(["pricing"]));
989    }
990
991    #[test]
992    fn test_spec_path_has_get_and_post() {
993        let engine = create_engine_with_code(
994            "spec pricing
995            data quantity: 10
996            rule total: quantity * 2",
997        );
998        let spec = generate_openapi(&engine, false);
999
1000        assert!(
1001            spec["paths"]["/pricing"].is_object(),
1002            "single spec path /pricing"
1003        );
1004        assert!(spec["paths"]["/pricing"]["get"].is_object());
1005        assert!(spec["paths"]["/pricing"]["post"].is_object());
1006
1007        assert_eq!(
1008            spec["paths"]["/pricing"]["get"]["operationId"],
1009            "get_pricing"
1010        );
1011        assert_eq!(
1012            spec["paths"]["/pricing"]["post"]["operationId"],
1013            "post_pricing"
1014        );
1015        assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
1016
1017        let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
1018            .as_array()
1019            .expect("parameters array");
1020        let param_names: Vec<&str> = get_params
1021            .iter()
1022            .map(|p| p["name"].as_str().unwrap())
1023            .collect();
1024        assert!(
1025            !param_names.contains(&"rules"),
1026            "GET must not have rules query param (show is full interface)"
1027        );
1028        let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
1029            .as_array()
1030            .expect("post parameters array");
1031        let post_param_names: Vec<&str> = post_params
1032            .iter()
1033            .map(|p| p["name"].as_str().unwrap())
1034            .collect();
1035        assert!(
1036            post_param_names.contains(&"rules"),
1037            "POST must have rules query param"
1038        );
1039        assert!(
1040            param_names.contains(&"Accept-Datetime"),
1041            "GET must have Accept-Datetime header"
1042        );
1043
1044        let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1045            ["application/json"]["schema"]["$ref"]
1046            .as_str()
1047            .unwrap();
1048        let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1049            ["application/json"]["schema"]["$ref"]
1050            .as_str()
1051            .unwrap();
1052        assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
1053        assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1054        assert_ne!(get_ref, post_ref);
1055
1056        let get_show = &spec["components"]["schemas"]["pricing_get_show"];
1057        assert!(get_show["properties"]["spec"]["type"] == "string");
1058        assert!(get_show["properties"].get("spec_set_id").is_none());
1059        assert!(get_show["properties"]["versions"].is_object());
1060        assert!(get_show["properties"]["start_line"]["type"] == "integer");
1061
1062        let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1063        assert!(h200["headers"]["Memento-Datetime"].is_object());
1064        assert!(h200["headers"]["Vary"].is_object());
1065    }
1066
1067    #[test]
1068    fn suggestion_only_field_is_required_with_json_schema_default() {
1069        let engine = create_engine_with_code(
1070            r#"
1071spec age_check
1072data age: number -> suggest 18
1073rule adult: age >= 18
1074"#,
1075        );
1076        let spec = generate_openapi(&engine, false);
1077        let body = &spec["components"]["schemas"]["age_check_request"];
1078        let required = body["required"]
1079            .as_array()
1080            .expect("required array")
1081            .iter()
1082            .map(|v| v.as_str().unwrap())
1083            .collect::<Vec<_>>();
1084        assert!(
1085            required.contains(&"age"),
1086            "suggest-only fields must stay required, got {required:?}"
1087        );
1088        assert_eq!(body["properties"]["age"]["default"], "18");
1089    }
1090
1091    /// The generated OpenAPI document describes the public spec surface only.
1092    /// Server shell routes (`/openapi.json`, `/health`, `/docs`) are
1093    /// intentionally omitted; consumers must not rely on them for code
1094    /// generation or contract inspection.
1095    #[test]
1096    fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1097        let engine = create_engine_with_code(
1098            "spec pricing
1099            data quantity: 10
1100            rule total: quantity * 2",
1101        );
1102        let spec = generate_openapi(&engine, false);
1103
1104        let paths = spec["paths"].as_object().expect("paths object");
1105        assert!(paths.contains_key("/"));
1106        assert_eq!(paths["/"]["get"]["operationId"], "list");
1107        assert!(!paths.contains_key("/openapi.json"));
1108        assert!(!paths.contains_key("/health"));
1109        assert!(!paths.contains_key("/docs"));
1110        assert!(!paths.contains_key("/schema/pricing"));
1111        assert!(!paths.contains_key("/schema/pricing/{rules}"));
1112        assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1113    }
1114
1115    #[test]
1116    fn test_generate_openapi_explain_adds_x_explain_and_explanation_schema() {
1117        let engine = create_engine_with_code(
1118            "spec pricing
1119            data quantity: 10
1120            rule total: quantity * 2",
1121        );
1122        let spec = generate_openapi(&engine, true);
1123
1124        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1125        assert!(has_param(get_params, "x-explain"));
1126
1127        let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
1128        assert!(rule_result["properties"]["explanation"].is_object());
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/LemmaRuleResult");
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/LemmaRuleResult"),
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 product: text -> option \"A\" -> option \"B\"
1477            rule result: product",
1478        );
1479        let spec = generate_openapi(&engine, false);
1480
1481        let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1482        assert!(product_prop["enum"].is_array());
1483        let enums = product_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["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    }
1520
1521    #[test]
1522    fn test_data_with_default_is_not_required() {
1523        let engine = create_engine_with_code(
1524            "spec test
1525            data quantity: 10
1526            data name: text
1527            rule result: quantity
1528            rule label: name",
1529        );
1530        let spec = generate_openapi(&engine, false);
1531
1532        let schema = &spec["components"]["schemas"]["test_request"];
1533        let required = schema["required"]
1534            .as_array()
1535            .expect("required should be array");
1536
1537        assert!(required.contains(&Value::String("name".to_string())));
1538        assert!(!required.contains(&Value::String("quantity".to_string())));
1539    }
1540
1541    #[test]
1542    fn test_help_and_default_in_openapi() {
1543        let engine = create_engine_with_code(
1544            r#"spec test
1545data quantity: number -> help "Number of items to order" -> suggest 10
1546data active: boolean -> help "Whether the feature is enabled" -> suggest true
1547rule result:
1548  quantity
1549  unless active then 0
1550"#,
1551        );
1552        let spec = generate_openapi(&engine, false);
1553
1554        let req_schema = &spec["components"]["schemas"]["test_request"];
1555        assert!(req_schema["properties"]["quantity"]["description"]
1556            .as_str()
1557            .unwrap()
1558            .contains("Number of items to order"));
1559        assert_eq!(
1560            req_schema["properties"]["quantity"]["default"]
1561                .as_str()
1562                .unwrap(),
1563            "10"
1564        );
1565        assert!(req_schema["properties"]["active"]["description"]
1566            .as_str()
1567            .unwrap()
1568            .contains("Whether the feature is enabled"));
1569        assert_eq!(
1570            req_schema["properties"]["active"]["default"]
1571                .as_str()
1572                .unwrap(),
1573            "true"
1574        );
1575        let required = req_schema["required"]
1576            .as_array()
1577            .expect("required array")
1578            .iter()
1579            .map(|v| v.as_str().unwrap())
1580            .collect::<Vec<_>>();
1581        assert!(required.contains(&"quantity"));
1582        assert!(required.contains(&"active"));
1583    }
1584}