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