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