1use 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#[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 allow_in_prod: bool,
53 swagger_asset_base: String,
61}
62
63pub const DEFAULT_SWAGGER_ASSET_BASE: &str = "https://unpkg.com/swagger-ui-dist@5.17.14";
67
68const 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 pub fn swagger_asset_base(mut self, base: impl Into<String>) -> Self {
105 self.swagger_asset_base = base.into();
106 self
107 }
108
109 pub fn allow_in_prod(mut self) -> Self {
113 self.allow_in_prod = true;
114 self
115 }
116
117 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 pub fn title(mut self, s: impl Into<String>) -> Self {
132 self.title = s.into();
133 self
134 }
135
136 pub fn version(mut self, s: impl Into<String>) -> Self {
138 self.version = s.into();
139 self
140 }
141
142 pub fn description(mut self, s: impl Into<String>) -> Self {
148 self.description = Some(s.into());
149 self
150 }
151
152 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 !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
191static CONFIG: OnceLock<OpenApiPlugin> = OnceLock::new();
195
196pub 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 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 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 if self.base_path != "/" {
253 router = router.route(&self.base_path, get(swagger_ui_handler));
254 }
255 router
256 }
257}
258
259async fn spec_handler() -> Response {
264 let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
265 let spec = build_spec(cfg);
266 (
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 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
298fn build_spec(cfg: &OpenApiPlugin) -> Value {
305 let mut schemas = Map::new();
306 let mut paths = Map::new();
307
308 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 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 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 let mut list_params = Vec::new();
353 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 list_params.push(fields_parameter(&model));
365 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 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 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 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 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 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 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
462fn 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 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 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 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 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 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
599fn 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 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 if !col.help.is_empty() {
643 obj.insert("description".into(), Value::String(col.help.clone()));
644 }
645 if !col.example.is_empty() {
649 obj.insert("example".into(), Value::String(col.example.clone()));
650 }
651 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 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 obj.insert("pattern".into(), Value::String("^[A-Za-z0-9_-]+$".into()));
681 }
682 _ => {}
683 }
684 }
685 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 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 if col.is_string_repr {
735 obj.insert("x-umbral-string-repr".into(), Value::Bool(true));
736 }
737 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 obj.insert("x-umbral-noform".into(), Value::Bool(true));
771 }
772 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 SqlType::Json => ("object", None),
800 SqlType::Array(_) => ("array", None),
807 SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => ("string", None),
812 SqlType::FullText => ("string", None),
815 SqlType::Xml | SqlType::Ltree | SqlType::Bit => ("string", None),
818 SqlType::ForeignKey => ("integer", Some("int64")),
821 SqlType::Bytes => ("array", Some("byte")),
828 SqlType::Decimal => ("string", Some("decimal")),
834 }
835}
836
837fn 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
862fn fields_parameter(model: &ModelMeta) -> Value {
873 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
896fn include_parameter(model: &ModelMeta) -> Value {
903 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
928fn 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
981fn 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
1008fn 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 _ => {
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 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 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 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 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 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
1276fn 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#[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#[cfg(test)]
1318mod tests {
1319 use super::*;
1320 use umbral::migrate::Column;
1321 use umbral::orm::SqlType;
1322
1323 #[test]
1326 fn swagger_asset_base_is_pinned_and_configurable() {
1327 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 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 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 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 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 #[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 #[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 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(¬e_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(¬e_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(¬e_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(¬e_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(¬e_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(¬e_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(¬e_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 #[test]
1673 fn fields_parameter_lists_model_columns() {
1674 let param = fields_parameter(¬e_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 #[test]
1693 fn item_paths_advertises_fields_query_param_on_retrieve() {
1694 let value = item_paths("note", "Note", &[fields_parameter(¬e_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 #[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 #[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 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 let required = schema["required"].as_array();
1768 if let Some(req) = required {
1769 assert!(!req.iter().any(|v| v == "tags"));
1770 }
1771 }
1772
1773 #[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 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 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 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 #[test]
1835 fn pagination_parameters_per_style() {
1836 use umbral_rest::PaginationStyle;
1837
1838 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 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 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 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}