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, EffectiveDate, Engine, LemmaSpec, LemmaType, TypeSpecification};
19use serde_json::{json, Map, Value};
20use std::sync::Arc;
21
22/// Query slug for the default temporal view (request-time instant). OpenAPI URLs use no `?effective=`.
23pub const NOW_SLUG: &str = "now";
24
25/// A single Scalar API reference source entry.
26///
27/// Each temporal version boundary gets its own source so Scalar renders a
28/// version switcher in the UI.
29#[derive(Debug, Clone, serde::Serialize)]
30pub struct ApiSource {
31    pub title: String,
32    pub slug: String,
33    pub url: String,
34}
35
36/// Compute the list of Scalar multi-source entries for temporal versioning.
37///
38/// Returns one [`ApiSource`] per distinct temporal version boundary across all
39/// loaded specs, plus one **now** source (slug [`NOW_SLUG`]) that uses no `effective`
40/// query (evaluation instant = request time). That entry is first (Scalar default),
41/// then boundaries in descending chronological order (newest first).
42///
43/// If there are no temporal version boundaries (all specs are unversioned),
44/// returns a single **now** entry.
45pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
46    let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
47        std::collections::BTreeSet::new();
48
49    for repo in engine.list() {
50        for ss in &repo.specs {
51            for (spec, _, _) in ss.iter_with_ranges() {
52                if let Some(af) = spec.effective_from() {
53                    all_boundaries.insert(af.clone());
54                }
55            }
56        }
57    }
58
59    if all_boundaries.is_empty() {
60        return vec![ApiSource {
61            title: "Now".to_string(),
62            slug: NOW_SLUG.to_string(),
63            url: "/openapi.json".to_string(),
64        }];
65    }
66
67    let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
68
69    sources.push(ApiSource {
70        title: "Now".to_string(),
71        slug: NOW_SLUG.to_string(),
72        url: "/openapi.json".to_string(),
73    });
74
75    for boundary in all_boundaries.iter().rev() {
76        let label = boundary.to_string();
77        sources.push(ApiSource {
78            title: format!("Effective {}", label),
79            slug: label.clone(),
80            url: format!("/openapi.json?effective={}", label),
81        });
82    }
83
84    sources
85}
86
87/// Generate a complete OpenAPI 3.1 specification using the current time.
88///
89/// Convenience wrapper around [`generate_openapi_effective`]. The document reflects
90/// only the specs and interfaces active at `DateTimeValue::now()`.
91pub fn generate_openapi(engine: &Engine, explanations_enabled: bool) -> Value {
92    generate_openapi_effective(engine, explanations_enabled, &DateTimeValue::now())
93}
94
95/// Generate a complete OpenAPI 3.1 specification for a specific point in time.
96///
97/// The specification includes:
98/// - `GET /` — list loaded specs (name, data/rule counts)
99/// - `/{spec_set_id}` GET (schema: `spec_set_id`, `effective_from`, `data`, `rules`, `meta`, `versions`) and
100///   POST (evaluate: envelope `spec`, `effective`, `result`) with optional `Accept-Datetime` header
101/// - `?rules=` on both methods to scope outputs
102/// - `x-effective-from` / `x-effective-to` vendor extensions on each PathItem
103///   exposing the half-open `[effective_from, effective_to)` range of the version
104///   resolved at the document's effective instant (both `null` when unbounded)
105///
106/// CLI `lemma server` also exposes shell routes (`/openapi.json`, `/health`, `/docs`) that are
107/// intentionally omitted from the generated document.
108///
109/// When `explanations_enabled` is true, the document adds the `x-explanations` header parameter
110/// to evaluation operations and describes the optional `explanation` field on rule results.
111pub fn generate_openapi_effective(
112    engine: &Engine,
113    explanations_enabled: bool,
114    effective: &DateTimeValue,
115) -> Value {
116    let mut paths = Map::new();
117    let mut components_schemas = Map::new();
118
119    components_schemas.insert(
120        "LemmaRuleResult".to_string(),
121        build_rule_result_schema(explanations_enabled),
122    );
123
124    let workspace = engine.get_workspace();
125    let effective_instant = EffectiveDate::DateTimeValue(effective.clone());
126
127    let active_specs: Vec<(Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> =
128        workspace
129            .specs
130            .iter()
131            .filter_map(|ss| {
132                ss.spec_at(&effective_instant).map(|spec| {
133                    let (from, to) = ss.effective_range(&spec);
134                    (spec, from, to)
135                })
136            })
137            .collect();
138
139    let unique_spec_names: Vec<String> = active_specs
140        .iter()
141        .map(|(s, _, _)| s.name.clone())
142        .collect();
143
144    paths.insert(
145        "/".to_string(),
146        index_path_item(&unique_spec_names, engine, effective),
147    );
148
149    for (spec_arc, spec_effective_from, spec_effective_to) in &active_specs {
150        let spec_name = &spec_arc.name;
151        if let Ok(plan) = engine.get_plan(None, spec_name, Some(effective)) {
152            let schema = plan.schema(&lemma::DataOverlay::default());
153            let artifacts = build_spec_openapi_artifacts(
154                spec_name,
155                &schema,
156                (spec_effective_from.as_ref(), spec_effective_to.as_ref()),
157                explanations_enabled,
158            );
159            paths.insert(format!("/{spec_name}"), artifacts.path_item);
160            for (name, schema_value) in artifacts.component_schemas {
161                components_schemas.insert(name, schema_value);
162            }
163        }
164    }
165
166    let mut tags = vec![json!({
167        "name": "Specs",
168        "description": "Simple API to retrieve the list of Lemma specs"
169    })];
170    for spec_name in &unique_spec_names {
171        let safe_tag = spec_name.replace('.', "_");
172        tags.push(json!({
173            "name": safe_tag,
174            "x-displayName": spec_name,
175            "description": format!("GET schema or POST evaluate for spec '{}'. Use ?rules= to scope.", spec_name)
176        }));
177    }
178
179    let spec_tags: Vec<Value> = unique_spec_names
180        .iter()
181        .map(|n| Value::String(n.replace('.', "_")))
182        .collect();
183
184    let tag_groups = vec![
185        json!({ "name": "Overview", "tags": ["Specs"] }),
186        json!({ "name": "Specs", "tags": spec_tags }),
187    ];
188
189    let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
190
191    json!({
192        "openapi": "3.1.0",
193        "info": {
194            "title": "Lemma API",
195            "description": "Lemma is a declarative language for expressing business logic — pricing rules, tax calculations, eligibility criteria, contracts, and policies. Learn more at [LemmaBase.com](https://lemmabase.com).\n\n**Temporal resolution.** `GET /{spec}` describes **version boundaries**: each entry in `versions` carries the half-open `[effective_from, effective_to)` validity range of a temporal version. `POST /{spec}` treats the request's effective instant (from the `Accept-Datetime` header, or the evaluation envelope's `effective` field) as the **evaluation instant** used to pick the active version and compute the result.",
196            "version": version_label
197        },
198        "tags": tags,
199        "x-tagGroups": tag_groups,
200        "paths": Value::Object(paths),
201        "components": {
202            "schemas": Value::Object(components_schemas)
203        }
204    })
205}
206
207/// Information about a single input data for OpenAPI generation.
208struct InputData {
209    /// The data name as it appears in the API (e.g. "measure", "is_member").
210    name: String,
211    /// The resolved Lemma type for this data.
212    lemma_type: LemmaType,
213    /// Spec literal or literal `with` binding.
214    prefilled: Option<lemma::LiteralValue>,
215    /// Caller overlay when schema was built with supplied values.
216    supplied: Option<lemma::LiteralValue>,
217    /// Suggestion from `-> default ...` (evaluator applies it when no overlay value is provided).
218    suggestion_default: Option<lemma::LiteralValue>,
219}
220
221/// Collect all local input data from a pre-built schema.
222///
223/// Only includes data local to the spec (no dot-separated cross-spec
224/// paths like `calc.price`). Already sorted alphabetically by `schema()`.
225fn collect_input_data_from_schema(schema: &lemma::SpecSchema) -> Vec<InputData> {
226    schema
227        .data
228        .iter()
229        .filter(|(name, _)| !name.contains('.'))
230        .map(|(name, entry)| InputData {
231            name: name.clone(),
232            lemma_type: entry.lemma_type.clone(),
233            prefilled: entry.prefilled.clone(),
234            supplied: entry.supplied.clone(),
235            suggestion_default: entry.default.clone(),
236        })
237        .collect()
238}
239
240// ---------------------------------------------------------------------------
241// Index path (list specs)
242// ---------------------------------------------------------------------------
243
244fn index_path_item(spec_names: &[String], engine: &Engine, effective: &DateTimeValue) -> Value {
245    let spec_items: Vec<Value> = spec_names
246        .iter()
247        .map(|name| match engine.schema(None, name, Some(effective)) {
248            Ok(s) => {
249                let data_count = s.data.keys().filter(|n| !n.contains('.')).count();
250                let rules_count = s.rules.len();
251                json!({
252                    "name": name,
253                    "data": data_count,
254                    "rules": rules_count
255                })
256            }
257            Err(e) => json!({
258                "name": name,
259                "schema_error": true,
260                "message": e.to_string()
261            }),
262        })
263        .collect();
264
265    json!({
266        "get": {
267            "operationId": "list",
268            "summary": "List all available specs",
269            "tags": ["Specs"],
270            "responses": {
271                "200": {
272                    "description": "List of loaded Lemma specs",
273                    "content": {
274                        "application/json": {
275                            "schema": {
276                                "type": "array",
277                                "items": {
278                                    "type": "object",
279                                    "properties": {
280                                        "name": { "type": "string" },
281                                        "data": { "type": "integer" },
282                                        "rules": { "type": "integer" },
283                                        "schema_error": { "type": "boolean" },
284                                        "message": { "type": "string" }
285                                    },
286                                    "required": ["name"]
287                                }
288                            },
289                            "example": spec_items
290                        }
291                    }
292                }
293            }
294        }
295    })
296}
297
298// ---------------------------------------------------------------------------
299// Shared response schemas
300// ---------------------------------------------------------------------------
301
302fn error_response_schema() -> Value {
303    json!({
304        "description": "Evaluation error",
305        "content": {
306            "application/json": {
307                "schema": {
308                    "type": "object",
309                    "properties": {
310                        "error": { "type": "string" }
311                    },
312                    "required": ["error"]
313                }
314            }
315        }
316    })
317}
318
319fn not_found_response_schema() -> Value {
320    json!({
321        "description": "Spec not found",
322        "content": {
323            "application/json": {
324                "schema": {
325                    "type": "object",
326                    "properties": {
327                        "error": { "type": "string" }
328                    },
329                    "required": ["error"]
330                }
331            }
332        }
333    })
334}
335
336fn memento_spec_response_headers() -> Value {
337    json!({
338        "Memento-Datetime": {
339            "description": "RFC 7089: datetime of the resolved spec version (absent for unversioned specs)",
340            "schema": { "type": "string" }
341        },
342        "Vary": {
343            "description": "Indicates negotiation on Accept-Datetime",
344            "schema": { "type": "string", "example": "Accept-Datetime" }
345        }
346    })
347}
348
349/// GET `/{spec}` body: matches [cli::server::GetSpecResponse].
350fn build_get_schema_response() -> Value {
351    json!({
352        "type": "object",
353        "required": ["spec_set_id", "data", "rules", "meta", "versions"],
354        "properties": {
355            "spec_set_id": {
356                "type": "string",
357                "description": "Spec set identifier (path segments, e.g. org/product/pricing)"
358            },
359            "effective_from": {
360                "type": ["string", "null"],
361                "description": "Effective-from of the resolved temporal version, if any"
362            },
363            "data": {
364                "type": "object",
365                "description": "Input data names mapped to type metadata and optional defaults",
366                "additionalProperties": true
367            },
368            "rules": {
369                "type": "object",
370                "description": "Rule names mapped to result types (scoped by ?rules= when provided)",
371                "additionalProperties": true
372            },
373            "meta": {
374                "type": "object",
375                "description": "Spec metadata key/value pairs",
376                "additionalProperties": true
377            },
378            "versions": {
379                "type": "array",
380                "description": "All loaded temporal versions for this spec name, each with a half-open [effective_from, effective_to) range",
381                "items": {
382                    "type": "object",
383                    "required": ["effective_from", "effective_to"],
384                    "properties": {
385                        "effective_from": {
386                            "type": ["string", "null"],
387                            "description": "Start of validity for this version; null when unbounded (no earlier version exists)"
388                        },
389                        "effective_to": {
390                            "type": ["string", "null"],
391                            "description": "Exclusive end of validity (same instant as the next version's effective_from); null when this is the latest version and has no successor"
392                        }
393                    }
394                }
395            }
396        }
397    })
398}
399
400/// Single rule output: flat fields matching engine [`lemma::RuleResult`].
401fn build_rule_result_schema(explanations_enabled: bool) -> Value {
402    let mut explanation = json!({
403        "type": "object",
404        "description": "Structured explanation tree when explanations are enabled"
405    });
406    if explanations_enabled {
407        explanation["description"] = Value::String(
408            "Structured explanation tree (present when x-explanations is sent and server uses --explanations)"
409                .to_string(),
410        );
411    }
412
413    json!({
414        "type": "object",
415        "required": ["vetoed", "rule_type"],
416        "properties": {
417            "vetoed": { "type": "boolean" },
418            "display": {
419                "type": "string",
420                "description": "Human-readable formatted value when not vetoed"
421            },
422            "veto_reason": { "type": "string" },
423            "rule_type": {
424                "type": "string",
425                "description": "Result type name (e.g. number, boolean, money)"
426            },
427            "measure": {
428                "type": "object",
429                "additionalProperties": { "type": "string" },
430                "description": "Named measure rule: unit name to magnitude string"
431            },
432            "ratio": {
433                "type": "object",
434                "additionalProperties": { "type": "string" },
435                "description": "Named ratio rule: unit name to magnitude string"
436            },
437            "number": { "type": "string" },
438            "boolean": { "type": "boolean" },
439            "text": { "type": "string" },
440            "date": { "type": "object" },
441            "time": { "type": "object" },
442            "calendar": {
443                "type": "object",
444                "properties": {
445                    "value": { "type": "string" },
446                    "unit": { "type": "string" }
447                }
448            },
449            "range": { "type": "object" },
450            "explanation": explanation
451        }
452    })
453}
454
455/// POST evaluate body: matches engine [`lemma::Response`] JSON shape.
456fn build_evaluate_response_schema(schema: &lemma::SpecSchema, rule_names: &[String]) -> Value {
457    let mut result_props = Map::new();
458    for rule_name in rule_names {
459        if schema.rules.contains_key(rule_name) {
460            result_props.insert(
461                rule_name.clone(),
462                json!({
463                    "$ref": "#/components/schemas/LemmaRuleResult"
464                }),
465            );
466        }
467    }
468
469    json!({
470        "type": "object",
471        "required": ["spec", "effective", "results"],
472        "properties": {
473            "spec": {
474                "type": "string",
475                "description": "Spec set id that was evaluated"
476            },
477            "effective": {
478                "type": "string",
479                "description": "Evaluation instant used for temporal resolution (matches request instant unless overridden)"
480            },
481            "results": {
482                "type": "object",
483                "description": "Rule names to evaluation results (definition order in response; keys match ?rules= filter when set)",
484                "properties": Value::Object(result_props)
485            },
486            "data": {
487                "type": "array",
488                "description": "Data entries in effect for the evaluated rules when explanations are enabled"
489            }
490        }
491    })
492}
493
494// ---------------------------------------------------------------------------
495// Spec path items
496// ---------------------------------------------------------------------------
497
498struct SpecOpenApiArtifacts {
499    path_item: Value,
500    component_schemas: Map<String, Value>,
501}
502
503fn spec_component_schema_names(spec_name: &str) -> (String, String, String, String) {
504    let safe_name = spec_name.replace('.', "_");
505    (
506        format!("{safe_name}_get_response"),
507        format!("{safe_name}_evaluate_response"),
508        format!("{safe_name}_request"),
509        format!("{safe_name}_form_request"),
510    )
511}
512
513/// Build the PathItem and per-spec component schemas for `/{spec_name}`.
514///
515/// `effective_range` is the half-open `[effective_from, effective_to)`
516/// validity range of the temporal version resolved at the OpenAPI document's
517/// effective instant. Both bounds are emitted as the `x-effective-from` /
518/// `x-effective-to` vendor extensions on the PathItem so tooling can render
519/// the active version's window without having to inspect the `versions`
520/// array. `None` in either position (unbounded start for the first row,
521/// unbounded end for the latest row) is serialised as JSON `null`.
522fn build_spec_openapi_artifacts(
523    spec_name: &str,
524    schema: &lemma::SpecSchema,
525    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
526    explanations_enabled: bool,
527) -> SpecOpenApiArtifacts {
528    let data = collect_input_data_from_schema(schema);
529    let rule_names: Vec<String> = schema.rules.keys().cloned().collect();
530    let (
531        get_response_schema_name,
532        evaluate_response_schema_name,
533        post_body_schema_name,
534        post_form_body_schema_name,
535    ) = spec_component_schema_names(spec_name);
536
537    let mut component_schemas = Map::new();
538    component_schemas.insert(
539        get_response_schema_name.clone(),
540        build_get_schema_response(),
541    );
542    component_schemas.insert(
543        evaluate_response_schema_name.clone(),
544        build_evaluate_response_schema(schema, &rule_names),
545    );
546    component_schemas.insert(
547        post_body_schema_name.clone(),
548        build_post_request_schema(&data),
549    );
550    component_schemas.insert(
551        post_form_body_schema_name.clone(),
552        build_post_form_request_schema(&data),
553    );
554
555    let path_item = build_spec_path_item_with_schema_refs(
556        spec_name,
557        (
558            &get_response_schema_name,
559            &evaluate_response_schema_name,
560            &post_body_schema_name,
561            &post_form_body_schema_name,
562        ),
563        &rule_names,
564        explanations_enabled,
565        effective_range,
566    );
567
568    SpecOpenApiArtifacts {
569        path_item,
570        component_schemas,
571    }
572}
573
574fn x_explanations_header_parameter() -> Value {
575    json!({
576        "name": "x-explanations",
577        "in": "header",
578        "required": false,
579        "description": "Set to request explanation objects in the response (server must be started with --explanations)",
580        "schema": { "type": "string", "default": "true" }
581    })
582}
583
584fn accept_datetime_header_parameter() -> Value {
585    json!({
586        "name": "Accept-Datetime",
587        "in": "header",
588        "required": false,
589        "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit to evaluate at the request instant (now).",
590        "schema": { "type": "string", "format": "date-time" },
591        "example": "Sat, 01 Jan 2025 00:00:00 GMT"
592    })
593}
594
595/// Build the PathItem for `/{spec_name}` (GET schema + POST evaluate).
596fn build_spec_path_item_with_schema_refs(
597    spec_name: &str,
598    schema_names: (&str, &str, &str, &str),
599    rule_names: &[String],
600    explanations_enabled: bool,
601    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
602) -> Value {
603    let (
604        get_response_schema_name,
605        evaluate_response_schema_name,
606        post_body_schema_name,
607        post_form_body_schema_name,
608    ) = schema_names;
609    let (effective_from, effective_to) = effective_range;
610
611    let get_schema_ref = json!({
612        "$ref": format!("#/components/schemas/{}", get_response_schema_name)
613    });
614    let evaluate_schema_ref = json!({
615        "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
616    });
617    let body_ref = json!({
618        "$ref": format!("#/components/schemas/{}", post_body_schema_name)
619    });
620    let form_body_ref = json!({
621        "$ref": format!("#/components/schemas/{}", post_form_body_schema_name)
622    });
623
624    let tag = spec_name.replace('.', "_");
625
626    let rules_example = if rule_names.is_empty() {
627        String::new()
628    } else {
629        rule_names.join(",")
630    };
631
632    let rules_param = json!({
633        "name": "rules",
634        "in": "query",
635        "required": false,
636        "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
637        "schema": { "type": "string" },
638        "example": rules_example
639    });
640
641    let mut get_parameters: Vec<Value> = vec![rules_param.clone()];
642    get_parameters.push(accept_datetime_header_parameter());
643    if explanations_enabled {
644        get_parameters.push(x_explanations_header_parameter());
645    }
646
647    let get_summary = "Schema of resolved version (spec, data, rules, meta, versions)".to_string();
648    let post_summary = "Evaluate".to_string();
649    let get_operation_id = format!("get_{}", spec_name);
650    let post_operation_id = format!("post_{}", spec_name);
651
652    let mut post_parameters: Vec<Value> = vec![rules_param];
653    post_parameters.push(accept_datetime_header_parameter());
654    if explanations_enabled {
655        post_parameters.push(x_explanations_header_parameter());
656    }
657
658    let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
659        match dt {
660            Some(d) => Value::String(d.to_string()),
661            None => Value::Null,
662        }
663    };
664
665    json!({
666        "x-effective-from": datetime_or_null(effective_from),
667        "x-effective-to": datetime_or_null(effective_to),
668        "get": {
669            "operationId": get_operation_id,
670            "summary": get_summary,
671            "tags": [tag],
672            "parameters": get_parameters,
673            "responses": {
674                "200": {
675                    "description": "Schema of resolved version (spec_set_id, effective_from, data, rules, meta, versions).",
676                    "headers": memento_spec_response_headers(),
677                    "content": {
678                        "application/json": {
679                            "schema": get_schema_ref
680                        }
681                    }
682                },
683                "400": error_response_schema(),
684                "404": not_found_response_schema()
685            }
686        },
687        "post": {
688            "operationId": post_operation_id,
689            "summary": post_summary,
690            "tags": [tag],
691            "parameters": post_parameters,
692            "requestBody": {
693                "required": true,
694                "content": {
695                    "application/json": {
696                        "schema": body_ref
697                    },
698                    "application/x-www-form-urlencoded": {
699                        "schema": form_body_ref
700                    }
701                }
702            },
703            "responses": {
704                "200": {
705                    "description": "Evaluation envelope: spec, effective, result (per-rule RuleResultJson).",
706                    "headers": memento_spec_response_headers(),
707                    "content": {
708                        "application/json": {
709                            "schema": evaluate_schema_ref
710                        }
711                    }
712                },
713                "400": error_response_schema(),
714                "404": not_found_response_schema()
715            }
716        }
717    })
718}
719
720// ---------------------------------------------------------------------------
721// Help and default from Lemma types
722// ---------------------------------------------------------------------------
723
724/// Extract the type's help text for use as description. Always has a value for non-Veto types.
725fn type_help(lemma_type: &LemmaType) -> String {
726    match &lemma_type.specifications {
727        TypeSpecification::Boolean { help, .. } => help.clone(),
728        TypeSpecification::Measure { help, .. } => help.clone(),
729        TypeSpecification::MeasureRange { help, .. } => help.clone(),
730        TypeSpecification::Number { help, .. } => help.clone(),
731        TypeSpecification::NumberRange { help, .. } => help.clone(),
732        TypeSpecification::Ratio { help, .. } => help.clone(),
733        TypeSpecification::RatioRange { help, .. } => help.clone(),
734        TypeSpecification::Text { help, .. } => help.clone(),
735        TypeSpecification::Date { help, .. } => help.clone(),
736        TypeSpecification::DateRange { help, .. } => help.clone(),
737        TypeSpecification::TimeRange { help, .. } => help.clone(),
738        TypeSpecification::Time { help, .. } => help.clone(),
739        TypeSpecification::Veto { .. } => String::new(),
740        TypeSpecification::Undetermined => unreachable!(
741            "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
742        ),
743    }
744}
745
746// ---------------------------------------------------------------------------
747// POST request body schema generation (JSON object keyed by data field names)
748// ---------------------------------------------------------------------------
749
750fn build_post_request_schema(data: &[InputData]) -> Value {
751    let mut properties = Map::new();
752    let mut required = Vec::new();
753
754    for data in data {
755        let default_for_docs = data
756            .prefilled
757            .as_ref()
758            .or(data.supplied.as_ref())
759            .or(data.suggestion_default.as_ref());
760        properties.insert(
761            data.name.clone(),
762            build_post_property_schema(&data.lemma_type, default_for_docs),
763        );
764        if data.prefilled.is_none() && data.supplied.is_none() && data.suggestion_default.is_none()
765        {
766            required.push(Value::String(data.name.clone()));
767        }
768    }
769
770    let mut schema = json!({
771        "type": "object",
772        "properties": Value::Object(properties)
773    });
774    if !required.is_empty() {
775        schema["required"] = Value::Array(required);
776    }
777    schema
778}
779
780fn build_post_property_schema(
781    lemma_type: &LemmaType,
782    data_value: Option<&lemma::LiteralValue>,
783) -> Value {
784    let mut schema = build_post_type_schema(lemma_type);
785
786    let help = type_help(lemma_type);
787    if !help.is_empty() {
788        schema["description"] = Value::String(help);
789    }
790
791    if let Some(v) = data_value {
792        schema["default"] = Value::String(v.display_value());
793    }
794
795    schema
796}
797
798fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
799    match &lemma_type.specifications {
800        TypeSpecification::Text { options, .. } => {
801            let mut schema = json!({ "type": "string" });
802            if !options.is_empty() {
803                schema["enum"] =
804                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
805            }
806            schema
807        }
808        TypeSpecification::Boolean { .. } => {
809            json!({ "type": "boolean" })
810        }
811        _ => json!({ "type": "string" }),
812    }
813}
814
815fn build_post_form_request_schema(data: &[InputData]) -> Value {
816    let mut properties = Map::new();
817    let mut required = Vec::new();
818
819    for data in data {
820        let default_for_docs = data
821            .prefilled
822            .as_ref()
823            .or(data.supplied.as_ref())
824            .or(data.suggestion_default.as_ref());
825        properties.insert(
826            data.name.clone(),
827            build_post_form_property_schema(&data.lemma_type, default_for_docs),
828        );
829        if data.prefilled.is_none() && data.supplied.is_none() && data.suggestion_default.is_none()
830        {
831            required.push(Value::String(data.name.clone()));
832        }
833    }
834
835    let mut schema = json!({
836        "type": "object",
837        "properties": Value::Object(properties)
838    });
839    if !required.is_empty() {
840        schema["required"] = Value::Array(required);
841    }
842    schema
843}
844
845fn build_post_form_property_schema(
846    lemma_type: &LemmaType,
847    data_value: Option<&lemma::LiteralValue>,
848) -> Value {
849    let mut schema = build_post_form_type_schema(lemma_type);
850
851    let help = type_help(lemma_type);
852    if !help.is_empty() {
853        schema["description"] = Value::String(help);
854    }
855
856    if let Some(v) = data_value {
857        schema["default"] = Value::String(v.display_value());
858    }
859
860    schema
861}
862
863fn build_post_form_type_schema(lemma_type: &LemmaType) -> Value {
864    match &lemma_type.specifications {
865        TypeSpecification::Text { options, .. } => {
866            let mut schema = json!({ "type": "string" });
867            if !options.is_empty() {
868                schema["enum"] =
869                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
870            }
871            schema
872        }
873        TypeSpecification::Boolean { .. } => {
874            json!({ "type": "string", "enum": ["true", "false"] })
875        }
876        _ => json!({ "type": "string" }),
877    }
878}
879
880// ---------------------------------------------------------------------------
881// Helpers
882// ---------------------------------------------------------------------------
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use lemma::{DateGranularity, DateTimeValue, SourceType};
888
889    fn create_engine_with_code(code: &str) -> Engine {
890        let mut engine = Engine::new();
891        engine
892            .load(
893                code,
894                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
895            )
896            .expect("failed to parse lemma code");
897        engine
898    }
899
900    fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
901        let mut engine = Engine::new();
902        for (name, code) in files {
903            engine
904                .load(
905                    code,
906                    SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
907                )
908                .expect("failed to parse lemma code");
909        }
910        engine
911    }
912
913    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
914        DateTimeValue {
915            year,
916            month,
917            day,
918            hour: 0,
919            minute: 0,
920            second: 0,
921            microsecond: 0,
922            timezone: None,
923            granularity: DateGranularity::Full,
924        }
925    }
926
927    fn has_param(params: &Value, name: &str) -> bool {
928        params
929            .as_array()
930            .map(|a| a.iter().any(|p| p["name"] == name))
931            .unwrap_or(false)
932    }
933
934    // =======================================================================
935    // Basic spec structure (pre-existing, adapted)
936    // =======================================================================
937
938    #[test]
939    fn test_generate_openapi_x_tag_groups() {
940        let engine = create_engine_with_code(
941            "spec pricing
942            data quantity: 10
943            rule total: quantity * 2",
944        );
945        let spec = generate_openapi(&engine, false);
946
947        let groups = spec["x-tagGroups"]
948            .as_array()
949            .expect("x-tagGroups should be array");
950        assert_eq!(groups.len(), 2);
951        assert_eq!(groups[0]["name"], "Overview");
952        assert_eq!(groups[0]["tags"], json!(["Specs"]));
953        assert_eq!(groups[1]["name"], "Specs");
954        assert_eq!(groups[1]["tags"], json!(["pricing"]));
955    }
956
957    #[test]
958    fn test_spec_path_has_get_and_post() {
959        let engine = create_engine_with_code(
960            "spec pricing
961            data quantity: 10
962            rule total: quantity * 2",
963        );
964        let spec = generate_openapi(&engine, false);
965
966        assert!(
967            spec["paths"]["/pricing"].is_object(),
968            "single spec path /pricing"
969        );
970        assert!(spec["paths"]["/pricing"]["get"].is_object());
971        assert!(spec["paths"]["/pricing"]["post"].is_object());
972
973        assert_eq!(
974            spec["paths"]["/pricing"]["get"]["operationId"],
975            "get_pricing"
976        );
977        assert_eq!(
978            spec["paths"]["/pricing"]["post"]["operationId"],
979            "post_pricing"
980        );
981        assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
982
983        let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
984            .as_array()
985            .expect("parameters array");
986        let param_names: Vec<&str> = get_params
987            .iter()
988            .map(|p| p["name"].as_str().unwrap())
989            .collect();
990        assert!(
991            param_names.contains(&"rules"),
992            "GET must have rules query param"
993        );
994        assert!(
995            param_names.contains(&"Accept-Datetime"),
996            "GET must have Accept-Datetime header"
997        );
998
999        let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1000            ["application/json"]["schema"]["$ref"]
1001            .as_str()
1002            .unwrap();
1003        let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1004            ["application/json"]["schema"]["$ref"]
1005            .as_str()
1006            .unwrap();
1007        assert_eq!(get_ref, "#/components/schemas/pricing_get_response");
1008        assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1009        assert_ne!(get_ref, post_ref);
1010
1011        let get_schema = &spec["components"]["schemas"]["pricing_get_response"];
1012        assert!(get_schema["properties"]["spec_set_id"]["type"] == "string");
1013        assert!(get_schema["properties"]["versions"].is_object());
1014
1015        let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1016        assert!(h200["headers"]["Memento-Datetime"].is_object());
1017        assert!(h200["headers"]["Vary"].is_object());
1018    }
1019
1020    /// The generated OpenAPI document describes the public spec surface only.
1021    /// Server shell routes (`/openapi.json`, `/health`, `/docs`) are
1022    /// intentionally omitted; consumers must not rely on them for code
1023    /// generation or contract inspection.
1024    #[test]
1025    fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1026        let engine = create_engine_with_code(
1027            "spec pricing
1028            data quantity: 10
1029            rule total: quantity * 2",
1030        );
1031        let spec = generate_openapi(&engine, false);
1032
1033        let paths = spec["paths"].as_object().expect("paths object");
1034        assert!(paths.contains_key("/"));
1035        assert_eq!(paths["/"]["get"]["operationId"], "list");
1036        assert!(!paths.contains_key("/openapi.json"));
1037        assert!(!paths.contains_key("/health"));
1038        assert!(!paths.contains_key("/docs"));
1039        assert!(!paths.contains_key("/schema/pricing"));
1040        assert!(!paths.contains_key("/schema/pricing/{rules}"));
1041        assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1042    }
1043
1044    #[test]
1045    fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
1046        let engine = create_engine_with_code(
1047            "spec pricing
1048            data quantity: 10
1049            rule total: quantity * 2",
1050        );
1051        let spec = generate_openapi(&engine, true);
1052
1053        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1054        assert!(has_param(get_params, "x-explanations"));
1055
1056        let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
1057        assert!(rule_result["properties"]["explanation"].is_object());
1058        assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1059        assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1060
1061        let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1062        assert!(evaluate["required"]
1063            .as_array()
1064            .unwrap()
1065            .contains(&json!("spec")));
1066        assert!(evaluate["required"]
1067            .as_array()
1068            .unwrap()
1069            .contains(&json!("effective")));
1070        assert!(evaluate["required"]
1071            .as_array()
1072            .unwrap()
1073            .contains(&json!("results")));
1074        let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1075            .as_str()
1076            .unwrap();
1077        assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
1078    }
1079
1080    #[test]
1081    fn test_generate_openapi_multiple_specs() {
1082        let engine = create_engine_with_files(vec![
1083            (
1084                "pricing.lemma",
1085                "spec pricing
1086                data quantity: 10
1087                rule total: quantity * 2",
1088            ),
1089            (
1090                "shipping.lemma",
1091                "spec shipping
1092                data weight: 5
1093                rule cost: weight * 3",
1094            ),
1095        ]);
1096        let spec = generate_openapi(&engine, false);
1097
1098        assert!(spec["paths"]["/pricing"].is_object());
1099        assert!(spec["paths"]["/shipping"].is_object());
1100    }
1101
1102    #[test]
1103    fn test_nested_spec_path_schema_refs_are_valid() {
1104        let engine = create_engine_with_code(
1105            "spec bc
1106        data x: number
1107        rule result: x",
1108        );
1109        let spec = generate_openapi(&engine, false);
1110
1111        assert!(spec["paths"]["/bc"]["post"].is_object());
1112        let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1113        let body_ref = post_content["application/json"]["schema"]["$ref"]
1114            .as_str()
1115            .unwrap();
1116        let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1117            .as_str()
1118            .unwrap();
1119        assert_eq!(body_ref, "#/components/schemas/bc_request");
1120        assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1121        assert!(spec["components"]["schemas"]["bc_request"].is_object());
1122        assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1123        assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1124        assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1125    }
1126
1127    // =======================================================================
1128    // generate_openapi_effective with explicit timestamp
1129    // =======================================================================
1130
1131    #[test]
1132    fn test_generate_openapi_effective_reflects_specific_time() {
1133        let engine = create_engine_with_code(
1134            "spec pricing
1135            data quantity: 10
1136            rule total: quantity * 2",
1137        );
1138        let effective = date(2025, 6, 15);
1139        let spec = generate_openapi_effective(&engine, false, &effective);
1140
1141        assert_eq!(spec["openapi"], "3.1.0");
1142        let version = spec["info"]["version"].as_str().unwrap();
1143        assert!(
1144            version.contains("2025-06-15"),
1145            "version string should contain the effective date, got: {}",
1146            version
1147        );
1148    }
1149
1150    #[test]
1151    fn test_effective_shows_correct_temporal_version_interface() {
1152        let engine = create_engine_with_files(vec![(
1153            "policy.lemma",
1154            r#"
1155spec policy
1156data base: 100
1157rule discount: 10
1158
1159spec policy 2025-06-01
1160data base: 200
1161data premium: boolean
1162rule discount: 20
1163rule surcharge:
1164  5
1165  unless premium then 10
1166"#,
1167        )]);
1168
1169        let before = date(2025, 3, 1);
1170        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1171
1172        assert!(spec_v1["paths"]["/policy"].is_object());
1173        let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1174        let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1175        assert_eq!(
1176            v1_result["discount"]["$ref"].as_str(),
1177            Some("#/components/schemas/LemmaRuleResult"),
1178            "v1 should have discount rule"
1179        );
1180        assert!(
1181            v1_result["surcharge"].is_null(),
1182            "v1 must NOT have surcharge rule"
1183        );
1184        let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1185        assert!(
1186            v1_request["properties"]["premium"].is_null(),
1187            "v1 must NOT have premium data"
1188        );
1189
1190        let after = date(2025, 8, 1);
1191        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1192
1193        let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1194        let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1195        assert!(
1196            v2_result["discount"]["$ref"].is_string(),
1197            "v2 should have discount rule"
1198        );
1199        assert!(
1200            v2_result["surcharge"]["$ref"].is_string(),
1201            "v2 should have surcharge rule"
1202        );
1203        let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1204        assert!(
1205            v2_request["properties"]["premium"].is_object(),
1206            "v2 should have premium data"
1207        );
1208    }
1209
1210    /// Each spec PathItem carries `x-effective-from` and `x-effective-to`
1211    /// describing the half-open `[effective_from, effective_to)` validity
1212    /// range of the version resolved at the document's effective instant.
1213    ///
1214    /// - Earlier row: `x-effective-to` = next row's `effective_from`.
1215    /// - Latest row: `x-effective-to` = `null` (no successor).
1216    /// - Unversioned spec (no declared `effective_from`): both extensions are
1217    ///   `null`.
1218    #[test]
1219    fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1220        let engine = create_engine_with_files(vec![(
1221            "policy.lemma",
1222            r#"
1223spec policy 2025-01-01
1224data base: 10
1225rule total: base
1226
1227spec policy 2026-01-01
1228data base: 99
1229rule total: base
1230"#,
1231        )]);
1232
1233        let at_earlier = date(2025, 6, 1);
1234        let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1235        let earlier_path = &earlier_doc["paths"]["/policy"];
1236        assert_eq!(
1237            earlier_path["x-effective-from"].as_str(),
1238            Some("2025-01-01"),
1239            "earlier version effective_from on PathItem"
1240        );
1241        assert_eq!(
1242            earlier_path["x-effective-to"].as_str(),
1243            Some("2026-01-01"),
1244            "earlier version effective_to equals next version's effective_from"
1245        );
1246
1247        let at_latest = date(2026, 6, 1);
1248        let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1249        let latest_path = &latest_doc["paths"]["/policy"];
1250        assert_eq!(
1251            latest_path["x-effective-from"].as_str(),
1252            Some("2026-01-01"),
1253            "latest version effective_from on PathItem"
1254        );
1255        assert!(
1256            latest_path["x-effective-to"].is_null(),
1257            "latest version has no successor; x-effective-to must be null: {latest_path}"
1258        );
1259    }
1260
1261    /// Unversioned specs (no declared `effective_from`) have both extensions
1262    /// serialised as JSON `null`, not omitted.
1263    #[test]
1264    fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1265        let engine = create_engine_with_code(
1266            "spec pricing
1267            data quantity: 10
1268            rule total: quantity * 2",
1269        );
1270        let document = generate_openapi(&engine, false);
1271        let path_item = &document["paths"]["/pricing"];
1272        assert!(
1273            path_item["x-effective-from"].is_null(),
1274            "unversioned spec: x-effective-from must be null: {path_item}"
1275        );
1276        assert!(
1277            path_item["x-effective-to"].is_null(),
1278            "unversioned spec: x-effective-to must be null: {path_item}"
1279        );
1280    }
1281
1282    // =======================================================================
1283    // temporal_api_sources
1284    // =======================================================================
1285
1286    #[test]
1287    fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1288        let engine = create_engine_with_files(vec![(
1289            "policy.lemma",
1290            r#"
1291spec policy
1292data base: 100
1293rule discount: 10
1294
1295spec policy 2025-06-01
1296data base: 200
1297rule discount: 20
1298"#,
1299        )]);
1300
1301        let sources = temporal_api_sources(&engine);
1302
1303        assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1304
1305        assert_eq!(sources[0].title, "Now");
1306        assert_eq!(sources[0].slug, NOW_SLUG);
1307        assert_eq!(sources[0].url, "/openapi.json");
1308
1309        assert_eq!(sources[1].title, "Effective 2025-06-01");
1310        assert_eq!(sources[1].slug, "2025-06-01");
1311        assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1312    }
1313
1314    #[test]
1315    fn test_temporal_sources_multiple_specs_merged_boundaries() {
1316        let engine = create_engine_with_files(vec![
1317            (
1318                "policy.lemma",
1319                r#"
1320spec policy
1321data base: 100
1322rule discount: 10
1323
1324spec policy 2025-06-01
1325data base: 200
1326rule discount: 20
1327"#,
1328            ),
1329            (
1330                "rates.lemma",
1331                r#"
1332spec rates
1333data rate: 5
1334rule total: rate * 2
1335
1336spec rates 2025-03-01
1337data rate: 7
1338rule total: rate * 2
1339
1340spec rates 2025-06-01
1341data rate: 9
1342rule total: rate * 2
1343"#,
1344            ),
1345        ]);
1346
1347        let sources = temporal_api_sources(&engine);
1348
1349        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1350        assert!(
1351            slugs.contains(&"2025-03-01"),
1352            "should contain rates boundary"
1353        );
1354        assert!(
1355            slugs.contains(&"2025-06-01"),
1356            "should contain shared boundary"
1357        );
1358        assert!(slugs.contains(&NOW_SLUG), "should contain now");
1359        assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1360    }
1361
1362    #[test]
1363    fn test_temporal_sources_ordered_chronologically() {
1364        let engine = create_engine_with_files(vec![(
1365            "policy.lemma",
1366            r#"
1367spec policy
1368data base: 100
1369rule discount: 10
1370
1371spec policy 2024-01-01
1372data base: 50
1373rule discount: 5
1374
1375spec policy 2025-06-01
1376data base: 200
1377rule discount: 20
1378"#,
1379        )]);
1380
1381        let sources = temporal_api_sources(&engine);
1382        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1383        assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1384    }
1385
1386    // =======================================================================
1387    // Type-specific parameter tests
1388    // =======================================================================
1389
1390    #[test]
1391    fn test_post_schema_text_with_options_has_enum() {
1392        let engine = create_engine_with_code(
1393            "spec test
1394            data product: text -> option \"A\" -> option \"B\"
1395            rule result: product",
1396        );
1397        let spec = generate_openapi(&engine, false);
1398
1399        let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1400        assert!(product_prop["enum"].is_array());
1401        let enums = product_prop["enum"].as_array().unwrap();
1402        assert_eq!(enums.len(), 2);
1403        assert_eq!(enums[0], "A");
1404        assert_eq!(enums[1], "B");
1405    }
1406
1407    #[test]
1408    fn test_post_schema_boolean_is_json_boolean() {
1409        let engine = create_engine_with_code(
1410            "spec test
1411            data is_active: boolean
1412            rule result: is_active",
1413        );
1414        let spec = generate_openapi(&engine, false);
1415
1416        let schema = &spec["components"]["schemas"]["test_request"];
1417        let is_active = &schema["properties"]["is_active"];
1418        assert_eq!(is_active["type"], "boolean");
1419
1420        let form_schema = &spec["components"]["schemas"]["test_form_request"];
1421        let form_is_active = &form_schema["properties"]["is_active"];
1422        assert_eq!(form_is_active["type"], "string");
1423        assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1424    }
1425
1426    #[test]
1427    fn test_post_schema_number_is_string() {
1428        let engine = create_engine_with_code(
1429            "spec test
1430            data quantity: number
1431            rule result: quantity",
1432        );
1433        let spec = generate_openapi(&engine, false);
1434
1435        let schema = &spec["components"]["schemas"]["test_request"];
1436        assert_eq!(schema["properties"]["quantity"]["type"], "string");
1437    }
1438
1439    #[test]
1440    fn test_data_with_default_is_not_required() {
1441        let engine = create_engine_with_code(
1442            "spec test
1443            data quantity: 10
1444            data name: text
1445            rule result: quantity
1446            rule label: name",
1447        );
1448        let spec = generate_openapi(&engine, false);
1449
1450        let schema = &spec["components"]["schemas"]["test_request"];
1451        let required = schema["required"]
1452            .as_array()
1453            .expect("required should be array");
1454
1455        assert!(required.contains(&Value::String("name".to_string())));
1456        assert!(!required.contains(&Value::String("quantity".to_string())));
1457    }
1458
1459    #[test]
1460    fn test_help_and_default_in_openapi() {
1461        let engine = create_engine_with_code(
1462            r#"spec test
1463data quantity: number -> help "Number of items to order" -> default 10
1464data active: boolean -> help "Whether the feature is enabled" -> default true
1465rule result:
1466  quantity
1467  unless active then 0
1468"#,
1469        );
1470        let spec = generate_openapi(&engine, false);
1471
1472        let req_schema = &spec["components"]["schemas"]["test_request"];
1473        assert!(req_schema["properties"]["quantity"]["description"]
1474            .as_str()
1475            .unwrap()
1476            .contains("Number of items to order"));
1477        assert_eq!(
1478            req_schema["properties"]["quantity"]["default"]
1479                .as_str()
1480                .unwrap(),
1481            "10"
1482        );
1483        assert!(req_schema["properties"]["active"]["description"]
1484            .as_str()
1485            .unwrap()
1486            .contains("Whether the feature is enabled"));
1487        assert_eq!(
1488            req_schema["properties"]["active"]["default"]
1489                .as_str()
1490                .unwrap(),
1491            "true"
1492        );
1493    }
1494}