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