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