Skip to main content

lemma_openapi/
lib.rs

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