Skip to main content

umbral_openapi/
lib.rs

1//! umbral-openapi — auto-generated OpenAPI 3.0 schema + Swagger UI.
2//!
3//! Register [`OpenApiPlugin`] on `App::builder()` alongside
4//! `RestPlugin`. The plugin walks the migration registry, drops the
5//! tables umbral-rest hides by default, and emits an OpenAPI 3.0
6//! document describing every remaining model's REST surface.
7//!
8//! Default mount point is `/openapi/`:
9//!
10//! - `GET /openapi/openapi.json` — the JSON spec
11//! - `GET /openapi/`             — Swagger UI loaded from unpkg
12//!
13//! Override via `OpenApiPlugin::new().at("/api/docs")` to put the UI
14//! under `/api/docs/` and the JSON under `/api/docs/openapi.json`.
15//!
16//! ## Scope
17//!
18//! v1 only describes umbral-rest's auto-generated endpoints. Hand-
19//! written routes the user added on the builder are not in scope.
20//! The spec emits a `components.securitySchemes` block populated from
21//! the REST layer's registered auth schemes (via
22//! `umbral_rest::registered_security_schemes()`). List endpoints
23//! include the pagination query parameters that match the configured
24//! backend — `page`/`page_size` for [`umbral_rest::PageNumberPagination`],
25//! `limit`/`offset` for [`umbral_rest::LimitOffsetPagination`], none for
26//! [`umbral_rest::NoPagination`] (the default).
27
28use std::sync::OnceLock;
29
30use serde_json::{Map, Value, json};
31use umbral::migrate::{Column, ModelMeta};
32use umbral::orm::SqlType;
33use umbral::prelude::*;
34use umbral::web::{Html, IntoResponse, Json, Response, StatusCode, header};
35use umbral_casing::pascal_case_from_ident;
36
37const SWAGGER_UI_HTML: &str = include_str!("../templates/swagger_ui.html");
38
39/// The OpenAPI plugin.
40#[derive(Debug, Clone)]
41pub struct OpenApiPlugin {
42    base_path: String,
43    title: String,
44    version: String,
45    description: Option<String>,
46    extra_exclude: Vec<String>,
47}
48
49impl Default for OpenApiPlugin {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl OpenApiPlugin {
56    pub fn new() -> Self {
57        Self {
58            base_path: "/openapi".to_string(),
59            title: "umbral API".to_string(),
60            version: "0.0.1".to_string(),
61            description: None,
62            extra_exclude: Vec::new(),
63        }
64    }
65
66    /// Mount the JSON + UI under a different base. Trailing slashes
67    /// are normalised so both `.at("/api/docs")` and `.at("/api/docs/")`
68    /// register the same routes.
69    pub fn at(mut self, path: &str) -> Self {
70        let trimmed = path.trim_end_matches('/');
71        self.base_path = if trimmed.is_empty() {
72            "/".to_string()
73        } else {
74            trimmed.to_string()
75        };
76        self
77    }
78
79    /// Override `info.title` in the emitted spec.
80    pub fn title(mut self, s: impl Into<String>) -> Self {
81        self.title = s.into();
82        self
83    }
84
85    /// Override `info.version` in the emitted spec.
86    pub fn version(mut self, s: impl Into<String>) -> Self {
87        self.version = s.into();
88        self
89    }
90
91    /// Set `info.description` in the emitted spec. Optional —
92    /// omitted from the JSON when unset. Markdown is permitted (per
93    /// OpenAPI 3.0.3); Swagger UI renders it above the operations
94    /// list, so this is the place to document API-wide auth, rate
95    /// limiting, conventions, etc.
96    pub fn description(mut self, s: impl Into<String>) -> Self {
97        self.description = Some(s.into());
98        self
99    }
100
101    /// Add tables to the block-list. The umbral-rest defaults still
102    /// apply.
103    pub fn exclude<I, S>(mut self, tables: I) -> Self
104    where
105        I: IntoIterator<Item = S>,
106        S: Into<String>,
107    {
108        for t in tables {
109            self.extra_exclude.push(t.into());
110        }
111        self
112    }
113
114    fn is_exposed(&self, table: &str) -> bool {
115        // The default block-list lives in umbral-rest and is consulted
116        // via `umbral_rest::is_exposed(table)` at spec-build time, so
117        // we don't duplicate it here. Our own opt-out is purely the
118        // `extra_exclude` list — for cases like "served by REST but
119        // I don't want it in the public spec."
120        !self.extra_exclude.iter().any(|t| t == table)
121    }
122
123    fn spec_url(&self) -> String {
124        if self.base_path == "/" {
125            "/openapi.json".to_string()
126        } else {
127            format!("{}/openapi.json", self.base_path)
128        }
129    }
130
131    fn ui_route(&self) -> String {
132        if self.base_path == "/" {
133            "/".to_string()
134        } else {
135            format!("{}/", self.base_path)
136        }
137    }
138}
139
140// Configured plugin lives in a OnceLock so the static handlers, which
141// can't capture per-instance state through axum, can read the title /
142// version / block-list at request time.
143static CONFIG: OnceLock<OpenApiPlugin> = OnceLock::new();
144
145/// Public read of the configured spec URL — the path the JSON
146/// document is served at after `App::build()` runs. Returns
147/// `None` when OpenApiPlugin isn't installed (the OnceLock
148/// hasn't been populated by `Plugin::routes()` yet); returns
149/// `Some("/openapi/openapi.json")` for the default mount and
150/// `Some("/api/docs/openapi.json")` when the user calls
151/// `OpenApiPlugin::default().at("/api/docs")`.
152///
153/// The playground plugin reads this at HTML-render time to inject
154/// the URL into the shell page as a JS global, so a re-mounted
155/// spec is auto-discovered by the SPA without the user having to
156/// also configure the playground.
157pub fn spec_url() -> Option<String> {
158    CONFIG.get().map(|cfg| cfg.spec_url())
159}
160
161impl Plugin for OpenApiPlugin {
162    fn name(&self) -> &'static str {
163        "openapi"
164    }
165
166    fn dependencies(&self) -> &'static [&'static str] {
167        &["rest"]
168    }
169
170    fn routes(&self) -> Router {
171        let _ = CONFIG.set(self.clone());
172        // Publish the spec URL to the core registry so cross-plugin
173        // consumers (umbral-playground's SPA fetches it from the
174        // browser) can discover the configured mount without
175        // hardcoding `/openapi/openapi.json`.
176        umbral::routes::init_openapi_spec_url(self.spec_url());
177        let mut router = Router::new()
178            .route(&self.spec_url(), get(spec_handler))
179            .route(&self.ui_route(), get(swagger_ui_handler));
180        // Also register the slash-less form (`/openapi` alongside
181        // `/openapi/`) so the trailing-slash gotcha doesn't bite users
182        // who haven't opted into the framework-wide
183        // `App::builder().slash_redirect(SlashRedirect::Append)`
184        // policy. Cheap: same handler, no extra state. Skipped when
185        // the base path is `/` (the ui_route is already just `/`,
186        // no alternate form to register).
187        if self.base_path != "/" {
188            router = router.route(&self.base_path, get(swagger_ui_handler));
189        }
190        router
191    }
192}
193
194// =========================================================================
195// Handlers.
196// =========================================================================
197
198async fn spec_handler() -> Response {
199    let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
200    let spec = build_spec(cfg);
201    // Json's IntoResponse already sets application/json, but be
202    // explicit so a future swap to a String body doesn't drop it.
203    (
204        StatusCode::OK,
205        [(header::CONTENT_TYPE, "application/json")],
206        Json(spec),
207    )
208        .into_response()
209}
210
211async fn swagger_ui_handler() -> Response {
212    let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
213    let body = SWAGGER_UI_HTML.replace("{SPEC_URL}", &cfg.spec_url());
214    Html(body).into_response()
215}
216
217// =========================================================================
218// Spec generation. Walk the registry, dispatch each SqlType to an
219// OpenAPI type/format, and emit one schema + six operations per
220// exposed model.
221// =========================================================================
222
223fn build_spec(cfg: &OpenApiPlugin) -> Value {
224    let mut schemas = Map::new();
225    let mut paths = Map::new();
226
227    // Playground-openapi-gaps #2: precompute every (table →
228    // schema_name) mapping so FK columns can emit
229    // `x-umbral-fk-ref` pointing at the target schema's JSON
230    // pointer. The pointer shape `#/components/schemas/<Target>`
231    // is what generated clients follow to navigate from `Post.author`
232    // to the `User` schema. Done in a separate walk first so the
233    // map is complete by the time column_schema runs on FK fields.
234    let mut table_to_schema: std::collections::HashMap<String, String> =
235        std::collections::HashMap::new();
236    for plugin in umbral::migrate::registered_plugins() {
237        for model in umbral::migrate::models_for_plugin(&plugin) {
238            table_to_schema.insert(model.table.clone(), pascal_case_from_ident(&model.name));
239        }
240    }
241
242    // Read the REST base path once before the model loop. This is what
243    // the real mounted routes use, so the documented paths mirror the live
244    // routes exactly. E.g. `.at("/v2")` → paths under `/v2/`, not `/api/`.
245    let rest_base = umbral_rest::registered_base_path().to_owned();
246
247    for plugin in umbral::migrate::registered_plugins() {
248        for model in umbral::migrate::models_for_plugin(&plugin) {
249            // The spec describes what REST actually serves, so defer
250            // to RestPlugin's allow/block decision first. This means
251            // `RestPlugin::default().include_only(["article"])`
252            // automatically restricts the spec to `article` without
253            // the user having to repeat the configuration on
254            // OpenApiPlugin. The OpenAPI plugin's own `.exclude(...)`
255            // list still applies AFTER as an additional filter for
256            // tables the user wants served-but-not-documented.
257            if !umbral_rest::is_exposed(&model.table) {
258                continue;
259            }
260            if !cfg.is_exposed(&model.table) {
261                continue;
262            }
263            let schema_name = pascal_case_from_ident(&model.name);
264            schemas.insert(schema_name.clone(), model_schema(&model, &table_to_schema));
265            // Advertise every filterable column × lookup AND the
266            // `?search=` free-text parameter (when enabled) as
267            // discoverable query parameters on the GET list
268            // operation. The playground (and any spec consumer) can
269            // then drive a real filter UI off the spec instead of
270            // guessing.
271            let mut list_params = Vec::new();
272            // Emit the pagination query params that match the configured
273            // backend. PageNumber → page/page_size; LimitOffset →
274            // limit/offset; NoPagination and unknown custom → nothing.
275            list_params.extend(pagination_parameters_for_style(
276                umbral_rest::registered_pagination_style(),
277            ));
278            if umbral_rest::search_enabled_for(&model.table) {
279                list_params.push(search_parameter());
280            }
281            // `?fields=` sparse fieldset (BUG-81) is always
282            // available — independent of search / filter opt-out.
283            list_params.push(fields_parameter(&model));
284            // `?include=fk1,fk2` — only emit when the model actually
285            // has FK columns; otherwise the param has nothing to
286            // expand and the playground multi-select would render
287            // empty.
288            if model.fields.iter().any(|c| c.fk_target.is_some()) {
289                list_params.push(include_parameter(&model));
290            }
291            if umbral_rest::filters_enabled_for(&model.table) {
292                list_params.extend(filter_parameters(&model));
293            }
294            // Skip the collection path entirely when `.views(...)` scoped
295            // out both List and Create — an OpenAPI path item with no
296            // operations is meaningless (and clutters Swagger UI).
297            let collection = collection_paths(&model.table, &schema_name, &list_params);
298            if has_operations(&collection) {
299                paths.insert(format!("{}/{}/", rest_base, model.table), collection);
300            }
301            // Retrieve respects both `?fields=` and `?include=` — same
302            // shape as list. Build the params slice dynamically so the
303            // FK-less models don't get a vestigial `?include=` entry.
304            let mut item_params = vec![fields_parameter(&model)];
305            if model.fields.iter().any(|c| c.fk_target.is_some()) {
306                item_params.push(include_parameter(&model));
307            }
308            // Same guard for the detail path: `views([List])` leaves the
309            // item URL with no operations (only the `id` parameter), so
310            // it's omitted from the spec.
311            let item = item_paths(&model.table, &schema_name, &item_params);
312            if has_operations(&item) {
313                paths.insert(format!("{}/{}/{{id}}", rest_base, model.table), item);
314            }
315        }
316    }
317
318    // BUG-20: every plugin's `Plugin::openapi_paths()` contribution
319    // gets merged into the spec. Auto-CRUD paths above land first
320    // (so a plugin can shadow a model's path with a custom Path Item
321    // if it wants); plugin contributions land on top, last-write-
322    // wins for duplicate URLs.
323    if let Some(entries) = umbral::routes::registered_openapi_paths() {
324        for (path, item) in entries {
325            paths.insert(path.clone(), item.clone());
326        }
327    }
328
329    // Every custom `@action` endpoint gets its own path item so it shows up
330    // in the spec + playground. Declared request/response schemas (feature
331    // #60) are inlined; a schemaless action (e.g. `get_price_at`) still
332    // appears, with a generic 200 response.
333    for action in umbral_rest::registered_action_schemas() {
334        let path = if action.detail {
335            format!(
336                "{}/{}/{{id}}/{}/",
337                action.base_path, action.table, action.name
338            )
339        } else {
340            format!("{}/{}/{}/", action.base_path, action.table, action.name)
341        };
342        paths.insert(path, action_path_item(&action));
343    }
344
345    let mut info = Map::new();
346    info.insert("title".into(), Value::String(cfg.title.clone()));
347    info.insert("version".into(), Value::String(cfg.version.clone()));
348    if let Some(desc) = &cfg.description {
349        info.insert("description".into(), Value::String(desc.clone()));
350    }
351
352    // Playground-openapi-gaps #4: read the configured auth chain's
353    // securitySchemes and emit a `components.securitySchemes` block
354    // + a global `security` array referencing each. The global
355    // security is an OR (any one scheme satisfies the request),
356    // matching `ChainAuthentication([Session, Bearer])`'s actual
357    // runtime behaviour.
358    let mut security_schemes = Map::new();
359    let mut security: Vec<Value> = Vec::new();
360    for (name, scheme) in umbral_rest::registered_security_schemes() {
361        security.push(json!({ name.clone(): [] }));
362        security_schemes.insert(name, scheme);
363    }
364    let mut components = Map::new();
365    components.insert("schemas".into(), Value::Object(schemas));
366    if !security_schemes.is_empty() {
367        components.insert("securitySchemes".into(), Value::Object(security_schemes));
368    }
369
370    let mut document = Map::new();
371    document.insert("openapi".into(), Value::String("3.0.3".into()));
372    document.insert("info".into(), Value::Object(info));
373    document.insert("paths".into(), Value::Object(paths));
374    document.insert("components".into(), Value::Object(components));
375    if !security.is_empty() {
376        document.insert("security".into(), Value::Array(security));
377    }
378    Value::Object(document)
379}
380
381/// Path Item for a custom `@action` (feature #60): the declared HTTP
382/// method with the request/response schemas inlined, plus the `{id}` path
383/// param for detail-scope actions.
384fn action_path_item(a: &umbral_rest::ActionSchema) -> Value {
385    let mut op = Map::new();
386    op.insert(
387        "operationId".into(),
388        Value::String(format!("{}_{}", a.table, a.name)),
389    );
390    op.insert("tags".into(), json!([a.table]));
391    op.insert(
392        "summary".into(),
393        Value::String(format!("`{}` action on {}", a.name, a.table)),
394    );
395    if a.detail {
396        op.insert(
397            "parameters".into(),
398            json!([{
399                "name": "id", "in": "path", "required": true,
400                "schema": { "type": "string" },
401                "description": "Primary key of the target row"
402            }]),
403        );
404    }
405    if let Some(input) = &a.input_schema {
406        op.insert(
407            "requestBody".into(),
408            json!({ "required": true, "content": { "application/json": { "schema": input } } }),
409        );
410    }
411    let mut ok = Map::new();
412    ok.insert("description".into(), Value::String("Action result".into()));
413    if let Some(output) = &a.output_schema {
414        ok.insert(
415            "content".into(),
416            json!({ "application/json": { "schema": output } }),
417        );
418    }
419    op.insert("responses".into(), json!({ "200": Value::Object(ok) }));
420
421    let mut item = Map::new();
422    item.insert(a.method.to_lowercase(), Value::Object(op));
423    Value::Object(item)
424}
425
426fn model_schema(
427    model: &ModelMeta,
428    table_to_schema: &std::collections::HashMap<String, String>,
429) -> Value {
430    let mut properties = Map::new();
431    let mut required: Vec<Value> = Vec::new();
432    for col in &model.fields {
433        // A column the REST plugin hides (`ResourceConfig::hide` /
434        // `RestPlugin::hide_model`) is stripped from every response
435        // body, so it must not appear in the schema either — otherwise
436        // the spec advertises (and Swagger UI shows) a field like
437        // `password_hash` the API will never return: an info leak +
438        // confusing docs. Skip it for both `properties` and `required`.
439        if umbral_rest::is_hidden(&model.table, &col.name) {
440            continue;
441        }
442        properties.insert(
443            col.name.clone(),
444            column_schema_with_refs(col, table_to_schema),
445        );
446        // PK is auto-generated by SQLite on POST.
447        // Non-nullable non-PK columns are what the client MUST
448        // supply — except when the framework supplies a default
449        // itself. `auto_now` / `auto_now_add` stamp `Utc::now()`
450        // when the body omits the value, and `noform` columns
451        // are stripped from the body before write. None of
452        // those should appear in `required`; making them so
453        // would force clients to ship server-managed timestamps
454        // and password hashes on every POST.
455        if !col.nullable && !col.primary_key && !col.auto_now && !col.auto_now_add && !col.noform {
456            required.push(Value::String(col.name.clone()));
457        }
458    }
459    // M2M relations live on the parent's `m2m_relations` channel
460    // (not on `fields`, because they have no column on the parent
461    // table). Surface them as `array of integer` with a vendor
462    // extension naming the child schema so playground / generated
463    // clients can render a tag-picker. Not marked required —
464    // M2M slots are always optional on write.
465    for rel in &model.m2m_relations {
466        let target_schema = table_to_schema
467            .get(&rel.target_table)
468            .cloned()
469            .unwrap_or_else(|| pascal_case_from_ident(&rel.target_name));
470        let mut prop = serde_json::Map::new();
471        prop.insert("type".into(), Value::String("array".into()));
472        // Items are the child model's PK type, not always int64 (review #4):
473        // a M2M to a String/Uuid-PK child sends an array of slugs/uuids.
474        let (item_ty, item_fmt) = umbral::migrate::pk_meta_for_table(&rel.target_table)
475            .map(|(_, pk_ty)| openapi_type(pk_ty))
476            .unwrap_or(("integer", Some("int64")));
477        let items = match item_fmt {
478            Some(f) => json!({ "type": item_ty, "format": f }),
479            None => json!({ "type": item_ty }),
480        };
481        prop.insert("items".into(), items);
482        prop.insert(
483            "description".into(),
484            Value::String(format!(
485                "Many-to-many relation to {}. Send an array of child ids on \
486                 create / update; the framework writes the junction table.",
487                target_schema,
488            )),
489        );
490        // Vendor extensions: aware clients (playground) can render
491        // a multi-select chip picker pointed at the child schema.
492        prop.insert("x-umbral-m2m".into(), Value::Bool(true));
493        prop.insert(
494            "x-umbral-m2m-target".into(),
495            Value::String(target_schema.clone()),
496        );
497        prop.insert(
498            "x-umbral-m2m-target-table".into(),
499            Value::String(rel.target_table.clone()),
500        );
501        if table_to_schema.contains_key(&rel.target_table) {
502            prop.insert(
503                "x-umbral-m2m-target-ref".into(),
504                Value::String(format!("#/components/schemas/{target_schema}")),
505            );
506        }
507        properties.insert(rel.field_name.clone(), Value::Object(prop));
508    }
509    let mut obj = Map::new();
510    obj.insert("type".into(), Value::String("object".into()));
511    obj.insert("properties".into(), Value::Object(properties));
512    if !required.is_empty() {
513        obj.insert("required".into(), Value::Array(required));
514    }
515    Value::Object(obj)
516}
517
518/// Wrap [`column_schema`] with the schema-name-aware FK ref. The
519/// inner function stays backwards-compatible (no map arg) for the
520/// test cases that exercise `column_schema(&col)` directly.
521fn column_schema_with_refs(
522    col: &Column,
523    table_to_schema: &std::collections::HashMap<String, String>,
524) -> Value {
525    let mut value = column_schema(col);
526    // Playground-openapi-gaps #2: emit `x-umbral-fk-ref` as a JSON
527    // pointer to the target schema. Generated clients that follow
528    // vendor extensions can navigate from a `Post.author` (integer)
529    // to the `User` schema. OpenAPI 3.0's strict `$ref` rule
530    // ("siblings of $ref must be ignored") rules out putting this on
531    // the value as a real `$ref`, which is why this lives as a
532    // vendor extension. The Swagger UI playground already special-
533    // cases umbral's `x-umbral-*` extensions; openapi-generator
534    // / orval can do the same.
535    if let Some(target_table) = &col.fk_target {
536        if let Some(schema_name) = table_to_schema.get(target_table) {
537            if let Some(obj) = value.as_object_mut() {
538                obj.insert(
539                    "x-umbral-fk-ref".into(),
540                    Value::String(format!("#/components/schemas/{schema_name}")),
541                );
542            }
543        }
544    }
545    value
546}
547
548fn column_schema(col: &Column) -> Value {
549    let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
550    let mut obj = Map::new();
551    obj.insert("type".into(), Value::String(ty.into()));
552    if let Some(f) = format {
553        obj.insert("format".into(), Value::String(f.into()));
554    }
555    if col.nullable {
556        obj.insert("nullable".into(), Value::Bool(true));
557    }
558    // `#[umbral(help = "...")]` lands as the OpenAPI standard
559    // `description` so Swagger UI / generated clients pick it up.
560    // Closes playground-openapi-gaps item 5.
561    if !col.help.is_empty() {
562        obj.insert("description".into(), Value::String(col.help.clone()));
563    }
564    // `#[umbral(example = "...")]` lands as the OpenAPI standard
565    // `example` so Swagger UI pre-fills request bodies with a
566    // useful sample. Closes playground-openapi-gaps item 6.
567    if !col.example.is_empty() {
568        obj.insert("example".into(), Value::String(col.example.clone()));
569    }
570    // IMP-3: `#[umbral(min = N)]` / `#[umbral(max = N)]` →
571    // OpenAPI `minimum` / `maximum`. Both are standard 3.0 keys.
572    if let Some(min) = col.min {
573        obj.insert(
574            "minimum".into(),
575            Value::Number(serde_json::Number::from(min)),
576        );
577    }
578    if let Some(max) = col.max {
579        obj.insert(
580            "maximum".into(),
581            Value::Number(serde_json::Number::from(max)),
582        );
583    }
584    // BUG-11/12/13: `Slug` / `Email` / `Url` wrappers lower to
585    // standard OpenAPI markers so generated clients and Swagger UI
586    // render the right widget.
587    if let Some(fmt) = col.text_format.as_deref() {
588        match fmt {
589            "email" => {
590                obj.insert("format".into(), Value::String("email".into()));
591            }
592            "url" => {
593                obj.insert("format".into(), Value::String("uri".into()));
594            }
595            "slug" => {
596                // No built-in OpenAPI format for slug; use the
597                // `pattern` keyword (standard 3.0) to constrain
598                // accepted values. Mirrors the macro-side regex.
599                obj.insert("pattern".into(), Value::String("^[A-Za-z0-9_-]+$".into()));
600            }
601            _ => {}
602        }
603    }
604    // Standard OpenAPI: closed-set values become `enum`. Skipped for
605    // multichoice (a CSV-encoded subset) because each request value is
606    // a comma-separated string of the choices, not one choice — clients
607    // need richer guidance than a flat enum can provide. We still emit
608    // the underlying choices via `x-umbral-choices` below.
609    if !col.choices.is_empty() && !col.is_multichoice {
610        obj.insert(
611            "enum".into(),
612            Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
613        );
614    }
615    if col.max_length > 0 {
616        obj.insert(
617            "maxLength".into(),
618            Value::Number(serde_json::Number::from(col.max_length)),
619        );
620    }
621    if !col.default.is_empty() {
622        // OpenAPI `default` is typed as the property's type, but the
623        // Column carries it as a string (it's a SQL literal). Emitting
624        // as a string is the conservative choice — Swagger UI shows it
625        // as a hint, and clients that care can re-parse.
626        obj.insert("default".into(), Value::String(col.default.clone()));
627    }
628    if col.is_multichoice {
629        obj.insert("x-umbral-multichoice".into(), Value::Bool(true));
630        obj.insert(
631            "x-umbral-choices".into(),
632            Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
633        );
634    }
635    if !col.choice_labels.is_empty() {
636        obj.insert(
637            "x-umbral-choice-labels".into(),
638            Value::Array(
639                col.choice_labels
640                    .iter()
641                    .cloned()
642                    .map(Value::String)
643                    .collect(),
644            ),
645        );
646    }
647    if let Some(target) = &col.fk_target {
648        obj.insert("x-umbral-fk-target".into(), Value::String(target.clone()));
649    }
650    // Playground-openapi-gaps #2: the schema-pointer flavour of
651    // `x-umbral-fk-target` lives on the wrapper `column_schema_with_refs`
652    // because it needs the table→schema name map.
653    if col.is_string_repr {
654        obj.insert("x-umbral-string-repr".into(), Value::Bool(true));
655    }
656    // `noedit` is intentionally NOT mapped to `readOnly`. The two
657    // concepts are different: `noedit` is an admin EDIT-form hint
658    // ("show this field disabled when the user clicks the row"),
659    // while OpenAPI's `readOnly` means "never accept this field in
660    // ANY request body" — including POST. The conflation hid
661    // required `noedit` fields from the playground autofill on
662    // CREATE, which is exactly the wrong direction.
663    //
664    // The real "API never accepts" semantic is `noform` (the field
665    // is never shown on any admin form AND the REST plugin drops
666    // it from request bodies before write). That maps cleanly to
667    // OpenAPI `readOnly`.
668    // `auto_now` / `auto_now_add` are server-populated: the ORM
669    // stamps `Utc::now()` when the body omits the value. Surface
670    // them as vendor extensions so an aware client (the playground)
671    // can show a "the server fills this in" hint and skip the
672    // field on autofill / form prefill. Not mapped to `readOnly`
673    // because the client CAN still send an explicit value — the
674    // framework respects it. `required` is already dropped at
675    // `model_schema`'s pass for the same reason.
676    if col.auto_now_add {
677        obj.insert("x-umbral-auto-now-add".into(), Value::Bool(true));
678    }
679    if col.auto_now {
680        obj.insert("x-umbral-auto-now".into(), Value::Bool(true));
681    }
682    if col.noform {
683        obj.insert("readOnly".into(), Value::Bool(true));
684        // Vendor extension so clients aware of the umbral surface
685        // (the playground in particular) can distinguish "API
686        // doesn't accept this" from "admin won't let you edit it"
687        // without having to re-derive the rule from the column
688        // metadata.
689        obj.insert("x-umbral-noform".into(), Value::Bool(true));
690    }
691    // `noedit` becomes a pure vendor extension. Aware clients can
692    // surface it in their edit UI (the playground could, e.g.,
693    // grey the field on PUT/PATCH but not POST) without it
694    // contaminating the request-body contract.
695    if col.noedit {
696        obj.insert("x-umbral-noedit".into(), Value::Bool(true));
697    }
698    Value::Object(obj)
699}
700
701fn openapi_type(ty: SqlType) -> (&'static str, Option<&'static str>) {
702    match ty {
703        SqlType::SmallInt => ("integer", Some("int32")),
704        SqlType::Integer => ("integer", Some("int32")),
705        SqlType::BigInt => ("integer", Some("int64")),
706        SqlType::Real => ("number", Some("float")),
707        SqlType::Double => ("number", Some("double")),
708        SqlType::Boolean => ("boolean", None),
709        SqlType::Text => ("string", None),
710        SqlType::Date => ("string", Some("date")),
711        SqlType::Time => ("string", Some("time")),
712        SqlType::Timestamptz => ("string", Some("date-time")),
713        SqlType::Uuid => ("string", Some("uuid")),
714        // OpenAPI represents JSON columns as the catch-all "object". A
715        // tighter schema would use `oneOf: [object, array]` to model the
716        // full JSON value space, but `object` is the conservative and
717        // most-tooling-friendly mapping for a first iteration.
718        SqlType::Json => ("object", None),
719        // Arrays render as `type: array` with an inferred item type in
720        // OpenAPI. The v1 mapping flattens the element to the same
721        // "type" string (no nested `items.format`) — enough for tools
722        // to validate the request shape, but not the full structural
723        // detail. A future pass can recurse into the element type via
724        // openapi_type for proper `items: { type, format }` nesting.
725        SqlType::Array(_) => ("array", None),
726        // Phase 4.4 network address types. INET and CIDR render as
727        // OpenAPI `ipv4`/`ipv6` strings (we use the generic "string"
728        // shape since umbral doesn't distinguish v4 vs v6 at the type
729        // level). MACADDR likewise renders as a string.
730        SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => ("string", None),
731        // Phase 4.3 tsvector — opaque text lexeme vector. Render as
732        // plain string in the OpenAPI schema.
733        SqlType::FullText => ("string", None),
734        // gaps2 #70: text-backed Postgres types (XML / LTREE / BIT
735        // VARYING) carry their value as a plain string on the wire.
736        SqlType::Xml | SqlType::Ltree | SqlType::Bit => ("string", None),
737        // ForeignKey columns expose as integer (i64) in the REST/OpenAPI
738        // schema — the raw PK value, not a nested object.
739        SqlType::ForeignKey => ("integer", Some("int64")),
740        // BLOB / BYTEA. OpenAPI's `string` + `format: byte` means
741        // base64-encoded on the wire by convention, but umbral-rest's
742        // current wire format is a JSON array of u8. Render as
743        // `array` + `format: byte` to keep the schema honest about
744        // the shape; clients that need base64 can handle the encoding
745        // boundary themselves.
746        SqlType::Bytes => ("array", Some("byte")),
747        // BUG-10: NUMERIC. OpenAPI represents arbitrary-precision
748        // decimals as `string` with `format: decimal` per the
749        // 3.1 spec convention; clients that round-trip through
750        // f64 lose precision, so the canonical wire shape is the
751        // string representation.
752        SqlType::Decimal => ("string", Some("decimal")),
753    }
754}
755
756/// Build the OpenAPI `?search=` parameter object. One slot shared
757/// across every searchable column on the resource — the REST list
758/// handler ORs `icontains` predicates on Text columns and `eq`
759/// predicates on numeric / FK / Boolean columns whose type matches
760/// the parsed term shape.
761///
762/// Vendor extension `x-umbral-search: true` flags this parameter for
763/// aware clients (the playground in particular surfaces it as a
764/// dedicated search box rather than treating it as a generic filter
765/// chip).
766fn search_parameter() -> Value {
767    json!({
768        "name": "search",
769        "in": "query",
770        "required": false,
771        "description": "Free-text search across every searchable column. \
772                        Text columns match via case-insensitive substring; \
773                        numeric / FK / Boolean columns match exactly when \
774                        the term parses as that type. Multiple matches are \
775                        ORed.",
776        "schema": { "type": "string" },
777        "x-umbral-search": true,
778    })
779}
780
781/// BUG-81: the `?fields=col1,col2` sparse-fieldset parameter. Lives
782/// on every list AND retrieve endpoint — when set, the response row
783/// drops every key not in the requested list. Unknown column names
784/// are silently ignored; an empty value falls back to the full row.
785///
786/// The `x-umbral-fields` vendor extension lists every column the
787/// model exposes so the playground can render a multi-select
788/// instead of a plain text box. Generated clients that ignore the
789/// extension still see a `string` parameter with a clear
790/// description.
791fn fields_parameter(model: &ModelMeta) -> Value {
792    // Drop REST-hidden columns: the `?fields=` picker shouldn't offer a
793    // field you can never get back (hide always wins in the response).
794    let columns: Vec<Value> = model
795        .fields
796        .iter()
797        .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
798        .map(|c| Value::String(c.name.clone()))
799        .collect();
800    json!({
801        "name": "fields",
802        "in": "query",
803        "required": false,
804        "description": "Comma-separated list of column names to include in the \
805                        response. Unknown names are silently dropped; an empty \
806                        value falls back to the full row (BUG-81). Composes \
807                        with hide / transform / computed — hide always wins, \
808                        the rest are returned iff in the list.",
809        "schema": { "type": "string" },
810        "x-umbral-fields": true,
811        "x-umbral-fields-columns": Value::Array(columns),
812    })
813}
814
815/// `?include=fk1,fk2` — expand the named FK columns into their full
816/// related-row objects via the REST plugin's select_related-backed
817/// path. Only FK columns are valid (anything else 400s); the
818/// playground reads `x-umbral-include-fks` to render a multi-select
819/// of the candidate FK names. Mirrors the `fields_parameter` shape
820/// so the same UI machinery can drive both.
821fn include_parameter(model: &ModelMeta) -> Value {
822    // A hidden FK column is stripped from responses, so expanding it via
823    // `?include=` could never surface anything — drop it from the
824    // includable list for consistency with the schema + fields picker.
825    let fks: Vec<Value> = model
826        .fields
827        .iter()
828        .filter(|c| c.fk_target.is_some())
829        .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
830        .map(|c| Value::String(c.name.clone()))
831        .collect();
832    json!({
833        "name": "include",
834        "in": "query",
835        "required": false,
836        "description": "Comma-separated list of foreign-key columns to expand \
837                        in the response. Each named FK gets replaced with the \
838                        full related-row JSON object (one batched IN(...) query \
839                        per FK — no N+1). Unknown or non-FK names return a 400. \
840                        Example: `?include=user,billing_address`.",
841        "schema": { "type": "string" },
842        "x-umbral-include": true,
843        "x-umbral-include-fks": Value::Array(fks),
844    })
845}
846
847/// Playground-openapi-gaps #3 / gaps2 #79: emit the pagination query
848/// parameters that match the configured backend, not a hardcoded
849/// `page`/`page_size` pair.
850///
851/// - [`PaginationStyle::PageNumber`] → `page` + `page_size` (the common default)
852/// - [`PaginationStyle::LimitOffset`] → `limit` + `offset` (REST classic)
853/// - [`PaginationStyle::None`] / [`PaginationStyle::Custom`] → empty Vec
854///   (NoPagination has no URL params; unknown custom backends are opaque)
855fn pagination_parameters_for_style(style: umbral_rest::PaginationStyle) -> Vec<Value> {
856    match style {
857        umbral_rest::PaginationStyle::PageNumber => vec![
858            json!({
859                "name": "page",
860                "in": "query",
861                "required": false,
862                "description": "1-indexed page number. Defaults to 1 when omitted.",
863                "schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 },
864                "x-umbral-pagination": "page",
865            }),
866            json!({
867                "name": "page_size",
868                "in": "query",
869                "required": false,
870                "description": "Rows per page. Capped at 100. Default 20.",
871                "schema": {
872                    "type": "integer", "format": "int32",
873                    "minimum": 1, "maximum": 100, "default": 20,
874                },
875                "x-umbral-pagination": "page_size",
876            }),
877        ],
878        umbral_rest::PaginationStyle::LimitOffset => vec![
879            json!({
880                "name": "limit",
881                "in": "query",
882                "required": false,
883                "description": "Maximum rows to return. Defaults to the configured page size.",
884                "schema": { "type": "integer", "format": "int32", "minimum": 1 },
885                "x-umbral-pagination": "limit",
886            }),
887            json!({
888                "name": "offset",
889                "in": "query",
890                "required": false,
891                "description": "Number of rows to skip from the start of the result set. Defaults to 0.",
892                "schema": { "type": "integer", "format": "int32", "minimum": 0, "default": 0 },
893                "x-umbral-pagination": "offset",
894            }),
895        ],
896        umbral_rest::PaginationStyle::None | umbral_rest::PaginationStyle::Custom => vec![],
897    }
898}
899
900/// Build the OpenAPI `parameters` entries that document the
901/// query-string filters on a list endpoint.
902/// One entry per (column, lookup) pair.
903///
904/// Skips the primary key (filtering on `id` adds no value over the
905/// detail URL `/api/<table>/{id}`) and the columns whose type the
906/// filter parser can't model (none today, but the helper takes the
907/// stance so future opt-outs are a one-line change).
908fn filter_parameters(model: &ModelMeta) -> Vec<Value> {
909    let mut out: Vec<Value> = Vec::new();
910    for col in &model.fields {
911        if col.primary_key {
912            continue;
913        }
914        let lookups = umbral_rest::filtering::applicable_lookups(col);
915        for lookup in lookups {
916            let name = if lookup == "eq" {
917                col.name.clone()
918            } else {
919                format!("{}__{}", col.name, lookup)
920            };
921            out.push(filter_parameter(col, lookup, &name));
922        }
923    }
924    out
925}
926
927/// One OpenAPI parameter object for a single (column, lookup) pair.
928///
929/// - `__in` takes a CSV string: schema `type: string` with a
930///   description spelling out the format. (A proper `style: form` +
931///   `explode: false` array would be more correct OpenAPI but
932///   complicates client code.)
933/// - `__isnull` takes a boolean.
934/// - `__contains` / `__icontains` / `__startswith` take a string
935///   regardless of column type.
936/// - Range / equality lookups inherit the column's own type.
937fn filter_parameter(col: &Column, lookup: &str, name: &str) -> Value {
938    let (schema, description) = match lookup {
939        "in" => (
940            json!({ "type": "string" }),
941            format!(
942                "Comma-separated `{}` values; matches rows where the column is in the set.",
943                col.name,
944            ),
945        ),
946        "isnull" => (
947            json!({ "type": "boolean" }),
948            format!(
949                "`true` matches rows where `{}` IS NULL; `false` matches IS NOT NULL.",
950                col.name,
951            ),
952        ),
953        "contains" | "icontains" | "startswith" => {
954            let phrase = match lookup {
955                "contains" => "case-sensitive substring",
956                "icontains" => "case-insensitive substring",
957                "startswith" => "case-sensitive prefix",
958                _ => unreachable!(),
959            };
960            (
961                json!({ "type": "string" }),
962                format!(
963                    "Matches rows where `{}` contains the given {phrase}.",
964                    col.name
965                ),
966            )
967        }
968        // eq, ne, gte, lte, gt, lt — type-aligned with the column.
969        _ => {
970            let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
971            let mut schema_obj = Map::new();
972            schema_obj.insert("type".into(), Value::String(ty.into()));
973            if let Some(f) = format {
974                schema_obj.insert("format".into(), Value::String(f.into()));
975            }
976            let phrase = match lookup {
977                "eq" => "equals the value",
978                "ne" => "does not equal the value",
979                "gte" => "is greater than or equal to the value",
980                "lte" => "is less than or equal to the value",
981                "gt" => "is greater than the value",
982                "lt" => "is less than the value",
983                _ => "matches the value",
984            };
985            (
986                Value::Object(schema_obj),
987                format!("Matches rows where `{}` {phrase}.", col.name),
988            )
989        }
990    };
991
992    json!({
993        "name": name,
994        "in": "query",
995        "required": false,
996        "description": description,
997        "schema": schema,
998        "x-umbral-filter-field": col.name,
999        "x-umbral-filter-lookup": lookup,
1000    })
1001}
1002
1003fn collection_paths(table: &str, schema_name: &str, filter_params: &[Value]) -> Value {
1004    use umbral_rest::Action;
1005    let mut item = Map::new();
1006
1007    // `get` (list) — only when the resource exposes List. The list
1008    // operation's `parameters` array is omitted entirely when there are
1009    // no filters (matches the pre-fix spec shape and keeps Swagger UI
1010    // from rendering an empty Parameters section).
1011    if umbral_rest::action_exposed(table, &Action::List) {
1012        let mut get_op = Map::new();
1013        get_op.insert(
1014            "operationId".into(),
1015            Value::String(format!("list_{}", table)),
1016        );
1017        get_op.insert("tags".into(), json!([table]));
1018        if !filter_params.is_empty() {
1019            get_op.insert("parameters".into(), Value::Array(filter_params.to_vec()));
1020        }
1021        get_op.insert(
1022            "responses".into(),
1023            json!({
1024                "200": {
1025                    "description": "List of rows",
1026                    "content": {
1027                        "application/json": {
1028                            "schema": list_envelope(schema_name)
1029                        }
1030                    }
1031                }
1032            }),
1033        );
1034        item.insert("get".into(), Value::Object(get_op));
1035    }
1036
1037    // `post` (create) — only when the resource exposes Create. A
1038    // `views([List, Retrieve])` resource omits it entirely.
1039    if umbral_rest::action_exposed(table, &Action::Create) {
1040        item.insert(
1041            "post".into(),
1042            json!({
1043                "operationId": format!("create_{}", table),
1044                "tags": [table],
1045                "requestBody": {
1046                    "required": true,
1047                    "content": {
1048                        "application/json": {
1049                            "schema": schema_ref(schema_name)
1050                        }
1051                    }
1052                },
1053                "responses": {
1054                    "201": {
1055                        "description": "Row created",
1056                        "content": {
1057                            "application/json": {
1058                                "schema": schema_ref(schema_name)
1059                            }
1060                        }
1061                    },
1062                    "400": { "description": "Invalid input" }
1063                }
1064            }),
1065        );
1066    }
1067
1068    Value::Object(item)
1069}
1070
1071fn item_paths(table: &str, schema_name: &str, retrieve_query_params: &[Value]) -> Value {
1072    use umbral_rest::Action;
1073    let id_param = json!({
1074        "name": "id",
1075        "in": "path",
1076        "required": true,
1077        "schema": { "type": "string" }
1078    });
1079    let mut item = Map::new();
1080    item.insert("parameters".into(), json!([id_param]));
1081
1082    // `get` (retrieve) — only when exposed. Build the GET op separately
1083    // so its query params can be listed alongside the path-level
1084    // `id_param`. Path-level `parameters` apply to every method on the
1085    // item URL, so GET-only knobs (like `?fields=`) land on the
1086    // operation itself instead.
1087    if umbral_rest::action_exposed(table, &Action::Retrieve) {
1088        let mut get_op = Map::new();
1089        get_op.insert(
1090            "operationId".into(),
1091            Value::String(format!("retrieve_{}", table)),
1092        );
1093        get_op.insert("tags".into(), json!([table]));
1094        if !retrieve_query_params.is_empty() {
1095            get_op.insert(
1096                "parameters".into(),
1097                Value::Array(retrieve_query_params.to_vec()),
1098            );
1099        }
1100        get_op.insert(
1101            "responses".into(),
1102            json!({
1103                "200": {
1104                    "description": "Row found",
1105                    "content": {
1106                        "application/json": {
1107                            "schema": schema_ref(schema_name)
1108                        }
1109                    }
1110                },
1111                "404": { "description": "Not found" }
1112            }),
1113        );
1114        item.insert("get".into(), Value::Object(get_op));
1115    }
1116
1117    // `put` + `patch` (update) — gated together on the Update action.
1118    if umbral_rest::action_exposed(table, &Action::Update) {
1119        item.insert(
1120            "put".into(),
1121            json!({
1122                "operationId": format!("update_{}", table),
1123                "tags": [table],
1124                "requestBody": {
1125                    "required": true,
1126                    "content": {
1127                        "application/json": {
1128                            "schema": schema_ref(schema_name)
1129                        }
1130                    }
1131                },
1132                "responses": {
1133                    "200": {
1134                        "description": "Row updated",
1135                        "content": {
1136                            "application/json": {
1137                                "schema": schema_ref(schema_name)
1138                            }
1139                        }
1140                    },
1141                    "404": { "description": "Not found" }
1142                }
1143            }),
1144        );
1145        item.insert(
1146            "patch".into(),
1147            json!({
1148                "operationId": format!("partial_update_{}", table),
1149                "tags": [table],
1150                "requestBody": {
1151                    "required": true,
1152                    "content": {
1153                        "application/json": {
1154                            "schema": schema_ref(schema_name)
1155                        }
1156                    }
1157                },
1158                "responses": {
1159                    "200": {
1160                        "description": "Row partially updated",
1161                        "content": {
1162                            "application/json": {
1163                                "schema": schema_ref(schema_name)
1164                            }
1165                        }
1166                    },
1167                    "404": { "description": "Not found" }
1168                }
1169            }),
1170        );
1171    }
1172
1173    // `delete` (destroy) — only when exposed.
1174    if umbral_rest::action_exposed(table, &Action::Delete) {
1175        item.insert(
1176            "delete".into(),
1177            json!({
1178                "operationId": format!("destroy_{}", table),
1179                "tags": [table],
1180                "responses": {
1181                    "204": { "description": "Row deleted" },
1182                    "404": { "description": "Not found" }
1183                }
1184            }),
1185        );
1186    }
1187
1188    Value::Object(item)
1189}
1190
1191fn schema_ref(name: &str) -> Value {
1192    json!({ "$ref": format!("#/components/schemas/{}", name) })
1193}
1194
1195/// True when an OpenAPI Path Item carries at least one HTTP operation.
1196/// A path item that only has `parameters` (no `get`/`post`/… keys) is
1197/// dropped from the spec — that happens when `.views(...)` scopes out
1198/// every verb the URI would otherwise serve.
1199fn has_operations(path_item: &Value) -> bool {
1200    const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"];
1201    path_item
1202        .as_object()
1203        .is_some_and(|m| METHODS.iter().any(|verb| m.contains_key(*verb)))
1204}
1205
1206fn list_envelope(schema_name: &str) -> Value {
1207    json!({
1208        "type": "object",
1209        "properties": {
1210            "results": {
1211                "type": "array",
1212                "items": schema_ref(schema_name)
1213            },
1214            "count": { "type": "integer" }
1215        },
1216        "required": ["results", "count"]
1217    })
1218}
1219
1220// Test hooks: expose the URL helpers so the integration test can
1221// assert that `.at("/api/docs")` flows through to the right path
1222// strings without booting a second App.
1223#[doc(hidden)]
1224pub fn test_spec_url(p: &OpenApiPlugin) -> String {
1225    p.spec_url()
1226}
1227
1228#[doc(hidden)]
1229pub fn test_ui_route(p: &OpenApiPlugin) -> String {
1230    p.ui_route()
1231}
1232
1233// `pascal_case` replaced by `umbral_casing::pascal_case_from_ident` (imported
1234// above) in the gaps2 #77 consolidation refactor.
1235
1236#[cfg(test)]
1237mod tests {
1238    use super::*;
1239    use umbral::migrate::Column;
1240    use umbral::orm::SqlType;
1241
1242    fn base_col(name: &str, ty: SqlType) -> Column {
1243        Column {
1244            name: name.into(),
1245            ty,
1246            primary_key: false,
1247            nullable: false,
1248            fk_target: None,
1249            noform: false,
1250            db_constraint: true,
1251            noedit: false,
1252            is_string_repr: false,
1253            max_length: 0,
1254            choices: Vec::new(),
1255            choice_labels: Vec::new(),
1256            default: String::new(),
1257            is_multichoice: false,
1258            unique: false,
1259            on_delete: ::umbral::orm::FkAction::NoAction,
1260            on_update: ::umbral::orm::FkAction::NoAction,
1261            index: false,
1262            auto_now_add: false,
1263            auto_now: false,
1264            help: String::new(),
1265            example: String::new(),
1266            widget: None,
1267            supported_backends: Vec::new(),
1268            min: None,
1269            max: None,
1270            text_format: ::core::option::Option::None,
1271            slug_from: ::core::option::Option::None,
1272        }
1273    }
1274
1275    #[test]
1276    fn choices_render_as_openapi_enum_with_labels_extension() {
1277        let mut col = base_col("status", SqlType::Text);
1278        col.choices = vec!["draft".into(), "published".into(), "archived".into()];
1279        col.choice_labels = vec!["Draft".into(), "Published".into(), "Archived".into()];
1280        let schema = column_schema(&col);
1281        assert_eq!(schema["type"], "string");
1282        assert_eq!(
1283            schema["enum"],
1284            serde_json::json!(["draft", "published", "archived"])
1285        );
1286        assert_eq!(
1287            schema["x-umbral-choice-labels"],
1288            serde_json::json!(["Draft", "Published", "Archived"])
1289        );
1290    }
1291
1292    #[test]
1293    fn multichoice_skips_enum_and_uses_vendor_extension() {
1294        let mut col = base_col("tags", SqlType::Text);
1295        col.choices = vec!["rust".into(), "python".into()];
1296        col.is_multichoice = true;
1297        let schema = column_schema(&col);
1298        assert!(
1299            schema.get("enum").is_none(),
1300            "multichoice columns should not declare a flat enum (value is a CSV subset)"
1301        );
1302        assert_eq!(schema["x-umbral-multichoice"], true);
1303        assert_eq!(
1304            schema["x-umbral-choices"],
1305            serde_json::json!(["rust", "python"])
1306        );
1307    }
1308
1309    #[test]
1310    fn max_length_and_default_surface_as_standard_openapi_keys() {
1311        let mut col = base_col("title", SqlType::Text);
1312        col.max_length = 50;
1313        col.default = "untitled".into();
1314        let schema = column_schema(&col);
1315        assert_eq!(schema["maxLength"], 50);
1316        assert_eq!(schema["default"], "untitled");
1317    }
1318
1319    #[test]
1320    fn fk_target_emits_vendor_extension_for_playground_navigation() {
1321        let mut col = base_col("author_id", SqlType::ForeignKey);
1322        col.fk_target = Some("auth_user".into());
1323        let schema = column_schema(&col);
1324        assert_eq!(schema["type"], "integer");
1325        assert_eq!(schema["format"], "int64");
1326        assert_eq!(schema["x-umbral-fk-target"], "auth_user");
1327    }
1328
1329    #[test]
1330    fn noform_renders_as_read_only_and_carries_vendor_extension() {
1331        // `noform` is the API-readOnly semantic — never accepted in
1332        // any request body, server fills it in. Maps to OpenAPI
1333        // `readOnly: true` so Swagger / generated clients honour it
1334        // on POST and PUT/PATCH alike.
1335        let mut col = base_col("internal_token", SqlType::Text);
1336        col.noform = true;
1337        let schema = column_schema(&col);
1338        assert_eq!(schema["readOnly"], true);
1339        assert_eq!(schema["x-umbral-noform"], true);
1340    }
1341
1342    #[test]
1343    fn noedit_does_NOT_render_as_read_only() {
1344        // Decoupled from API contract: `noedit` is purely an admin
1345        // EDIT-form hint. The field stays writable in the spec so a
1346        // required `noedit` field (e.g. `email` you can set at
1347        // signup but not change later) still gets autofilled on POST
1348        // by the playground and accepted by the REST plugin on CREATE.
1349        let mut col = base_col("email", SqlType::Text);
1350        col.noedit = true;
1351        let schema = column_schema(&col);
1352        assert!(
1353            schema.get("readOnly").is_none(),
1354            "noedit must NOT contaminate the API request-body contract; \
1355             got readOnly in schema: {schema:?}"
1356        );
1357        // Surface it as a vendor extension so aware clients can
1358        // still grey the field on PUT/PATCH if they want.
1359        assert_eq!(schema["x-umbral-noedit"], true);
1360    }
1361
1362    #[test]
1363    fn plain_column_keeps_minimal_schema_no_extensions() {
1364        let col = base_col("body", SqlType::Text);
1365        let schema = column_schema(&col);
1366        let obj = schema.as_object().expect("object");
1367        assert_eq!(
1368            obj.len(),
1369            1,
1370            "plain column should only have `type`: {obj:?}"
1371        );
1372        assert_eq!(schema["type"], "string");
1373    }
1374
1375    /// Playground-openapi-gaps item 5: `#[umbral(help = "...")]`
1376    /// emits as the standard OpenAPI `description` so Swagger UI
1377    /// and any generated client picks it up. Empty help leaves the
1378    /// key absent.
1379    #[test]
1380    fn help_attribute_flows_to_openapi_description() {
1381        let mut col = base_col("status", SqlType::Text);
1382        col.help = "Workflow step. Set by editors on Save.".to_string();
1383        let schema = column_schema(&col);
1384        assert_eq!(
1385            schema["description"], "Workflow step. Set by editors on Save.",
1386            "help should round-trip to OpenAPI description; got: {schema:?}",
1387        );
1388    }
1389
1390    #[test]
1391    fn empty_help_omits_description() {
1392        let col = base_col("body", SqlType::Text);
1393        let schema = column_schema(&col);
1394        assert!(
1395            schema.get("description").is_none(),
1396            "empty help should omit description; got: {schema:?}",
1397        );
1398    }
1399
1400    /// Playground-openapi-gaps item 6: `#[umbral(example = "...")]`
1401    /// emits as OpenAPI `example` on the property schema. Empty
1402    /// leaves the key absent.
1403    #[test]
1404    fn example_attribute_flows_to_openapi_example() {
1405        let mut col = base_col("status", SqlType::Text);
1406        col.example = "published".to_string();
1407        let schema = column_schema(&col);
1408        assert_eq!(
1409            schema["example"], "published",
1410            "example should round-trip; got: {schema:?}",
1411        );
1412    }
1413
1414    #[test]
1415    fn empty_example_omits_example() {
1416        let col = base_col("body", SqlType::Text);
1417        let schema = column_schema(&col);
1418        assert!(
1419            schema.get("example").is_none(),
1420            "empty example should omit example key; got: {schema:?}",
1421        );
1422    }
1423
1424    // ----------------------------------------------------------------- //
1425    // Filter parameter emission                                          //
1426    // ----------------------------------------------------------------- //
1427
1428    fn note_model() -> ModelMeta {
1429        let mut id = base_col("id", SqlType::BigInt);
1430        id.primary_key = true;
1431        let mut published_at = base_col("published_at", SqlType::Timestamptz);
1432        published_at.nullable = true;
1433        ModelMeta {
1434            name: "Note".to_string(),
1435            table: "note".to_string(),
1436            fields: vec![
1437                id,
1438                base_col("title", SqlType::Text),
1439                base_col("views", SqlType::Integer),
1440                published_at,
1441            ],
1442            display: "Note".to_string(),
1443            icon: "database".to_string(),
1444            database: None,
1445            singleton: false,
1446            unique_together: Vec::new(),
1447            indexes: Vec::new(),
1448            ordering: Vec::new(),
1449            m2m_relations: Vec::new(),
1450            soft_delete: false,
1451            app_label: "app".to_string(),
1452        }
1453    }
1454
1455    #[test]
1456    fn filter_parameters_skips_primary_key() {
1457        let params = filter_parameters(&note_model());
1458        let names: Vec<&str> = params.iter().map(|p| p["name"].as_str().unwrap()).collect();
1459        assert!(
1460            !names.iter().any(|n| *n == "id" || n.starts_with("id__")),
1461            "PK column should be skipped; got {names:?}",
1462        );
1463    }
1464
1465    #[test]
1466    fn filter_parameters_eq_uses_bare_column_name_no_suffix() {
1467        let params = filter_parameters(&note_model());
1468        let bare_title = params
1469            .iter()
1470            .find(|p| p["name"] == "title")
1471            .expect("title eq parameter should be present");
1472        assert_eq!(bare_title["x-umbral-filter-lookup"], "eq");
1473        assert_eq!(bare_title["x-umbral-filter-field"], "title");
1474        assert_eq!(bare_title["schema"]["type"], "string");
1475    }
1476
1477    #[test]
1478    fn filter_parameters_in_is_string_typed_with_csv_description() {
1479        let params = filter_parameters(&note_model());
1480        let title_in = params
1481            .iter()
1482            .find(|p| p["name"] == "title__in")
1483            .expect("title__in parameter should be present");
1484        assert_eq!(title_in["schema"]["type"], "string");
1485        assert!(
1486            title_in["description"]
1487                .as_str()
1488                .unwrap()
1489                .to_lowercase()
1490                .contains("comma"),
1491            "__in description should mention the comma-separated format",
1492        );
1493    }
1494
1495    #[test]
1496    fn filter_parameters_isnull_only_on_nullable_columns() {
1497        let params = filter_parameters(&note_model());
1498        let isnull_params: Vec<&str> = params
1499            .iter()
1500            .filter_map(|p| p["name"].as_str())
1501            .filter(|n| n.ends_with("__isnull"))
1502            .collect();
1503        assert_eq!(
1504            isnull_params,
1505            vec!["published_at__isnull"],
1506            "isnull lookup should only appear for nullable columns; got {isnull_params:?}",
1507        );
1508    }
1509
1510    #[test]
1511    fn filter_parameters_range_lookups_only_on_numeric_or_temporal() {
1512        let params = filter_parameters(&note_model());
1513        let has_gte = |field: &str| params.iter().any(|p| p["name"] == format!("{field}__gte"));
1514        assert!(has_gte("views"), "integer column gets gte");
1515        assert!(has_gte("published_at"), "timestamp column gets gte");
1516        assert!(
1517            !has_gte("title"),
1518            "text column must NOT get gte; got {params:?}",
1519        );
1520    }
1521
1522    #[test]
1523    fn filter_parameters_string_lookups_only_on_text() {
1524        let params = filter_parameters(&note_model());
1525        let has_contains = |field: &str| {
1526            params
1527                .iter()
1528                .any(|p| p["name"] == format!("{field}__contains"))
1529        };
1530        assert!(has_contains("title"), "text column gets contains");
1531        assert!(
1532            !has_contains("views"),
1533            "integer column must NOT get contains; got {params:?}",
1534        );
1535    }
1536
1537    #[test]
1538    fn collection_paths_omits_parameters_array_when_no_filters() {
1539        let value = collection_paths("note", "Note", &[]);
1540        let get_op = &value["get"];
1541        assert!(
1542            get_op.get("parameters").is_none(),
1543            "no filters → no parameters key; got {get_op:?}",
1544        );
1545    }
1546
1547    #[test]
1548    fn collection_paths_includes_parameters_when_filters_present() {
1549        let filter_params = filter_parameters(&note_model());
1550        let value = collection_paths("note", "Note", &filter_params);
1551        let params = value["get"]["parameters"]
1552            .as_array()
1553            .expect("parameters array should be present when filters land");
1554        assert!(!params.is_empty());
1555        assert!(
1556            params.iter().all(|p| p["in"] == "query"),
1557            "every filter parameter is in: query",
1558        );
1559    }
1560
1561    /// BUG-81: the `?fields=` sparse-fieldset parameter is built
1562    /// with the model's columns listed under the
1563    /// `x-umbral-fields-columns` vendor extension so the playground
1564    /// can render a multi-select.
1565    #[test]
1566    fn fields_parameter_lists_model_columns() {
1567        let param = fields_parameter(&note_model());
1568        assert_eq!(param["name"], "fields");
1569        assert_eq!(param["in"], "query");
1570        assert_eq!(param["x-umbral-fields"], true);
1571        let cols = param["x-umbral-fields-columns"]
1572            .as_array()
1573            .expect("x-umbral-fields-columns should be a list");
1574        let names: Vec<&str> = cols.iter().filter_map(|v| v.as_str()).collect();
1575        assert!(names.contains(&"title"));
1576        assert!(names.contains(&"views"));
1577        assert!(
1578            !names.is_empty(),
1579            "every column should land in the enum so the playground can offer it",
1580        );
1581    }
1582
1583    /// The retrieve op also documents `?fields=` so the playground
1584    /// renders the same param on GET /resource/{id}.
1585    #[test]
1586    fn item_paths_advertises_fields_query_param_on_retrieve() {
1587        let value = item_paths("note", "Note", &[fields_parameter(&note_model())]);
1588        let get_params = value["get"]["parameters"]
1589            .as_array()
1590            .expect("retrieve op should carry its query parameters");
1591        assert!(
1592            get_params.iter().any(|p| p["name"] == "fields"),
1593            "fields parameter should be on the retrieve op; got {get_params:?}",
1594        );
1595    }
1596
1597    /// Playground-openapi-gaps #2: FK columns gain an
1598    /// `x-umbral-fk-ref` JSON pointer when the target schema is
1599    /// known. Generated clients that follow vendor extensions can
1600    /// navigate Post.author → User.
1601    #[test]
1602    fn fk_column_emits_schema_ref_when_target_known() {
1603        let mut col = base_col("author", SqlType::ForeignKey);
1604        col.fk_target = Some("auth_user".into());
1605        let mut map = std::collections::HashMap::new();
1606        map.insert("auth_user".to_string(), "AuthUser".to_string());
1607        let schema = column_schema_with_refs(&col, &map);
1608        assert_eq!(
1609            schema["x-umbral-fk-target"], "auth_user",
1610            "the table-name vendor extension stays for backward compat",
1611        );
1612        assert_eq!(
1613            schema["x-umbral-fk-ref"], "#/components/schemas/AuthUser",
1614            "the JSON pointer to the target schema should be emitted",
1615        );
1616    }
1617
1618    #[test]
1619    fn fk_column_without_known_target_omits_schema_ref() {
1620        let mut col = base_col("author", SqlType::ForeignKey);
1621        col.fk_target = Some("unknown_table".into());
1622        let map = std::collections::HashMap::new();
1623        let schema = column_schema_with_refs(&col, &map);
1624        assert!(
1625            schema.get("x-umbral-fk-ref").is_none(),
1626            "unknown FK target → no ref emitted; got: {schema:?}",
1627        );
1628    }
1629
1630    /// M2M relations get a property entry on the model schema
1631    /// (`array of integer` ids) plus vendor extensions naming the
1632    /// target schema. Without this the playground / generated
1633    /// clients have no way to know the model has a many-to-many
1634    /// slot.
1635    #[test]
1636    fn m2m_relation_lands_in_model_schema_with_target_extension() {
1637        let mut model = note_model();
1638        model.m2m_relations.push(umbral::migrate::M2MRelation {
1639            field_name: "tags".to_string(),
1640            target_table: "tag".to_string(),
1641            target_name: "Tag".to_string(),
1642        });
1643        // table_to_schema mirrors what `model_schemas` builds at
1644        // spec-emit time; pre-seed with the M2M target so the
1645        // vendor `x-umbral-m2m-target-ref` JSON pointer is set.
1646        let mut tts = std::collections::HashMap::new();
1647        tts.insert("tag".to_string(), "Tag".to_string());
1648        let schema = model_schema(&model, &tts);
1649        let tags_prop = &schema["properties"]["tags"];
1650        assert_eq!(tags_prop["type"], "array");
1651        assert_eq!(tags_prop["items"]["type"], "integer");
1652        assert_eq!(tags_prop["x-umbral-m2m"], true);
1653        assert_eq!(tags_prop["x-umbral-m2m-target"], "Tag");
1654        assert_eq!(tags_prop["x-umbral-m2m-target-table"], "tag");
1655        assert_eq!(
1656            tags_prop["x-umbral-m2m-target-ref"],
1657            "#/components/schemas/Tag",
1658        );
1659        // Not in `required` — M2M slots are always optional.
1660        let required = schema["required"].as_array();
1661        if let Some(req) = required {
1662            assert!(!req.iter().any(|v| v == "tags"));
1663        }
1664    }
1665
1666    /// `auto_now_add` (created_at) and `auto_now` (updated_at)
1667    /// fields are server-populated — the framework stamps
1668    /// `Utc::now()` when the body omits them. The OpenAPI
1669    /// schema must reflect that: the columns drop out of the
1670    /// `required` array AND gain vendor extensions so the
1671    /// playground can render them as "server fills this in"
1672    /// instead of marking them as missing inputs.
1673    #[test]
1674    fn auto_now_columns_are_optional_in_the_request_schema() {
1675        let mut model = note_model();
1676        let mut created = base_col("created_at", SqlType::Timestamptz);
1677        created.auto_now_add = true;
1678        let mut updated = base_col("updated_at", SqlType::Timestamptz);
1679        updated.auto_now = true;
1680        model.fields.push(created);
1681        model.fields.push(updated);
1682
1683        let schema = model_schema(&model, &std::collections::HashMap::new());
1684
1685        // Vendor extensions: aware clients flag these as
1686        // server-populated. Both extensions present, keyed
1687        // under the right column.
1688        assert_eq!(
1689            schema["properties"]["created_at"]["x-umbral-auto-now-add"],
1690            true
1691        );
1692        assert_eq!(
1693            schema["properties"]["updated_at"]["x-umbral-auto-now"],
1694            true
1695        );
1696
1697        // NOT marked `readOnly` — the client can still send an
1698        // explicit timestamp if they want. `readOnly` is reserved
1699        // for `noform` columns the framework drops from bodies.
1700        assert!(
1701            schema["properties"]["created_at"].get("readOnly").is_none(),
1702            "auto_now_add must not be readOnly; got {}",
1703            schema["properties"]["created_at"],
1704        );
1705        assert!(
1706            schema["properties"]["updated_at"].get("readOnly").is_none(),
1707            "auto_now must not be readOnly; got {}",
1708            schema["properties"]["updated_at"],
1709        );
1710
1711        // And dropped from `required` so a POST that omits them
1712        // doesn't 400 with "this field is required."
1713        let required = schema["required"].as_array().expect("required array");
1714        let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1715        assert!(
1716            !names.contains(&"created_at"),
1717            "auto_now_add should drop out of required; got {names:?}",
1718        );
1719        assert!(
1720            !names.contains(&"updated_at"),
1721            "auto_now should drop out of required; got {names:?}",
1722        );
1723    }
1724
1725    /// gaps2 #79: pagination_parameters_for_style emits the correct
1726    /// params per pagination class, not always `page`/`page_size`.
1727    #[test]
1728    fn pagination_parameters_per_style() {
1729        use umbral_rest::PaginationStyle;
1730
1731        // NoPagination → no params.
1732        let none_params = pagination_parameters_for_style(PaginationStyle::None);
1733        assert!(
1734            none_params.is_empty(),
1735            "NoPagination should emit no pagination params; got {none_params:?}"
1736        );
1737
1738        // Custom → no params (opaque).
1739        let custom_params = pagination_parameters_for_style(PaginationStyle::Custom);
1740        assert!(
1741            custom_params.is_empty(),
1742            "Custom pagination should emit no params; got {custom_params:?}"
1743        );
1744
1745        // PageNumber → page + page_size.
1746        let page_params = pagination_parameters_for_style(PaginationStyle::PageNumber);
1747        assert_eq!(page_params.len(), 2, "PageNumber should emit 2 params");
1748        assert_eq!(page_params[0]["name"], "page");
1749        assert_eq!(page_params[0]["in"], "query");
1750        assert_eq!(page_params[0]["schema"]["type"], "integer");
1751        assert_eq!(page_params[0]["schema"]["minimum"], 1);
1752        assert_eq!(page_params[0]["schema"]["default"], 1);
1753        assert_eq!(page_params[0]["x-umbral-pagination"], "page");
1754        assert_eq!(page_params[1]["name"], "page_size");
1755        assert_eq!(page_params[1]["schema"]["maximum"], 100);
1756        assert_eq!(page_params[1]["x-umbral-pagination"], "page_size");
1757
1758        // LimitOffset → limit + offset.
1759        let lo_params = pagination_parameters_for_style(PaginationStyle::LimitOffset);
1760        assert_eq!(lo_params.len(), 2, "LimitOffset should emit 2 params");
1761        assert_eq!(lo_params[0]["name"], "limit");
1762        assert_eq!(lo_params[0]["x-umbral-pagination"], "limit");
1763        assert_eq!(lo_params[1]["name"], "offset");
1764        assert_eq!(lo_params[1]["x-umbral-pagination"], "offset");
1765        assert_eq!(lo_params[1]["schema"]["minimum"], 0);
1766    }
1767}