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: workspace 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    prefilled: Option<lemma::RuleResultValue>,
210    /// Suggestion from `-> suggest ...` (UI hint only; never commits at evaluation).
211    suggestion: Option<lemma::RuleResultValue>,
212}
213
214/// Collect all local input data from a pre-built schema.
215///
216/// Only includes data local to the spec (no dot-separated cross-spec
217/// paths like `calc.price`). Already sorted alphabetically by `show()`.
218fn collect_input_data_from_show(show: &lemma::Show) -> Vec<InputData> {
219    show.data
220        .iter()
221        .filter(|(name, _)| !name.contains('.'))
222        .map(|(name, entry)| InputData {
223            name: name.clone(),
224            lemma_type: entry.lemma_type.clone(),
225            prefilled: entry.prefilled.clone(),
226            suggestion: entry.suggestion.clone(),
227        })
228        .collect()
229}
230
231// ---------------------------------------------------------------------------
232// Index path (list specs)
233// ---------------------------------------------------------------------------
234
235fn index_path_item(engine: &Engine) -> Value {
236    let list = engine.list();
237    let example = serde_json::to_value(&list).expect("BUG: Engine::list must serialize");
238
239    json!({
240        "get": {
241            "operationId": "list",
242            "summary": "List specs",
243            "tags": ["Specs"],
244            "responses": {
245                "200": {
246                    "description": "Loaded repositories and their specs",
247                    "content": {
248                        "application/json": {
249                            "schema": {
250                                "type": "array",
251                                "items": {
252                                    "type": "object",
253                                    "properties": {
254                                        "repository": {
255                                            "type": ["string", "null"],
256                                            "description": "Repository name. Null for the workspace."
257                                        },
258                                        "specs": {
259                                            "type": "array",
260                                            "description": "Specs in this repository",
261                                            "items": {
262                                                "type": "object",
263                                                "properties": {
264                                                    "name": {
265                                                        "type": "string",
266                                                        "description": "Spec name"
267                                                    },
268                                                    "effective_from": {
269                                                        "type": ["string", "null"],
270                                                        "description": "When this version starts. Null if unbounded."
271                                                    },
272                                                    "effective_to": {
273                                                        "type": ["string", "null"],
274                                                        "description": "When this version ends (exclusive). Null if this is the latest."
275                                                    }
276                                                },
277                                                "required": ["name"]
278                                            }
279                                        }
280                                    },
281                                    "required": ["specs"]
282                                }
283                            },
284                            "example": example
285                        }
286                    }
287                }
288            }
289        }
290    })
291}
292
293// ---------------------------------------------------------------------------
294// Shared response schemas
295// ---------------------------------------------------------------------------
296
297fn error_response_schema() -> Value {
298    json!({
299        "description": "Bad request",
300        "content": {
301            "application/json": {
302                "schema": {
303                    "type": "object",
304                    "properties": {
305                        "error": { "type": "string" }
306                    },
307                    "required": ["error"]
308                }
309            }
310        }
311    })
312}
313
314fn not_found_response_schema() -> Value {
315    json!({
316        "description": "Spec not found",
317        "content": {
318            "application/json": {
319                "schema": {
320                    "type": "object",
321                    "properties": {
322                        "error": { "type": "string" }
323                    },
324                    "required": ["error"]
325                }
326            }
327        }
328    })
329}
330
331fn memento_spec_response_headers() -> Value {
332    json!({
333        "Memento-Datetime": {
334            "description": "`effective_from` of the resolved version, when versioned",
335            "schema": { "type": "string" }
336        },
337        "Vary": {
338            "description": "Varies with Accept-Datetime",
339            "schema": { "type": "string", "example": "Accept-Datetime" }
340        }
341    })
342}
343
344/// GET `/{spec}` body: [`lemma::Show`].
345fn build_get_show_response() -> Value {
346    json!({
347        "type": "object",
348        "required": ["spec", "data", "rules", "meta", "start_line"],
349        "properties": {
350            "spec": {
351                "type": "string",
352                "description": "Spec name"
353            },
354            "commentary": {
355                "type": ["string", "null"],
356                "description": "Docstring after the spec line"
357            },
358            "effective_from": {
359                "type": ["string", "null"],
360                "description": "When this version starts. Null if unbounded."
361            },
362            "effective_to": {
363                "type": ["string", "null"],
364                "description": "When this version ends (exclusive). Null if this is the latest."
365            },
366            "start_line": {
367                "type": "integer",
368                "description": "Line of the spec declaration (1-based)"
369            },
370            "source_type": {
371                "description": "How this spec was loaded"
372            },
373            "data": {
374                "type": "object",
375                "description": "Inputs: types, prefilled values, and suggestions",
376                "additionalProperties": true
377            },
378            "rules": {
379                "type": "object",
380                "description": "Rules and their result types",
381                "additionalProperties": true
382            },
383            "meta": {
384                "type": "object",
385                "description": "Spec metadata",
386                "additionalProperties": true
387            },
388            "versions": {
389                "type": "array",
390                "description": "All versions of this spec. Ranges are `[effective_from, effective_to)`.",
391                "items": {
392                    "type": "object",
393                    "required": ["effective_from", "effective_to"],
394                    "properties": {
395                        "effective_from": {
396                            "type": ["string", "null"],
397                            "description": "When this version starts. Null if unbounded."
398                        },
399                        "effective_to": {
400                            "type": ["string", "null"],
401                            "description": "When this version ends (exclusive). Null if this is the latest."
402                        }
403                    }
404                }
405            }
406        }
407    })
408}
409
410/// Single rule output: flat fields matching engine [`lemma::RuleResult`].
411fn build_rule_result_schema() -> Value {
412    json!({
413        "type": "object",
414        "required": ["vetoed", "rule_type"],
415        "properties": {
416            "vetoed": {
417                "type": "boolean",
418                "description": "True when the rule has no value"
419            },
420            "display": {
421                "type": "string",
422                "description": "Formatted value when not vetoed"
423            },
424            "veto_reason": {
425                "type": "string",
426                "description": "Why the rule vetoed"
427            },
428            "rule_type": {
429                "type": "string",
430                "description": "Result type (number, boolean, money, ...)"
431            },
432            "measure": {
433                "type": "object",
434                "additionalProperties": { "type": "string" },
435                "description": "Measure value: unit name to magnitude"
436            },
437            "ratio": {
438                "type": "object",
439                "additionalProperties": { "type": "string" },
440                "description": "Ratio value: unit name to magnitude"
441            },
442            "number": {
443                "type": "string",
444                "description": "Value when the result is this type"
445            },
446            "boolean": {
447                "type": "boolean",
448                "description": "Value when the result is this type"
449            },
450            "text": {
451                "type": "string",
452                "description": "Value when the result is this type"
453            },
454            "date": {
455                "type": "object",
456                "description": "Value when the result is this type"
457            },
458            "time": {
459                "type": "object",
460                "description": "Value when the result is this type"
461            },
462            "calendar": {
463                "type": "object",
464                "description": "Value when the result is this type",
465                "properties": {
466                    "value": { "type": "string" },
467                    "unit": { "type": "string" }
468                }
469            },
470            "range": {
471                "type": "object",
472                "description": "Value when the result is this type"
473            },
474            "missing_data": {
475                "type": "array",
476                "items": { "type": "string" },
477                "description": "Unbound input names for this rule"
478            },
479            "explanation": {
480                "type": "object",
481                "description": "How this result was computed. Needs `--explain` and `x-explain`."
482            }
483        }
484    })
485}
486
487/// POST evaluate body: matches engine [`lemma::Response`] JSON shape.
488fn build_evaluate_response_schema(show: &lemma::Show, rule_names: &[String]) -> Value {
489    let mut result_props = Map::new();
490    for rule_name in rule_names {
491        if show.rules.contains_key(rule_name) {
492            result_props.insert(
493                rule_name.clone(),
494                json!({
495                    "$ref": "#/components/schemas/LemmaRuleResult"
496                }),
497            );
498        }
499    }
500
501    json!({
502        "type": "object",
503        "required": ["spec", "effective", "results"],
504        "properties": {
505            "spec": {
506                "type": "string",
507                "description": "Spec that was evaluated"
508            },
509            "effective": {
510                "type": "string",
511                "description": "Datetime used to pick the version and evaluate"
512            },
513            "spec_effective_from": {
514                "type": "string",
515                "description": "When the resolved version starts"
516            },
517            "spec_effective_to": {
518                "type": "string",
519                "description": "When the resolved version ends (exclusive). Absent if unbounded."
520            },
521            "results": {
522                "type": "object",
523                "description": "Results by rule name. Honors `?rules=`.",
524                "properties": Value::Object(result_props)
525            }
526        }
527    })
528}
529
530// ---------------------------------------------------------------------------
531// Spec path items
532// ---------------------------------------------------------------------------
533
534struct SpecOpenApiArtifacts {
535    path_item: Value,
536    component_schemas: Map<String, Value>,
537}
538
539fn spec_component_show_names(spec_name: &str) -> (String, String, String, String) {
540    let safe_name = spec_name.replace('.', "_");
541    (
542        format!("{safe_name}_get_show"),
543        format!("{safe_name}_evaluate_response"),
544        format!("{safe_name}_request"),
545        format!("{safe_name}_form_request"),
546    )
547}
548
549/// Build the PathItem and per-spec component schemas for `/{spec_name}`.
550///
551/// `effective_range` is the half-open `[effective_from, effective_to)`
552/// validity range of the temporal version resolved at the OpenAPI document's
553/// effective instant. Both bounds are emitted as the `x-effective-from` /
554/// `x-effective-to` vendor extensions on the PathItem so tooling can render
555/// the active version's window without having to inspect the `versions`
556/// array. `None` in either position (unbounded start for the first row,
557/// unbounded end for the latest row) is serialised as JSON `null`.
558fn build_spec_openapi_artifacts(
559    spec_name: &str,
560    show: &lemma::Show,
561    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
562    explain: bool,
563) -> SpecOpenApiArtifacts {
564    let data = collect_input_data_from_show(show);
565    let rule_names: Vec<String> = show.rules.keys().cloned().collect();
566    let (
567        get_show_component_name,
568        evaluate_response_schema_name,
569        post_body_schema_name,
570        post_form_body_schema_name,
571    ) = spec_component_show_names(spec_name);
572
573    let mut component_schemas = Map::new();
574    component_schemas.insert(get_show_component_name.clone(), build_get_show_response());
575    component_schemas.insert(
576        evaluate_response_schema_name.clone(),
577        build_evaluate_response_schema(show, &rule_names),
578    );
579    component_schemas.insert(
580        post_body_schema_name.clone(),
581        build_post_request_schema(&data),
582    );
583    component_schemas.insert(
584        post_form_body_schema_name.clone(),
585        build_post_form_request_schema(&data),
586    );
587
588    let path_item = build_spec_path_item_with_show_refs(
589        spec_name,
590        (
591            &get_show_component_name,
592            &evaluate_response_schema_name,
593            &post_body_schema_name,
594            &post_form_body_schema_name,
595        ),
596        &rule_names,
597        explain,
598        effective_range,
599    );
600
601    SpecOpenApiArtifacts {
602        path_item,
603        component_schemas,
604    }
605}
606
607fn x_explain_header_parameter() -> Value {
608    json!({
609        "name": "x-explain",
610        "in": "header",
611        "required": false,
612        "description": "Include explanations. Server needs `--explain`.",
613        "schema": { "type": "string", "default": "true" }
614    })
615}
616
617fn accept_datetime_header_parameter() -> Value {
618    json!({
619        "name": "Accept-Datetime",
620        "in": "header",
621        "required": false,
622        "description": "Evaluate at this datetime. Omit for now.",
623        "schema": { "type": "string", "format": "date-time" },
624        "example": "Sat, 01 Jan 2025 00:00:00 GMT"
625    })
626}
627
628/// Build the PathItem for `/{spec_name}` (GET show + POST evaluate).
629fn build_spec_path_item_with_show_refs(
630    spec_name: &str,
631    component_names: (&str, &str, &str, &str),
632    rule_names: &[String],
633    explain: bool,
634    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
635) -> Value {
636    let (
637        get_show_component_name,
638        evaluate_response_schema_name,
639        post_body_schema_name,
640        post_form_body_schema_name,
641    ) = component_names;
642    let (effective_from, effective_to) = effective_range;
643
644    let get_show_ref = json!({
645        "$ref": format!("#/components/schemas/{}", get_show_component_name)
646    });
647    let evaluate_schema_ref = json!({
648        "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
649    });
650    let body_ref = json!({
651        "$ref": format!("#/components/schemas/{}", post_body_schema_name)
652    });
653    let form_body_ref = json!({
654        "$ref": format!("#/components/schemas/{}", post_form_body_schema_name)
655    });
656
657    let tag = spec_name.replace('.', "_");
658
659    let rules_example = if rule_names.is_empty() {
660        String::new()
661    } else {
662        rule_names.join(",")
663    };
664
665    let rules_param = json!({
666        "name": "rules",
667        "in": "query",
668        "required": false,
669        "description": "Rules to evaluate, comma-separated. Omit for all.",
670        "schema": { "type": "string" },
671        "example": rules_example
672    });
673
674    let mut get_parameters: Vec<Value> = Vec::new();
675    get_parameters.push(accept_datetime_header_parameter());
676    if explain {
677        get_parameters.push(x_explain_header_parameter());
678    }
679
680    let get_operation_id = format!("get_{}", spec_name);
681    let post_operation_id = format!("post_{}", spec_name);
682
683    let mut post_parameters: Vec<Value> = vec![rules_param];
684    post_parameters.push(accept_datetime_header_parameter());
685    if explain {
686        post_parameters.push(x_explain_header_parameter());
687    }
688
689    let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
690        match dt {
691            Some(d) => Value::String(d.to_string()),
692            None => Value::Null,
693        }
694    };
695
696    json!({
697        "x-effective-from": datetime_or_null(effective_from),
698        "x-effective-to": datetime_or_null(effective_to),
699        "get": {
700            "operationId": get_operation_id,
701            "summary": "Show spec",
702            "tags": [tag],
703            "parameters": get_parameters,
704            "responses": {
705                "200": {
706                    "description": "Spec interface",
707                    "headers": memento_spec_response_headers(),
708                    "content": {
709                        "application/json": {
710                            "schema": get_show_ref
711                        }
712                    }
713                },
714                "400": error_response_schema(),
715                "404": not_found_response_schema()
716            }
717        },
718        "post": {
719            "operationId": post_operation_id,
720            "summary": "Evaluate",
721            "tags": [tag],
722            "parameters": post_parameters,
723            "requestBody": {
724                "required": true,
725                "content": {
726                    // First media type is Scalar's default body encoding.
727                    "application/x-www-form-urlencoded": {
728                        "schema": form_body_ref
729                    },
730                    "application/json": {
731                        "schema": body_ref
732                    }
733                }
734            },
735            "responses": {
736                "200": {
737                    "description": "Evaluation results",
738                    "headers": memento_spec_response_headers(),
739                    "content": {
740                        "application/json": {
741                            "schema": evaluate_schema_ref
742                        }
743                    }
744                },
745                "400": error_response_schema(),
746                "404": not_found_response_schema()
747            }
748        }
749    })
750}
751
752// ---------------------------------------------------------------------------
753// Help and default from Lemma types
754// ---------------------------------------------------------------------------
755
756/// Extract the type's help text for use as description. Always has a value for non-Veto types.
757fn type_help(lemma_type: &LemmaType) -> String {
758    match &lemma_type.specifications {
759        TypeSpecification::Boolean { help, .. } => help.clone(),
760        TypeSpecification::Measure { help, .. } => help.clone(),
761        TypeSpecification::MeasureRange { help, .. } => help.clone(),
762        TypeSpecification::Number { help, .. } => help.clone(),
763        TypeSpecification::NumberRange { help, .. } => help.clone(),
764        TypeSpecification::Ratio { help, .. } => help.clone(),
765        TypeSpecification::RatioRange { help, .. } => help.clone(),
766        TypeSpecification::Text { help, .. } => help.clone(),
767        TypeSpecification::Date { help, .. } => help.clone(),
768        TypeSpecification::DateRange { help, .. } => help.clone(),
769        TypeSpecification::TimeRange { help, .. } => help.clone(),
770        TypeSpecification::Time { help, .. } => help.clone(),
771        TypeSpecification::Veto { .. } => String::new(),
772        TypeSpecification::Undetermined => unreachable!(
773            "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
774        ),
775    }
776}
777
778// ---------------------------------------------------------------------------
779// POST request body schema generation (JSON object keyed by data field names)
780// ---------------------------------------------------------------------------
781
782fn build_post_request_schema(data: &[InputData]) -> Value {
783    let mut properties = Map::new();
784    let mut required = Vec::new();
785
786    for data in data {
787        let default_for_docs = data.prefilled.as_ref().or(data.suggestion.as_ref());
788        properties.insert(
789            data.name.clone(),
790            build_post_property_schema(&data.lemma_type, default_for_docs),
791        );
792        if data.prefilled.is_none() {
793            required.push(Value::String(data.name.clone()));
794        }
795    }
796
797    let mut schema = json!({
798        "type": "object",
799        "properties": Value::Object(properties)
800    });
801    if !required.is_empty() {
802        schema["required"] = Value::Array(required);
803    }
804    schema
805}
806
807fn build_post_property_schema(
808    lemma_type: &LemmaType,
809    data_value: Option<&lemma::RuleResultValue>,
810) -> Value {
811    let mut schema = build_post_type_schema(lemma_type);
812
813    let help = type_help(lemma_type);
814    if !help.is_empty() {
815        schema["description"] = Value::String(help);
816    }
817
818    if let Some(v) = data_value {
819        schema["default"] = Value::String(v.to_literal(lemma_type).display_value());
820    }
821
822    schema
823}
824
825fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
826    match &lemma_type.specifications {
827        TypeSpecification::Text { options, .. } => {
828            let mut schema = json!({ "type": "string" });
829            if !options.is_empty() {
830                schema["enum"] =
831                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
832            }
833            schema
834        }
835        TypeSpecification::Boolean { .. } => {
836            json!({ "type": "boolean" })
837        }
838        _ => json!({ "type": "string" }),
839    }
840}
841
842fn build_post_form_request_schema(data: &[InputData]) -> Value {
843    let mut properties = Map::new();
844    let mut required = Vec::new();
845
846    for data in data {
847        let default_for_docs = data.prefilled.as_ref().or(data.suggestion.as_ref());
848        properties.insert(
849            data.name.clone(),
850            build_post_form_property_schema(&data.lemma_type, default_for_docs),
851        );
852        if data.prefilled.is_none() {
853            required.push(Value::String(data.name.clone()));
854        }
855    }
856
857    let mut schema = json!({
858        "type": "object",
859        "properties": Value::Object(properties)
860    });
861    if !required.is_empty() {
862        schema["required"] = Value::Array(required);
863    }
864    schema
865}
866
867fn build_post_form_property_schema(
868    lemma_type: &LemmaType,
869    data_value: Option<&lemma::RuleResultValue>,
870) -> Value {
871    let mut schema = build_post_form_type_schema(lemma_type);
872
873    let help = type_help(lemma_type);
874    if !help.is_empty() {
875        schema["description"] = Value::String(help);
876    }
877
878    if let Some(v) = data_value {
879        schema["default"] = Value::String(v.to_literal(lemma_type).display_value());
880    }
881
882    schema
883}
884
885fn build_post_form_type_schema(lemma_type: &LemmaType) -> Value {
886    match &lemma_type.specifications {
887        TypeSpecification::Text { options, .. } => {
888            let mut schema = json!({ "type": "string" });
889            if !options.is_empty() {
890                schema["enum"] =
891                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
892            }
893            schema
894        }
895        TypeSpecification::Boolean { .. } => {
896            json!({ "type": "string", "enum": ["true", "false"] })
897        }
898        _ => json!({ "type": "string" }),
899    }
900}
901
902// ---------------------------------------------------------------------------
903// Helpers
904// ---------------------------------------------------------------------------
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use lemma::{DateGranularity, DateTimeValue, SourceType};
910
911    fn create_engine_with_code(code: &str) -> Engine {
912        let mut engine = Engine::new();
913        engine
914            .load([(
915                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
916                code.to_string(),
917            )])
918            .expect("failed to parse lemma code");
919        engine
920    }
921
922    fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
923        let mut engine = Engine::new();
924        for (name, code) in files {
925            engine
926                .load([(
927                    SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
928                    code.to_string(),
929                )])
930                .expect("failed to parse lemma code");
931        }
932        engine
933    }
934
935    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
936        DateTimeValue {
937            year,
938            month,
939            day,
940            hour: 0,
941            minute: 0,
942            second: 0,
943            microsecond: 0,
944            timezone: None,
945            granularity: DateGranularity::Full,
946        }
947    }
948
949    fn has_param(params: &Value, name: &str) -> bool {
950        params
951            .as_array()
952            .map(|a| a.iter().any(|p| p["name"] == name))
953            .unwrap_or(false)
954    }
955
956    // =======================================================================
957    // Basic spec structure (pre-existing, adapted)
958    // =======================================================================
959
960    #[test]
961    fn test_generate_openapi_x_tag_groups() {
962        let engine = create_engine_with_code(
963            "spec pricing
964            data quantity: 10
965            rule total: quantity * 2",
966        );
967        let spec = generate_openapi(&engine, false);
968
969        let groups = spec["x-tagGroups"]
970            .as_array()
971            .expect("x-tagGroups should be array");
972        assert_eq!(groups.len(), 2);
973        assert_eq!(groups[0]["name"], "Overview");
974        assert_eq!(groups[0]["tags"], json!(["Specs"]));
975        assert_eq!(groups[1]["name"], "Specs");
976        assert_eq!(groups[1]["tags"], json!(["pricing"]));
977    }
978
979    #[test]
980    fn test_spec_path_has_get_and_post() {
981        let engine = create_engine_with_code(
982            "spec pricing
983            data quantity: 10
984            rule total: quantity * 2",
985        );
986        let spec = generate_openapi(&engine, false);
987
988        assert!(
989            spec["paths"]["/pricing"].is_object(),
990            "single spec path /pricing"
991        );
992        assert!(spec["paths"]["/pricing"]["get"].is_object());
993        assert!(spec["paths"]["/pricing"]["post"].is_object());
994
995        assert_eq!(
996            spec["paths"]["/pricing"]["get"]["operationId"],
997            "get_pricing"
998        );
999        assert_eq!(
1000            spec["paths"]["/pricing"]["post"]["operationId"],
1001            "post_pricing"
1002        );
1003        assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
1004
1005        let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
1006            .as_array()
1007            .expect("parameters array");
1008        let param_names: Vec<&str> = get_params
1009            .iter()
1010            .map(|p| p["name"].as_str().unwrap())
1011            .collect();
1012        assert!(
1013            !param_names.contains(&"rules"),
1014            "GET must not have rules query param (show is full interface)"
1015        );
1016        let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
1017            .as_array()
1018            .expect("post parameters array");
1019        let post_param_names: Vec<&str> = post_params
1020            .iter()
1021            .map(|p| p["name"].as_str().unwrap())
1022            .collect();
1023        assert!(
1024            post_param_names.contains(&"rules"),
1025            "POST must have rules query param"
1026        );
1027        assert!(
1028            param_names.contains(&"Accept-Datetime"),
1029            "GET must have Accept-Datetime header"
1030        );
1031
1032        let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1033            ["application/json"]["schema"]["$ref"]
1034            .as_str()
1035            .unwrap();
1036        let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1037            ["application/json"]["schema"]["$ref"]
1038            .as_str()
1039            .unwrap();
1040        assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
1041        assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1042        assert_ne!(get_ref, post_ref);
1043
1044        let get_show = &spec["components"]["schemas"]["pricing_get_show"];
1045        assert!(get_show["properties"]["spec"]["type"] == "string");
1046        assert!(get_show["properties"].get("spec_set_id").is_none());
1047        assert!(get_show["properties"]["versions"].is_object());
1048        assert!(get_show["properties"]["start_line"]["type"] == "integer");
1049
1050        let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1051        assert!(h200["headers"]["Memento-Datetime"].is_object());
1052        assert!(h200["headers"]["Vary"].is_object());
1053    }
1054
1055    #[test]
1056    fn suggestion_only_field_is_required_with_json_schema_default() {
1057        let engine = create_engine_with_code(
1058            r#"
1059spec age_check
1060data age: number -> suggest 18
1061rule adult: age >= 18
1062"#,
1063        );
1064        let spec = generate_openapi(&engine, false);
1065        let body = &spec["components"]["schemas"]["age_check_request"];
1066        let required = body["required"]
1067            .as_array()
1068            .expect("required array")
1069            .iter()
1070            .map(|v| v.as_str().unwrap())
1071            .collect::<Vec<_>>();
1072        assert!(
1073            required.contains(&"age"),
1074            "suggest-only fields must stay required, got {required:?}"
1075        );
1076        assert_eq!(body["properties"]["age"]["default"], "18");
1077    }
1078
1079    /// The generated OpenAPI document describes the public spec surface only.
1080    /// Server shell routes (`/openapi.json`, `/health`, `/docs`) are
1081    /// intentionally omitted; consumers must not rely on them for code
1082    /// generation or contract inspection.
1083    #[test]
1084    fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1085        let engine = create_engine_with_code(
1086            "spec pricing
1087            data quantity: 10
1088            rule total: quantity * 2",
1089        );
1090        let spec = generate_openapi(&engine, false);
1091
1092        let paths = spec["paths"].as_object().expect("paths object");
1093        assert!(paths.contains_key("/"));
1094        assert_eq!(paths["/"]["get"]["operationId"], "list");
1095        assert!(!paths.contains_key("/openapi.json"));
1096        assert!(!paths.contains_key("/health"));
1097        assert!(!paths.contains_key("/docs"));
1098        assert!(!paths.contains_key("/schema/pricing"));
1099        assert!(!paths.contains_key("/schema/pricing/{rules}"));
1100        assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1101    }
1102
1103    #[test]
1104    fn test_generate_openapi_explain_adds_x_explain_and_explanation_schema() {
1105        let engine = create_engine_with_code(
1106            "spec pricing
1107            data quantity: 10
1108            rule total: quantity * 2",
1109        );
1110        let spec = generate_openapi(&engine, true);
1111
1112        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1113        assert!(has_param(get_params, "x-explain"));
1114
1115        let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
1116        assert!(rule_result["properties"]["explanation"].is_object());
1117        assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1118        assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1119
1120        let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1121        assert!(evaluate["required"]
1122            .as_array()
1123            .unwrap()
1124            .contains(&json!("spec")));
1125        assert!(evaluate["required"]
1126            .as_array()
1127            .unwrap()
1128            .contains(&json!("effective")));
1129        assert!(evaluate["required"]
1130            .as_array()
1131            .unwrap()
1132            .contains(&json!("results")));
1133        let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1134            .as_str()
1135            .unwrap();
1136        assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
1137    }
1138
1139    #[test]
1140    fn test_generate_openapi_multiple_specs() {
1141        let engine = create_engine_with_files(vec![
1142            (
1143                "pricing.lemma",
1144                "spec pricing
1145                data quantity: 10
1146                rule total: quantity * 2",
1147            ),
1148            (
1149                "shipping.lemma",
1150                "spec shipping
1151                data weight: 5
1152                rule cost: weight * 3",
1153            ),
1154        ]);
1155        let spec = generate_openapi(&engine, false);
1156
1157        assert!(spec["paths"]["/pricing"].is_object());
1158        assert!(spec["paths"]["/shipping"].is_object());
1159    }
1160
1161    #[test]
1162    fn test_nested_spec_path_schema_refs_are_valid() {
1163        let engine = create_engine_with_code(
1164            "spec bc
1165        data x: number
1166        rule result: x",
1167        );
1168        let spec = generate_openapi(&engine, false);
1169
1170        assert!(spec["paths"]["/bc"]["post"].is_object());
1171        let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1172        let content_keys: Vec<&str> = post_content
1173            .as_object()
1174            .expect("requestBody.content object")
1175            .keys()
1176            .map(|k| k.as_str())
1177            .collect();
1178        assert_eq!(
1179            content_keys.first().copied(),
1180            Some("application/x-www-form-urlencoded"),
1181            "form-urlencoded must be first so Scalar docs default to Form URL Encoded"
1182        );
1183        let body_ref = post_content["application/json"]["schema"]["$ref"]
1184            .as_str()
1185            .unwrap();
1186        let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1187            .as_str()
1188            .unwrap();
1189        assert_eq!(body_ref, "#/components/schemas/bc_request");
1190        assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1191        assert!(spec["components"]["schemas"]["bc_request"].is_object());
1192        assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1193        assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1194        assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1195    }
1196
1197    // =======================================================================
1198    // generate_openapi_effective with explicit timestamp
1199    // =======================================================================
1200
1201    #[test]
1202    fn test_generate_openapi_effective_reflects_specific_time() {
1203        let engine = create_engine_with_code(
1204            "spec pricing
1205            data quantity: 10
1206            rule total: quantity * 2",
1207        );
1208        let effective = date(2025, 6, 15);
1209        let spec = generate_openapi_effective(&engine, false, &effective);
1210
1211        assert_eq!(spec["openapi"], "3.1.0");
1212        let version = spec["info"]["version"].as_str().unwrap();
1213        assert!(
1214            version.contains("2025-06-15"),
1215            "version string should contain the effective date, got: {}",
1216            version
1217        );
1218    }
1219
1220    #[test]
1221    fn test_effective_shows_correct_temporal_version_interface() {
1222        let engine = create_engine_with_files(vec![(
1223            "policy.lemma",
1224            r#"
1225spec policy
1226data base: 100
1227rule discount: 10
1228
1229spec policy 2025-06-01
1230data base: 200
1231data premium: boolean
1232rule discount: 20
1233rule surcharge:
1234  5
1235  unless premium then 10
1236"#,
1237        )]);
1238
1239        let before = date(2025, 3, 1);
1240        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1241
1242        assert!(spec_v1["paths"]["/policy"].is_object());
1243        let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1244        let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1245        assert_eq!(
1246            v1_result["discount"]["$ref"].as_str(),
1247            Some("#/components/schemas/LemmaRuleResult"),
1248            "v1 should have discount rule"
1249        );
1250        assert!(
1251            v1_result["surcharge"].is_null(),
1252            "v1 must NOT have surcharge rule"
1253        );
1254        let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1255        assert!(
1256            v1_request["properties"]["premium"].is_null(),
1257            "v1 must NOT have premium data"
1258        );
1259
1260        let after = date(2025, 8, 1);
1261        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1262
1263        let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1264        let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1265        assert!(
1266            v2_result["discount"]["$ref"].is_string(),
1267            "v2 should have discount rule"
1268        );
1269        assert!(
1270            v2_result["surcharge"]["$ref"].is_string(),
1271            "v2 should have surcharge rule"
1272        );
1273        let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1274        assert!(
1275            v2_request["properties"]["premium"].is_object(),
1276            "v2 should have premium data"
1277        );
1278    }
1279
1280    /// Each spec PathItem carries `x-effective-from` and `x-effective-to`
1281    /// describing the half-open `[effective_from, effective_to)` validity
1282    /// range of the version resolved at the document's effective instant.
1283    ///
1284    /// - Earlier row: `x-effective-to` = next row's `effective_from`.
1285    /// - Latest row: `x-effective-to` = `null` (no successor).
1286    /// - Unversioned spec (no declared `effective_from`): both extensions are
1287    ///   `null`.
1288    #[test]
1289    fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1290        let engine = create_engine_with_files(vec![(
1291            "policy.lemma",
1292            r#"
1293spec policy 2025-01-01
1294data base: 10
1295rule total: base
1296
1297spec policy 2026-01-01
1298data base: 99
1299rule total: base
1300"#,
1301        )]);
1302
1303        let at_earlier = date(2025, 6, 1);
1304        let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1305        let earlier_path = &earlier_doc["paths"]["/policy"];
1306        assert_eq!(
1307            earlier_path["x-effective-from"].as_str(),
1308            Some("2025-01-01"),
1309            "earlier version effective_from on PathItem"
1310        );
1311        assert_eq!(
1312            earlier_path["x-effective-to"].as_str(),
1313            Some("2026-01-01"),
1314            "earlier version effective_to equals next version's effective_from"
1315        );
1316
1317        let at_latest = date(2026, 6, 1);
1318        let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1319        let latest_path = &latest_doc["paths"]["/policy"];
1320        assert_eq!(
1321            latest_path["x-effective-from"].as_str(),
1322            Some("2026-01-01"),
1323            "latest version effective_from on PathItem"
1324        );
1325        assert!(
1326            latest_path["x-effective-to"].is_null(),
1327            "latest version has no successor; x-effective-to must be null: {latest_path}"
1328        );
1329    }
1330
1331    /// Unversioned specs (no declared `effective_from`) have both extensions
1332    /// serialised as JSON `null`, not omitted.
1333    #[test]
1334    fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1335        let engine = create_engine_with_code(
1336            "spec pricing
1337            data quantity: 10
1338            rule total: quantity * 2",
1339        );
1340        let document = generate_openapi(&engine, false);
1341        let path_item = &document["paths"]["/pricing"];
1342        assert!(
1343            path_item["x-effective-from"].is_null(),
1344            "unversioned spec: x-effective-from must be null: {path_item}"
1345        );
1346        assert!(
1347            path_item["x-effective-to"].is_null(),
1348            "unversioned spec: x-effective-to must be null: {path_item}"
1349        );
1350    }
1351
1352    // =======================================================================
1353    // temporal_api_sources
1354    // =======================================================================
1355
1356    #[test]
1357    fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1358        let engine = create_engine_with_files(vec![(
1359            "policy.lemma",
1360            r#"
1361spec policy
1362data base: 100
1363rule discount: 10
1364
1365spec policy 2025-06-01
1366data base: 200
1367rule discount: 20
1368"#,
1369        )]);
1370
1371        let sources = temporal_api_sources(&engine);
1372
1373        assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1374
1375        assert_eq!(sources[0].title, "Now");
1376        assert_eq!(sources[0].slug, NOW_SLUG);
1377        assert_eq!(sources[0].url, "/openapi.json");
1378
1379        assert_eq!(sources[1].title, "Effective 2025-06-01");
1380        assert_eq!(sources[1].slug, "2025-06-01");
1381        assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1382    }
1383
1384    #[test]
1385    fn test_temporal_sources_multiple_specs_merged_boundaries() {
1386        let engine = create_engine_with_files(vec![
1387            (
1388                "policy.lemma",
1389                r#"
1390spec policy
1391data base: 100
1392rule discount: 10
1393
1394spec policy 2025-06-01
1395data base: 200
1396rule discount: 20
1397"#,
1398            ),
1399            (
1400                "rates.lemma",
1401                r#"
1402spec rates
1403data rate: 5
1404rule total: rate * 2
1405
1406spec rates 2025-03-01
1407data rate: 7
1408rule total: rate * 2
1409
1410spec rates 2025-06-01
1411data rate: 9
1412rule total: rate * 2
1413"#,
1414            ),
1415        ]);
1416
1417        let sources = temporal_api_sources(&engine);
1418
1419        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1420        assert!(
1421            slugs.contains(&"2025-03-01"),
1422            "should contain rates boundary"
1423        );
1424        assert!(
1425            slugs.contains(&"2025-06-01"),
1426            "should contain shared boundary"
1427        );
1428        assert!(slugs.contains(&NOW_SLUG), "should contain now");
1429        assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1430    }
1431
1432    #[test]
1433    fn test_temporal_sources_ordered_chronologically() {
1434        let engine = create_engine_with_files(vec![(
1435            "policy.lemma",
1436            r#"
1437spec policy
1438data base: 100
1439rule discount: 10
1440
1441spec policy 2024-01-01
1442data base: 50
1443rule discount: 5
1444
1445spec policy 2025-06-01
1446data base: 200
1447rule discount: 20
1448"#,
1449        )]);
1450
1451        let sources = temporal_api_sources(&engine);
1452        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1453        assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1454    }
1455
1456    // =======================================================================
1457    // Type-specific parameter tests
1458    // =======================================================================
1459
1460    #[test]
1461    fn test_post_schema_text_with_options_has_enum() {
1462        let engine = create_engine_with_code(
1463            "spec test
1464            data product: text -> option \"A\" -> option \"B\"
1465            rule result: product",
1466        );
1467        let spec = generate_openapi(&engine, false);
1468
1469        let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1470        assert!(product_prop["enum"].is_array());
1471        let enums = product_prop["enum"].as_array().unwrap();
1472        assert_eq!(enums.len(), 2);
1473        assert_eq!(enums[0], "A");
1474        assert_eq!(enums[1], "B");
1475    }
1476
1477    #[test]
1478    fn test_post_schema_boolean_is_json_boolean() {
1479        let engine = create_engine_with_code(
1480            "spec test
1481            data is_active: boolean
1482            rule result: is_active",
1483        );
1484        let spec = generate_openapi(&engine, false);
1485
1486        let schema = &spec["components"]["schemas"]["test_request"];
1487        let is_active = &schema["properties"]["is_active"];
1488        assert_eq!(is_active["type"], "boolean");
1489
1490        let form_schema = &spec["components"]["schemas"]["test_form_request"];
1491        let form_is_active = &form_schema["properties"]["is_active"];
1492        assert_eq!(form_is_active["type"], "string");
1493        assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1494    }
1495
1496    #[test]
1497    fn test_post_schema_number_is_string() {
1498        let engine = create_engine_with_code(
1499            "spec test
1500            data quantity: number
1501            rule result: quantity",
1502        );
1503        let spec = generate_openapi(&engine, false);
1504
1505        let schema = &spec["components"]["schemas"]["test_request"];
1506        assert_eq!(schema["properties"]["quantity"]["type"], "string");
1507    }
1508
1509    #[test]
1510    fn test_data_with_default_is_not_required() {
1511        let engine = create_engine_with_code(
1512            "spec test
1513            data quantity: 10
1514            data name: text
1515            rule result: quantity
1516            rule label: name",
1517        );
1518        let spec = generate_openapi(&engine, false);
1519
1520        let schema = &spec["components"]["schemas"]["test_request"];
1521        let required = schema["required"]
1522            .as_array()
1523            .expect("required should be array");
1524
1525        assert!(required.contains(&Value::String("name".to_string())));
1526        assert!(!required.contains(&Value::String("quantity".to_string())));
1527    }
1528
1529    #[test]
1530    fn test_help_and_default_in_openapi() {
1531        let engine = create_engine_with_code(
1532            r#"spec test
1533data quantity: number -> help "Number of items to order" -> suggest 10
1534data active: boolean -> help "Whether the feature is enabled" -> suggest true
1535rule result:
1536  quantity
1537  unless active then 0
1538"#,
1539        );
1540        let spec = generate_openapi(&engine, false);
1541
1542        let req_schema = &spec["components"]["schemas"]["test_request"];
1543        assert!(req_schema["properties"]["quantity"]["description"]
1544            .as_str()
1545            .unwrap()
1546            .contains("Number of items to order"));
1547        assert_eq!(
1548            req_schema["properties"]["quantity"]["default"]
1549                .as_str()
1550                .unwrap(),
1551            "10"
1552        );
1553        assert!(req_schema["properties"]["active"]["description"]
1554            .as_str()
1555            .unwrap()
1556            .contains("Whether the feature is enabled"));
1557        assert_eq!(
1558            req_schema["properties"]["active"]["default"]
1559                .as_str()
1560                .unwrap(),
1561            "true"
1562        );
1563        let required = req_schema["required"]
1564            .as_array()
1565            .expect("required array")
1566            .iter()
1567            .map(|v| v.as_str().unwrap())
1568            .collect::<Vec<_>>();
1569        assert!(required.contains(&"quantity"));
1570        assert!(required.contains(&"active"));
1571    }
1572}