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