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