1use std::sync::OnceLock;
29
30pub mod client_gen;
31
32use serde_json::{Map, Value, json};
33use umbral::migrate::{Column, ModelMeta};
34use umbral::orm::SqlType;
35use umbral::prelude::*;
36use umbral::web::{Html, IntoResponse, Json, Response, StatusCode, header};
37use umbral_casing::pascal_case_from_ident;
38
39const SWAGGER_UI_HTML: &str = include_str!("../templates/swagger_ui.html");
40
41#[derive(Debug, Clone)]
43pub struct OpenApiPlugin {
44 base_path: String,
45 title: String,
46 version: String,
47 description: Option<String>,
48 extra_exclude: Vec<String>,
49 allow_in_prod: bool,
55 swagger_asset_base: String,
63}
64
65pub const DEFAULT_SWAGGER_ASSET_BASE: &str = "https://unpkg.com/swagger-ui-dist@5.17.14";
69
70const SWAGGER_CSS_SRI: &str =
79 "sha384-wxLW6kwyHktdDGr6Pv1zgm/VGJh99lfUbzSn6HNHBENZlCN7W602k9VkGdxuFvPn";
80const SWAGGER_JS_SRI: &str =
81 "sha384-wmyclcVGX/WhUkdkATwhaK1X1JtiNrr2EoYJ+diV3vj4v6OC5yCeSu+yW13SYJep";
82
83impl Default for OpenApiPlugin {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl OpenApiPlugin {
90 pub fn new() -> Self {
91 Self {
92 base_path: "/openapi".to_string(),
93 title: "umbral API".to_string(),
94 version: "0.0.1".to_string(),
95 description: None,
96 extra_exclude: Vec::new(),
97 allow_in_prod: false,
98 swagger_asset_base: DEFAULT_SWAGGER_ASSET_BASE.to_string(),
99 }
100 }
101
102 pub fn swagger_asset_base(mut self, base: impl Into<String>) -> Self {
107 self.swagger_asset_base = base.into();
108 self
109 }
110
111 pub fn allow_in_prod(mut self) -> Self {
115 self.allow_in_prod = true;
116 self
117 }
118
119 pub fn at(mut self, path: &str) -> Self {
123 let trimmed = path.trim_end_matches('/');
124 self.base_path = if trimmed.is_empty() {
125 "/".to_string()
126 } else {
127 trimmed.to_string()
128 };
129 self
130 }
131
132 pub fn title(mut self, s: impl Into<String>) -> Self {
134 self.title = s.into();
135 self
136 }
137
138 pub fn version(mut self, s: impl Into<String>) -> Self {
140 self.version = s.into();
141 self
142 }
143
144 pub fn description(mut self, s: impl Into<String>) -> Self {
150 self.description = Some(s.into());
151 self
152 }
153
154 pub fn exclude<I, S>(mut self, tables: I) -> Self
157 where
158 I: IntoIterator<Item = S>,
159 S: Into<String>,
160 {
161 for t in tables {
162 self.extra_exclude.push(t.into());
163 }
164 self
165 }
166
167 fn is_exposed(&self, table: &str) -> bool {
168 !self.extra_exclude.iter().any(|t| t == table)
174 }
175
176 fn spec_url(&self) -> String {
177 if self.base_path == "/" {
178 "/openapi.json".to_string()
179 } else {
180 format!("{}/openapi.json", self.base_path)
181 }
182 }
183
184 fn ui_route(&self) -> String {
185 if self.base_path == "/" {
186 "/".to_string()
187 } else {
188 format!("{}/", self.base_path)
189 }
190 }
191}
192
193static CONFIG: OnceLock<OpenApiPlugin> = OnceLock::new();
197
198pub fn spec_url() -> Option<String> {
211 CONFIG.get().map(|cfg| cfg.spec_url())
212}
213
214impl Plugin for OpenApiPlugin {
215 fn name(&self) -> &'static str {
216 "openapi"
217 }
218
219 fn dependencies(&self) -> &'static [&'static str] {
220 &["rest"]
221 }
222
223 fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {
224 vec![Box::new(GenClientCommand)]
225 }
226
227 fn routes(&self) -> Router {
228 let is_prod = matches!(
231 umbral::settings::get_opt().map(|s| &s.environment),
232 Some(umbral::Environment::Prod)
233 );
234 if is_prod && !self.allow_in_prod {
235 tracing::warn!(
236 "umbral-openapi: not mounting in Environment::Prod (the OpenAPI spec maps your \
237 entire API surface for unauthenticated callers). Call \
238 OpenApiPlugin::new().allow_in_prod() to override, ideally behind a firewall.",
239 );
240 return Router::new();
241 }
242 let _ = CONFIG.set(self.clone());
243 umbral::routes::init_openapi_spec_url(self.spec_url());
248 let mut router = Router::new()
249 .route(&self.spec_url(), get(spec_handler))
250 .route(&self.ui_route(), get(swagger_ui_handler));
251 if self.base_path != "/" {
259 router = router.route(&self.base_path, get(swagger_ui_handler));
260 }
261 router
262 }
263}
264
265async fn spec_handler() -> Response {
270 let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
271 let spec = build_spec(cfg);
272 (
275 StatusCode::OK,
276 [(header::CONTENT_TYPE, "application/json")],
277 Json(spec),
278 )
279 .into_response()
280}
281
282async fn swagger_ui_handler() -> Response {
283 let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
284 let is_default = cfg.swagger_asset_base == DEFAULT_SWAGGER_ASSET_BASE;
288 let (css_integrity, js_integrity) = if is_default {
289 (
290 format!(" integrity=\"{SWAGGER_CSS_SRI}\""),
291 format!(" integrity=\"{SWAGGER_JS_SRI}\""),
292 )
293 } else {
294 (String::new(), String::new())
295 };
296 let body = SWAGGER_UI_HTML
297 .replace("{ASSET_BASE}", &cfg.swagger_asset_base)
298 .replace("{CSS_INTEGRITY}", &css_integrity)
299 .replace("{JS_INTEGRITY}", &js_integrity)
300 .replace("{SPEC_URL}", &cfg.spec_url());
301 Html(body).into_response()
302}
303
304fn build_spec(cfg: &OpenApiPlugin) -> Value {
311 let mut schemas = Map::new();
312 let mut paths = Map::new();
313
314 let mut table_to_schema: std::collections::HashMap<String, String> =
322 std::collections::HashMap::new();
323 for plugin in umbral::migrate::registered_plugins() {
324 for model in umbral::migrate::models_for_plugin(&plugin) {
325 table_to_schema.insert(model.table.clone(), pascal_case_from_ident(&model.name));
326 }
327 }
328
329 let rest_base = umbral_rest::registered_base_path().to_owned();
333
334 for plugin in umbral::migrate::registered_plugins() {
335 for model in umbral::migrate::models_for_plugin(&plugin) {
336 if !umbral_rest::is_exposed(&model.table) {
345 continue;
346 }
347 if !cfg.is_exposed(&model.table) {
348 continue;
349 }
350 let schema_name = pascal_case_from_ident(&model.name);
351 schemas.insert(schema_name.clone(), model_schema(&model, &table_to_schema));
352 let mut list_params = Vec::new();
359 list_params.extend(pagination_parameters_for_style(
363 umbral_rest::registered_pagination_style(),
364 ));
365 if umbral_rest::search_enabled_for(&model.table) {
366 list_params.push(search_parameter());
367 }
368 list_params.push(fields_parameter(&model));
371 if model.fields.iter().any(|c| c.fk_target.is_some()) {
376 list_params.push(include_parameter(&model));
377 }
378 if umbral_rest::filters_enabled_for(&model.table) {
379 list_params.extend(filter_parameters(&model));
380 }
381 let collection = collection_paths(&model.table, &schema_name, &list_params);
385 if has_operations(&collection) {
386 paths.insert(format!("{}/{}/", rest_base, model.table), collection);
387 }
388 let mut item_params = vec![fields_parameter(&model)];
392 if model.fields.iter().any(|c| c.fk_target.is_some()) {
393 item_params.push(include_parameter(&model));
394 }
395 let item = item_paths(&model.table, &schema_name, &item_params);
399 if has_operations(&item) {
400 paths.insert(format!("{}/{}/{{id}}", rest_base, model.table), item);
401 }
402 }
403 }
404
405 if let Some(entries) = umbral::routes::registered_openapi_paths() {
411 for (path, item) in entries {
412 paths.insert(path.clone(), item.clone());
413 }
414 }
415
416 for action in umbral_rest::registered_action_schemas() {
421 let path = if action.detail {
422 format!(
423 "{}/{}/{{id}}/{}/",
424 action.base_path, action.table, action.name
425 )
426 } else {
427 format!("{}/{}/{}/", action.base_path, action.table, action.name)
428 };
429 paths.insert(path, action_path_item(&action));
430 }
431
432 let mut info = Map::new();
433 info.insert("title".into(), Value::String(cfg.title.clone()));
434 info.insert("version".into(), Value::String(cfg.version.clone()));
435 if let Some(desc) = &cfg.description {
436 info.insert("description".into(), Value::String(desc.clone()));
437 }
438
439 let mut security_schemes = Map::new();
446 let mut security: Vec<Value> = Vec::new();
447 for (name, scheme) in umbral_rest::registered_security_schemes() {
448 security.push(json!({ name.clone(): [] }));
449 security_schemes.insert(name, scheme);
450 }
451 let mut components = Map::new();
452 components.insert("schemas".into(), Value::Object(schemas));
453 if !security_schemes.is_empty() {
454 components.insert("securitySchemes".into(), Value::Object(security_schemes));
455 }
456
457 let mut document = Map::new();
458 document.insert("openapi".into(), Value::String("3.0.3".into()));
459 document.insert("info".into(), Value::Object(info));
460 document.insert("paths".into(), Value::Object(paths));
461 document.insert("components".into(), Value::Object(components));
462 if !security.is_empty() {
463 document.insert("security".into(), Value::Array(security));
464 }
465 Value::Object(document)
466}
467
468fn action_path_item(a: &umbral_rest::ActionSchema) -> Value {
472 let mut op = Map::new();
473 op.insert(
474 "operationId".into(),
475 Value::String(format!("{}_{}", a.table, a.name)),
476 );
477 op.insert("tags".into(), json!([a.table]));
478 op.insert(
479 "summary".into(),
480 Value::String(format!("`{}` action on {}", a.name, a.table)),
481 );
482 if a.detail {
483 op.insert(
484 "parameters".into(),
485 json!([{
486 "name": "id", "in": "path", "required": true,
487 "schema": { "type": "string" },
488 "description": "Primary key of the target row"
489 }]),
490 );
491 }
492 if let Some(input) = &a.input_schema {
493 op.insert(
494 "requestBody".into(),
495 json!({ "required": true, "content": { "application/json": { "schema": input } } }),
496 );
497 }
498 let mut ok = Map::new();
499 ok.insert("description".into(), Value::String("Action result".into()));
500 if let Some(output) = &a.output_schema {
501 ok.insert(
502 "content".into(),
503 json!({ "application/json": { "schema": output } }),
504 );
505 }
506 op.insert("responses".into(), json!({ "200": Value::Object(ok) }));
507
508 let mut item = Map::new();
509 item.insert(a.method.to_lowercase(), Value::Object(op));
510 Value::Object(item)
511}
512
513fn model_schema(
514 model: &ModelMeta,
515 table_to_schema: &std::collections::HashMap<String, String>,
516) -> Value {
517 let mut properties = Map::new();
518 let mut required: Vec<Value> = Vec::new();
519 for col in &model.fields {
520 let write_only = umbral_rest::is_write_only(&model.table, &col.name);
531 if umbral_rest::is_hidden(&model.table, &col.name) && !write_only {
532 continue;
533 }
534 let mut schema = column_schema_with_refs(col, table_to_schema);
535 if write_only {
536 if let Some(obj) = schema.as_object_mut() {
537 obj.insert("writeOnly".to_string(), Value::Bool(true));
538 }
539 }
540 properties.insert(col.name.clone(), schema);
541 if umbral_rest::is_conditionally_visible(&model.table, &col.name) {
557 continue;
558 }
559 if !col.nullable && !col.primary_key && !col.auto_now && !col.auto_now_add && !col.noform {
560 required.push(Value::String(col.name.clone()));
561 }
562 }
563 for rel in &model.m2m_relations {
570 let target_schema = table_to_schema
571 .get(&rel.target_table)
572 .cloned()
573 .unwrap_or_else(|| pascal_case_from_ident(&rel.target_name));
574 let mut prop = serde_json::Map::new();
575 prop.insert("type".into(), Value::String("array".into()));
576 let (item_ty, item_fmt) = umbral::migrate::pk_meta_for_table(&rel.target_table)
579 .map(|(_, pk_ty)| openapi_type(pk_ty))
580 .unwrap_or(("integer", Some("int64")));
581 let items = match item_fmt {
582 Some(f) => json!({ "type": item_ty, "format": f }),
583 None => json!({ "type": item_ty }),
584 };
585 prop.insert("items".into(), items);
586 prop.insert(
587 "description".into(),
588 Value::String(format!(
589 "Many-to-many relation to {}. Send an array of child ids on \
590 create / update; the framework writes the junction table.",
591 target_schema,
592 )),
593 );
594 prop.insert("x-umbral-m2m".into(), Value::Bool(true));
597 prop.insert(
598 "x-umbral-m2m-target".into(),
599 Value::String(target_schema.clone()),
600 );
601 prop.insert(
602 "x-umbral-m2m-target-table".into(),
603 Value::String(rel.target_table.clone()),
604 );
605 if table_to_schema.contains_key(&rel.target_table) {
606 prop.insert(
607 "x-umbral-m2m-target-ref".into(),
608 Value::String(format!("#/components/schemas/{target_schema}")),
609 );
610 }
611 properties.insert(rel.field_name.clone(), Value::Object(prop));
612 }
613 let mut obj = Map::new();
614 obj.insert("type".into(), Value::String("object".into()));
615 obj.insert("properties".into(), Value::Object(properties));
616 if !required.is_empty() {
617 obj.insert("required".into(), Value::Array(required));
618 }
619 Value::Object(obj)
620}
621
622fn column_schema_with_refs(
626 col: &Column,
627 table_to_schema: &std::collections::HashMap<String, String>,
628) -> Value {
629 let mut value = column_schema(col);
630 if let Some(target_table) = &col.fk_target {
640 if let Some(schema_name) = table_to_schema.get(target_table) {
641 if let Some(obj) = value.as_object_mut() {
642 obj.insert(
643 "x-umbral-fk-ref".into(),
644 Value::String(format!("#/components/schemas/{schema_name}")),
645 );
646 }
647 }
648 }
649 value
650}
651
652fn column_schema(col: &Column) -> Value {
653 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
654 let mut obj = Map::new();
655 obj.insert("type".into(), Value::String(ty.into()));
656 if let Some(f) = format {
657 obj.insert("format".into(), Value::String(f.into()));
658 }
659 if col.nullable {
660 obj.insert("nullable".into(), Value::Bool(true));
661 }
662 if !col.help.is_empty() {
666 obj.insert("description".into(), Value::String(col.help.clone()));
667 }
668 if !col.example.is_empty() {
672 obj.insert("example".into(), Value::String(col.example.clone()));
673 }
674 if let Some(min) = col.min {
677 obj.insert(
678 "minimum".into(),
679 Value::Number(serde_json::Number::from(min)),
680 );
681 }
682 if let Some(max) = col.max {
683 obj.insert(
684 "maximum".into(),
685 Value::Number(serde_json::Number::from(max)),
686 );
687 }
688 if let Some(fmt) = col.text_format.as_deref() {
692 match fmt {
693 "email" => {
694 obj.insert("format".into(), Value::String("email".into()));
695 }
696 "url" => {
697 obj.insert("format".into(), Value::String("uri".into()));
698 }
699 "slug" => {
700 obj.insert("pattern".into(), Value::String("^[A-Za-z0-9_-]+$".into()));
704 }
705 _ => {}
706 }
707 }
708 if !col.choices.is_empty() && !col.is_multichoice {
714 obj.insert(
715 "enum".into(),
716 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
717 );
718 }
719 if col.max_length > 0 {
720 obj.insert(
721 "maxLength".into(),
722 Value::Number(serde_json::Number::from(col.max_length)),
723 );
724 }
725 if !col.default.is_empty() {
726 obj.insert("default".into(), Value::String(col.default.clone()));
731 }
732 if col.is_multichoice {
733 obj.insert("x-umbral-multichoice".into(), Value::Bool(true));
734 obj.insert(
735 "x-umbral-choices".into(),
736 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
737 );
738 }
739 if !col.choice_labels.is_empty() {
740 obj.insert(
741 "x-umbral-choice-labels".into(),
742 Value::Array(
743 col.choice_labels
744 .iter()
745 .cloned()
746 .map(Value::String)
747 .collect(),
748 ),
749 );
750 }
751 if let Some(target) = &col.fk_target {
752 obj.insert("x-umbral-fk-target".into(), Value::String(target.clone()));
753 }
754 if col.is_string_repr {
758 obj.insert("x-umbral-string-repr".into(), Value::Bool(true));
759 }
760 if col.auto_now_add {
781 obj.insert("x-umbral-auto-now-add".into(), Value::Bool(true));
782 }
783 if col.auto_now {
784 obj.insert("x-umbral-auto-now".into(), Value::Bool(true));
785 }
786 if col.noform {
787 obj.insert("readOnly".into(), Value::Bool(true));
788 obj.insert("x-umbral-noform".into(), Value::Bool(true));
794 }
795 if col.noedit {
800 obj.insert("x-umbral-noedit".into(), Value::Bool(true));
801 }
802 Value::Object(obj)
803}
804
805fn openapi_type(ty: SqlType) -> (&'static str, Option<&'static str>) {
806 match ty {
807 SqlType::SmallInt => ("integer", Some("int32")),
808 SqlType::Integer => ("integer", Some("int32")),
809 SqlType::BigInt => ("integer", Some("int64")),
810 SqlType::Real => ("number", Some("float")),
811 SqlType::Double => ("number", Some("double")),
812 SqlType::Boolean => ("boolean", None),
813 SqlType::Text => ("string", None),
814 SqlType::Date => ("string", Some("date")),
815 SqlType::Time => ("string", Some("time")),
816 SqlType::Timestamptz => ("string", Some("date-time")),
817 SqlType::Timestamp => ("string", Some("date-time")),
818 SqlType::Uuid => ("string", Some("uuid")),
819 SqlType::Json => ("object", None),
824 SqlType::Array(_) => ("array", None),
831 SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => ("string", None),
836 SqlType::FullText => ("string", None),
839 SqlType::Xml | SqlType::Ltree | SqlType::Bit => ("string", None),
842 SqlType::ForeignKey => ("integer", Some("int64")),
845 SqlType::Bytes => ("array", Some("byte")),
852 SqlType::Decimal => ("string", Some("decimal")),
858 SqlType::DecimalN(_) => ("string", Some("decimal")),
859 SqlType::BigDecimal => ("string", Some("decimal")),
860 SqlType::Geometry(_) | SqlType::Geography(_) => ("object", None),
862 }
863}
864
865fn search_parameter() -> Value {
876 json!({
877 "name": "search",
878 "in": "query",
879 "required": false,
880 "description": "Free-text search across every searchable column. \
881 Text columns match via case-insensitive substring; \
882 numeric / FK / Boolean columns match exactly when \
883 the term parses as that type. Multiple matches are \
884 ORed.",
885 "schema": { "type": "string" },
886 "x-umbral-search": true,
887 })
888}
889
890fn fields_parameter(model: &ModelMeta) -> Value {
901 let columns: Vec<Value> = model
904 .fields
905 .iter()
906 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
907 .map(|c| Value::String(c.name.clone()))
908 .collect();
909 json!({
910 "name": "fields",
911 "in": "query",
912 "required": false,
913 "description": "Comma-separated list of column names to include in the \
914 response. Unknown names are silently dropped; an empty \
915 value falls back to the full row (BUG-81). Composes \
916 with hide / transform / computed — hide always wins, \
917 the rest are returned iff in the list.",
918 "schema": { "type": "string" },
919 "x-umbral-fields": true,
920 "x-umbral-fields-columns": Value::Array(columns),
921 })
922}
923
924fn include_parameter(model: &ModelMeta) -> Value {
931 let fks: Vec<Value> = model
935 .fields
936 .iter()
937 .filter(|c| c.fk_target.is_some())
938 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
939 .map(|c| Value::String(c.name.clone()))
940 .collect();
941 json!({
942 "name": "include",
943 "in": "query",
944 "required": false,
945 "description": "Comma-separated list of foreign-key columns to expand \
946 in the response. Each named FK gets replaced with the \
947 full related-row JSON object (one batched IN(...) query \
948 per FK — no N+1). Unknown or non-FK names return a 400. \
949 Example: `?include=user,billing_address`.",
950 "schema": { "type": "string" },
951 "x-umbral-include": true,
952 "x-umbral-include-fks": Value::Array(fks),
953 })
954}
955
956fn pagination_parameters_for_style(style: umbral_rest::PaginationStyle) -> Vec<Value> {
965 match style {
966 umbral_rest::PaginationStyle::PageNumber => vec![
967 json!({
968 "name": "page",
969 "in": "query",
970 "required": false,
971 "description": "1-indexed page number. Defaults to 1 when omitted.",
972 "schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 },
973 "x-umbral-pagination": "page",
974 }),
975 json!({
976 "name": "page_size",
977 "in": "query",
978 "required": false,
979 "description": "Rows per page. Capped at 100. Default 20.",
980 "schema": {
981 "type": "integer", "format": "int32",
982 "minimum": 1, "maximum": 100, "default": 20,
983 },
984 "x-umbral-pagination": "page_size",
985 }),
986 ],
987 umbral_rest::PaginationStyle::LimitOffset => vec![
988 json!({
989 "name": "limit",
990 "in": "query",
991 "required": false,
992 "description": "Maximum rows to return. Defaults to the configured page size.",
993 "schema": { "type": "integer", "format": "int32", "minimum": 1 },
994 "x-umbral-pagination": "limit",
995 }),
996 json!({
997 "name": "offset",
998 "in": "query",
999 "required": false,
1000 "description": "Number of rows to skip from the start of the result set. Defaults to 0.",
1001 "schema": { "type": "integer", "format": "int32", "minimum": 0, "default": 0 },
1002 "x-umbral-pagination": "offset",
1003 }),
1004 ],
1005 umbral_rest::PaginationStyle::None | umbral_rest::PaginationStyle::Custom => vec![],
1006 }
1007}
1008
1009fn filter_parameters(model: &ModelMeta) -> Vec<Value> {
1018 let mut out: Vec<Value> = Vec::new();
1019 for col in &model.fields {
1020 if col.primary_key {
1021 continue;
1022 }
1023 if umbral_rest::is_hidden(&model.table, &col.name) {
1029 continue;
1030 }
1031 let lookups = umbral_rest::filtering::applicable_lookups(col);
1032 for lookup in lookups {
1033 let name = if lookup == "eq" {
1034 col.name.clone()
1035 } else {
1036 format!("{}__{}", col.name, lookup)
1037 };
1038 out.push(filter_parameter(col, lookup, &name));
1039 }
1040 }
1041 out
1042}
1043
1044fn filter_parameter(col: &Column, lookup: &str, name: &str) -> Value {
1055 let (schema, description) = match lookup {
1056 "in" => (
1057 json!({ "type": "string" }),
1058 format!(
1059 "Comma-separated `{}` values; matches rows where the column is in the set.",
1060 col.name,
1061 ),
1062 ),
1063 "isnull" => (
1064 json!({ "type": "boolean" }),
1065 format!(
1066 "`true` matches rows where `{}` IS NULL; `false` matches IS NOT NULL.",
1067 col.name,
1068 ),
1069 ),
1070 "contains" | "icontains" | "startswith" => {
1071 let phrase = match lookup {
1072 "contains" => "case-sensitive substring",
1073 "icontains" => "case-insensitive substring",
1074 "startswith" => "case-sensitive prefix",
1075 _ => unreachable!(),
1076 };
1077 (
1078 json!({ "type": "string" }),
1079 format!(
1080 "Matches rows where `{}` contains the given {phrase}.",
1081 col.name
1082 ),
1083 )
1084 }
1085 _ => {
1087 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
1088 let mut schema_obj = Map::new();
1089 schema_obj.insert("type".into(), Value::String(ty.into()));
1090 if let Some(f) = format {
1091 schema_obj.insert("format".into(), Value::String(f.into()));
1092 }
1093 let phrase = match lookup {
1094 "eq" => "equals the value",
1095 "ne" => "does not equal the value",
1096 "gte" => "is greater than or equal to the value",
1097 "lte" => "is less than or equal to the value",
1098 "gt" => "is greater than the value",
1099 "lt" => "is less than the value",
1100 _ => "matches the value",
1101 };
1102 (
1103 Value::Object(schema_obj),
1104 format!("Matches rows where `{}` {phrase}.", col.name),
1105 )
1106 }
1107 };
1108
1109 json!({
1110 "name": name,
1111 "in": "query",
1112 "required": false,
1113 "description": description,
1114 "schema": schema,
1115 "x-umbral-filter-field": col.name,
1116 "x-umbral-filter-lookup": lookup,
1117 })
1118}
1119
1120fn collection_paths(table: &str, schema_name: &str, filter_params: &[Value]) -> Value {
1121 use umbral_rest::Action;
1122 let mut item = Map::new();
1123
1124 if umbral_rest::action_exposed(table, &Action::List) {
1129 let mut get_op = Map::new();
1130 get_op.insert(
1131 "operationId".into(),
1132 Value::String(format!("list_{}", table)),
1133 );
1134 get_op.insert("tags".into(), json!([table]));
1135 if !filter_params.is_empty() {
1136 get_op.insert("parameters".into(), Value::Array(filter_params.to_vec()));
1137 }
1138 get_op.insert(
1139 "responses".into(),
1140 json!({
1141 "200": {
1142 "description": "List of rows",
1143 "content": {
1144 "application/json": {
1145 "schema": list_envelope(schema_name)
1146 }
1147 }
1148 }
1149 }),
1150 );
1151 item.insert("get".into(), Value::Object(get_op));
1152 }
1153
1154 if umbral_rest::action_exposed(table, &Action::Create) {
1157 item.insert(
1158 "post".into(),
1159 json!({
1160 "operationId": format!("create_{}", table),
1161 "tags": [table],
1162 "requestBody": {
1163 "required": true,
1164 "content": {
1165 "application/json": {
1166 "schema": schema_ref(schema_name)
1167 }
1168 }
1169 },
1170 "responses": {
1171 "201": {
1172 "description": "Row created",
1173 "content": {
1174 "application/json": {
1175 "schema": schema_ref(schema_name)
1176 }
1177 }
1178 },
1179 "400": { "description": "Invalid input" }
1180 }
1181 }),
1182 );
1183 }
1184
1185 Value::Object(item)
1186}
1187
1188fn item_paths(table: &str, schema_name: &str, retrieve_query_params: &[Value]) -> Value {
1189 use umbral_rest::Action;
1190 let id_param = json!({
1191 "name": "id",
1192 "in": "path",
1193 "required": true,
1194 "schema": { "type": "string" }
1195 });
1196 let mut item = Map::new();
1197 item.insert("parameters".into(), json!([id_param]));
1198
1199 if umbral_rest::action_exposed(table, &Action::Retrieve) {
1205 let mut get_op = Map::new();
1206 get_op.insert(
1207 "operationId".into(),
1208 Value::String(format!("retrieve_{}", table)),
1209 );
1210 get_op.insert("tags".into(), json!([table]));
1211 if !retrieve_query_params.is_empty() {
1212 get_op.insert(
1213 "parameters".into(),
1214 Value::Array(retrieve_query_params.to_vec()),
1215 );
1216 }
1217 get_op.insert(
1218 "responses".into(),
1219 json!({
1220 "200": {
1221 "description": "Row found",
1222 "content": {
1223 "application/json": {
1224 "schema": schema_ref(schema_name)
1225 }
1226 }
1227 },
1228 "404": { "description": "Not found" }
1229 }),
1230 );
1231 item.insert("get".into(), Value::Object(get_op));
1232 }
1233
1234 if umbral_rest::action_exposed(table, &Action::Update) {
1236 item.insert(
1237 "put".into(),
1238 json!({
1239 "operationId": format!("update_{}", table),
1240 "tags": [table],
1241 "requestBody": {
1242 "required": true,
1243 "content": {
1244 "application/json": {
1245 "schema": schema_ref(schema_name)
1246 }
1247 }
1248 },
1249 "responses": {
1250 "200": {
1251 "description": "Row updated",
1252 "content": {
1253 "application/json": {
1254 "schema": schema_ref(schema_name)
1255 }
1256 }
1257 },
1258 "404": { "description": "Not found" }
1259 }
1260 }),
1261 );
1262 item.insert(
1263 "patch".into(),
1264 json!({
1265 "operationId": format!("partial_update_{}", table),
1266 "tags": [table],
1267 "requestBody": {
1268 "required": true,
1269 "content": {
1270 "application/json": {
1271 "schema": schema_ref(schema_name)
1272 }
1273 }
1274 },
1275 "responses": {
1276 "200": {
1277 "description": "Row partially updated",
1278 "content": {
1279 "application/json": {
1280 "schema": schema_ref(schema_name)
1281 }
1282 }
1283 },
1284 "404": { "description": "Not found" }
1285 }
1286 }),
1287 );
1288 }
1289
1290 if umbral_rest::action_exposed(table, &Action::Delete) {
1292 item.insert(
1293 "delete".into(),
1294 json!({
1295 "operationId": format!("destroy_{}", table),
1296 "tags": [table],
1297 "responses": {
1298 "204": { "description": "Row deleted" },
1299 "404": { "description": "Not found" }
1300 }
1301 }),
1302 );
1303 }
1304
1305 Value::Object(item)
1306}
1307
1308fn schema_ref(name: &str) -> Value {
1309 json!({ "$ref": format!("#/components/schemas/{}", name) })
1310}
1311
1312fn has_operations(path_item: &Value) -> bool {
1317 const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"];
1318 path_item
1319 .as_object()
1320 .is_some_and(|m| METHODS.iter().any(|verb| m.contains_key(*verb)))
1321}
1322
1323fn list_envelope(schema_name: &str) -> Value {
1324 json!({
1325 "type": "object",
1326 "properties": {
1327 "results": {
1328 "type": "array",
1329 "items": schema_ref(schema_name)
1330 },
1331 "count": { "type": "integer" }
1332 },
1333 "required": ["results", "count"]
1334 })
1335}
1336
1337#[doc(hidden)]
1341pub fn test_spec_url(p: &OpenApiPlugin) -> String {
1342 p.spec_url()
1343}
1344
1345#[doc(hidden)]
1346pub fn test_ui_route(p: &OpenApiPlugin) -> String {
1347 p.ui_route()
1348}
1349
1350#[derive(Debug, Default)]
1361struct GenClientCommand;
1362
1363#[async_trait::async_trait]
1364impl umbral::cli::PluginCommand for GenClientCommand {
1365 fn needs_ready(&self) -> bool {
1376 false
1377 }
1378
1379 fn command(&self) -> clap::Command {
1380 clap::Command::new("gen-client")
1381 .about("Generate a typed client (client.js + client.d.ts) for the REST API")
1382 .after_help("Example:\n cargo run -- gen-client --out ./web/src/api")
1383 .arg(
1384 clap::Arg::new("out")
1385 .long("out")
1386 .value_name("DIR")
1387 .required(true)
1388 .help("Directory to write client.js and client.d.ts into"),
1389 )
1390 .arg(
1391 clap::Arg::new("lang")
1392 .long("lang")
1393 .value_name("LANG")
1394 .default_value("ts")
1395 .help("Target language (only `ts` is supported)"),
1396 )
1397 .arg(
1398 clap::Arg::new("check")
1399 .long("check")
1400 .action(clap::ArgAction::SetTrue)
1401 .help("Write nothing; exit non-zero if the files have drifted from the models"),
1402 )
1403 }
1404
1405 async fn run(&self, matches: &clap::ArgMatches) -> Result<(), umbral::cli::CliError> {
1406 let lang = matches
1407 .get_one::<String>("lang")
1408 .map(String::as_str)
1409 .unwrap_or("ts");
1410 if lang != "ts" {
1411 return Err(format!("gen-client: unsupported --lang `{lang}` (only `ts`)").into());
1412 }
1413 let dir = std::path::PathBuf::from(
1414 matches
1415 .get_one::<String>("out")
1416 .expect("--out is required by clap"),
1417 );
1418 let check = matches.get_flag("check");
1419
1420 let generated = client_gen::generate();
1421 let files = [("client.js", generated.js), ("client.d.ts", generated.dts)];
1422
1423 if check {
1424 let mut stale = Vec::new();
1425 for (name, want) in &files {
1426 let path = dir.join(name);
1427 let have = std::fs::read_to_string(&path).unwrap_or_default();
1429 if &have != want {
1430 stale.push(path.display().to_string());
1431 }
1432 }
1433 if stale.is_empty() {
1434 println!("{} is up to date.", dir.display());
1435 return Ok(());
1436 }
1437 return Err(format!(
1438 "gen-client: out of date with the models: {}. Regenerate:\n \
1439 cargo run -- gen-client --out {}",
1440 stale.join(", "),
1441 dir.display(),
1442 )
1443 .into());
1444 }
1445
1446 std::fs::create_dir_all(&dir)?;
1447 for (name, contents) in &files {
1448 std::fs::write(dir.join(name), contents)?;
1449 }
1450 println!(
1451 "Wrote {} and {}.",
1452 dir.join("client.js").display(),
1453 dir.join("client.d.ts").display(),
1454 );
1455 Ok(())
1456 }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461 use super::*;
1462 use umbral::migrate::Column;
1463 use umbral::orm::SqlType;
1464
1465 #[test]
1468 fn swagger_asset_base_is_pinned_and_configurable() {
1469 assert!(
1471 DEFAULT_SWAGGER_ASSET_BASE.contains("@5.17"),
1472 "default asset base must pin an exact version, got {DEFAULT_SWAGGER_ASSET_BASE}"
1473 );
1474 assert!(!SWAGGER_UI_HTML.contains("unpkg.com/swagger-ui-dist@5/"));
1475 assert!(SWAGGER_UI_HTML.contains("{ASSET_BASE}"));
1476 assert!(SWAGGER_UI_HTML.contains("crossorigin=\"anonymous\""));
1477
1478 let p = OpenApiPlugin::new().swagger_asset_base("/static/swagger");
1480 let rendered = SWAGGER_UI_HTML
1481 .replace("{ASSET_BASE}", &p.swagger_asset_base)
1482 .replace("{SPEC_URL}", "/openapi/openapi.json");
1483 assert!(rendered.contains("/static/swagger/swagger-ui-bundle.js"));
1484 assert!(!rendered.contains("{ASSET_BASE}"));
1485 }
1486
1487 fn base_col(name: &str, ty: SqlType) -> Column {
1488 Column {
1489 name: name.into(),
1490 ty,
1491 primary_key: false,
1492 nullable: false,
1493 fk_target: None,
1494 noform: false,
1495 privileged: false,
1496 private: false,
1497 secret: false,
1498 db_constraint: true,
1499 noedit: false,
1500 auto_user_add: false,
1501 auto_user: false,
1502 is_string_repr: false,
1503 max_length: 0,
1504 choices: Vec::new(),
1505 choice_labels: Vec::new(),
1506 default: String::new(),
1507 is_multichoice: false,
1508 unique: false,
1509 on_delete: ::umbral::orm::FkAction::NoAction,
1510 on_update: ::umbral::orm::FkAction::NoAction,
1511 index: false,
1512 auto_now_add: false,
1513 auto_uuid: false,
1514 auto_now: false,
1515 trim: false,
1516 lowercase: false,
1517 case_insensitive: false,
1518 help: String::new(),
1519 example: String::new(),
1520 widget: None,
1521 supported_backends: Vec::new(),
1522 min: None,
1523 max: None,
1524 text_format: ::core::option::Option::None,
1525 slug_from: ::core::option::Option::None,
1526 }
1527 }
1528
1529 #[test]
1530 fn choices_render_as_openapi_enum_with_labels_extension() {
1531 let mut col = base_col("status", SqlType::Text);
1532 col.choices = vec!["draft".into(), "published".into(), "archived".into()];
1533 col.choice_labels = vec!["Draft".into(), "Published".into(), "Archived".into()];
1534 let schema = column_schema(&col);
1535 assert_eq!(schema["type"], "string");
1536 assert_eq!(
1537 schema["enum"],
1538 serde_json::json!(["draft", "published", "archived"])
1539 );
1540 assert_eq!(
1541 schema["x-umbral-choice-labels"],
1542 serde_json::json!(["Draft", "Published", "Archived"])
1543 );
1544 }
1545
1546 #[test]
1547 fn multichoice_skips_enum_and_uses_vendor_extension() {
1548 let mut col = base_col("tags", SqlType::Text);
1549 col.choices = vec!["rust".into(), "python".into()];
1550 col.is_multichoice = true;
1551 let schema = column_schema(&col);
1552 assert!(
1553 schema.get("enum").is_none(),
1554 "multichoice columns should not declare a flat enum (value is a CSV subset)"
1555 );
1556 assert_eq!(schema["x-umbral-multichoice"], true);
1557 assert_eq!(
1558 schema["x-umbral-choices"],
1559 serde_json::json!(["rust", "python"])
1560 );
1561 }
1562
1563 #[test]
1564 fn max_length_and_default_surface_as_standard_openapi_keys() {
1565 let mut col = base_col("title", SqlType::Text);
1566 col.max_length = 50;
1567 col.default = "untitled".into();
1568 let schema = column_schema(&col);
1569 assert_eq!(schema["maxLength"], 50);
1570 assert_eq!(schema["default"], "untitled");
1571 }
1572
1573 #[test]
1574 fn fk_target_emits_vendor_extension_for_playground_navigation() {
1575 let mut col = base_col("author_id", SqlType::ForeignKey);
1576 col.fk_target = Some("auth_user".into());
1577 let schema = column_schema(&col);
1578 assert_eq!(schema["type"], "integer");
1579 assert_eq!(schema["format"], "int64");
1580 assert_eq!(schema["x-umbral-fk-target"], "auth_user");
1581 }
1582
1583 #[test]
1584 fn noform_renders_as_read_only_and_carries_vendor_extension() {
1585 let mut col = base_col("internal_token", SqlType::Text);
1590 col.noform = true;
1591 let schema = column_schema(&col);
1592 assert_eq!(schema["readOnly"], true);
1593 assert_eq!(schema["x-umbral-noform"], true);
1594 }
1595
1596 #[test]
1597 fn noedit_does_NOT_render_as_read_only() {
1598 let mut col = base_col("email", SqlType::Text);
1604 col.noedit = true;
1605 let schema = column_schema(&col);
1606 assert!(
1607 schema.get("readOnly").is_none(),
1608 "noedit must NOT contaminate the API request-body contract; \
1609 got readOnly in schema: {schema:?}"
1610 );
1611 assert_eq!(schema["x-umbral-noedit"], true);
1614 }
1615
1616 #[test]
1617 fn plain_column_keeps_minimal_schema_no_extensions() {
1618 let col = base_col("body", SqlType::Text);
1619 let schema = column_schema(&col);
1620 let obj = schema.as_object().expect("object");
1621 assert_eq!(
1622 obj.len(),
1623 1,
1624 "plain column should only have `type`: {obj:?}"
1625 );
1626 assert_eq!(schema["type"], "string");
1627 }
1628
1629 #[test]
1634 fn help_attribute_flows_to_openapi_description() {
1635 let mut col = base_col("status", SqlType::Text);
1636 col.help = "Workflow step. Set by editors on Save.".to_string();
1637 let schema = column_schema(&col);
1638 assert_eq!(
1639 schema["description"], "Workflow step. Set by editors on Save.",
1640 "help should round-trip to OpenAPI description; got: {schema:?}",
1641 );
1642 }
1643
1644 #[test]
1645 fn empty_help_omits_description() {
1646 let col = base_col("body", SqlType::Text);
1647 let schema = column_schema(&col);
1648 assert!(
1649 schema.get("description").is_none(),
1650 "empty help should omit description; got: {schema:?}",
1651 );
1652 }
1653
1654 #[test]
1658 fn example_attribute_flows_to_openapi_example() {
1659 let mut col = base_col("status", SqlType::Text);
1660 col.example = "published".to_string();
1661 let schema = column_schema(&col);
1662 assert_eq!(
1663 schema["example"], "published",
1664 "example should round-trip; got: {schema:?}",
1665 );
1666 }
1667
1668 #[test]
1669 fn empty_example_omits_example() {
1670 let col = base_col("body", SqlType::Text);
1671 let schema = column_schema(&col);
1672 assert!(
1673 schema.get("example").is_none(),
1674 "empty example should omit example key; got: {schema:?}",
1675 );
1676 }
1677
1678 fn note_model() -> ModelMeta {
1683 let mut id = base_col("id", SqlType::BigInt);
1684 id.primary_key = true;
1685 let mut published_at = base_col("published_at", SqlType::Timestamptz);
1686 published_at.nullable = true;
1687 ModelMeta {
1688 view: None,
1689 materialized: false,
1690 name: "Note".to_string(),
1691 table: "note".to_string(),
1692 fields: vec![
1693 id,
1694 base_col("title", SqlType::Text),
1695 base_col("views", SqlType::Integer),
1696 published_at,
1697 ],
1698 display: "Note".to_string(),
1699 icon: "database".to_string(),
1700 database: None,
1701 singleton: false,
1702 unique_together: Vec::new(),
1703 indexes: Vec::new(),
1704 ordering: Vec::new(),
1705 m2m_relations: Vec::new(),
1706 soft_delete: false,
1707 audited: false,
1708 app_label: "app".to_string(),
1709 }
1710 }
1711
1712 #[test]
1713 fn filter_parameters_skips_primary_key() {
1714 let params = filter_parameters(¬e_model());
1715 let names: Vec<&str> = params.iter().map(|p| p["name"].as_str().unwrap()).collect();
1716 assert!(
1717 !names.iter().any(|n| *n == "id" || n.starts_with("id__")),
1718 "PK column should be skipped; got {names:?}",
1719 );
1720 }
1721
1722 #[test]
1723 fn filter_parameters_eq_uses_bare_column_name_no_suffix() {
1724 let params = filter_parameters(¬e_model());
1725 let bare_title = params
1726 .iter()
1727 .find(|p| p["name"] == "title")
1728 .expect("title eq parameter should be present");
1729 assert_eq!(bare_title["x-umbral-filter-lookup"], "eq");
1730 assert_eq!(bare_title["x-umbral-filter-field"], "title");
1731 assert_eq!(bare_title["schema"]["type"], "string");
1732 }
1733
1734 #[test]
1735 fn filter_parameters_in_is_string_typed_with_csv_description() {
1736 let params = filter_parameters(¬e_model());
1737 let title_in = params
1738 .iter()
1739 .find(|p| p["name"] == "title__in")
1740 .expect("title__in parameter should be present");
1741 assert_eq!(title_in["schema"]["type"], "string");
1742 assert!(
1743 title_in["description"]
1744 .as_str()
1745 .unwrap()
1746 .to_lowercase()
1747 .contains("comma"),
1748 "__in description should mention the comma-separated format",
1749 );
1750 }
1751
1752 #[test]
1753 fn filter_parameters_isnull_only_on_nullable_columns() {
1754 let params = filter_parameters(¬e_model());
1755 let isnull_params: Vec<&str> = params
1756 .iter()
1757 .filter_map(|p| p["name"].as_str())
1758 .filter(|n| n.ends_with("__isnull"))
1759 .collect();
1760 assert_eq!(
1761 isnull_params,
1762 vec!["published_at__isnull"],
1763 "isnull lookup should only appear for nullable columns; got {isnull_params:?}",
1764 );
1765 }
1766
1767 #[test]
1768 fn filter_parameters_range_lookups_only_on_numeric_or_temporal() {
1769 let params = filter_parameters(¬e_model());
1770 let has_gte = |field: &str| params.iter().any(|p| p["name"] == format!("{field}__gte"));
1771 assert!(has_gte("views"), "integer column gets gte");
1772 assert!(has_gte("published_at"), "timestamp column gets gte");
1773 assert!(
1774 !has_gte("title"),
1775 "text column must NOT get gte; got {params:?}",
1776 );
1777 }
1778
1779 #[test]
1780 fn filter_parameters_string_lookups_only_on_text() {
1781 let params = filter_parameters(¬e_model());
1782 let has_contains = |field: &str| {
1783 params
1784 .iter()
1785 .any(|p| p["name"] == format!("{field}__contains"))
1786 };
1787 assert!(has_contains("title"), "text column gets contains");
1788 assert!(
1789 !has_contains("views"),
1790 "integer column must NOT get contains; got {params:?}",
1791 );
1792 }
1793
1794 #[test]
1795 fn collection_paths_omits_parameters_array_when_no_filters() {
1796 let value = collection_paths("note", "Note", &[]);
1797 let get_op = &value["get"];
1798 assert!(
1799 get_op.get("parameters").is_none(),
1800 "no filters → no parameters key; got {get_op:?}",
1801 );
1802 }
1803
1804 #[test]
1805 fn collection_paths_includes_parameters_when_filters_present() {
1806 let filter_params = filter_parameters(¬e_model());
1807 let value = collection_paths("note", "Note", &filter_params);
1808 let params = value["get"]["parameters"]
1809 .as_array()
1810 .expect("parameters array should be present when filters land");
1811 assert!(!params.is_empty());
1812 assert!(
1813 params.iter().all(|p| p["in"] == "query"),
1814 "every filter parameter is in: query",
1815 );
1816 }
1817
1818 #[test]
1823 fn fields_parameter_lists_model_columns() {
1824 let param = fields_parameter(¬e_model());
1825 assert_eq!(param["name"], "fields");
1826 assert_eq!(param["in"], "query");
1827 assert_eq!(param["x-umbral-fields"], true);
1828 let cols = param["x-umbral-fields-columns"]
1829 .as_array()
1830 .expect("x-umbral-fields-columns should be a list");
1831 let names: Vec<&str> = cols.iter().filter_map(|v| v.as_str()).collect();
1832 assert!(names.contains(&"title"));
1833 assert!(names.contains(&"views"));
1834 assert!(
1835 !names.is_empty(),
1836 "every column should land in the enum so the playground can offer it",
1837 );
1838 }
1839
1840 #[test]
1843 fn item_paths_advertises_fields_query_param_on_retrieve() {
1844 let value = item_paths("note", "Note", &[fields_parameter(¬e_model())]);
1845 let get_params = value["get"]["parameters"]
1846 .as_array()
1847 .expect("retrieve op should carry its query parameters");
1848 assert!(
1849 get_params.iter().any(|p| p["name"] == "fields"),
1850 "fields parameter should be on the retrieve op; got {get_params:?}",
1851 );
1852 }
1853
1854 #[test]
1859 fn fk_column_emits_schema_ref_when_target_known() {
1860 let mut col = base_col("author", SqlType::ForeignKey);
1861 col.fk_target = Some("auth_user".into());
1862 let mut map = std::collections::HashMap::new();
1863 map.insert("auth_user".to_string(), "AuthUser".to_string());
1864 let schema = column_schema_with_refs(&col, &map);
1865 assert_eq!(
1866 schema["x-umbral-fk-target"], "auth_user",
1867 "the table-name vendor extension stays for backward compat",
1868 );
1869 assert_eq!(
1870 schema["x-umbral-fk-ref"], "#/components/schemas/AuthUser",
1871 "the JSON pointer to the target schema should be emitted",
1872 );
1873 }
1874
1875 #[test]
1876 fn fk_column_without_known_target_omits_schema_ref() {
1877 let mut col = base_col("author", SqlType::ForeignKey);
1878 col.fk_target = Some("unknown_table".into());
1879 let map = std::collections::HashMap::new();
1880 let schema = column_schema_with_refs(&col, &map);
1881 assert!(
1882 schema.get("x-umbral-fk-ref").is_none(),
1883 "unknown FK target → no ref emitted; got: {schema:?}",
1884 );
1885 }
1886
1887 #[test]
1893 fn m2m_relation_lands_in_model_schema_with_target_extension() {
1894 let mut model = note_model();
1895 model.m2m_relations.push(umbral::migrate::M2MRelation {
1896 field_name: "tags".to_string(),
1897 target_table: "tag".to_string(),
1898 target_name: "Tag".to_string(),
1899 });
1900 let mut tts = std::collections::HashMap::new();
1904 tts.insert("tag".to_string(), "Tag".to_string());
1905 let schema = model_schema(&model, &tts);
1906 let tags_prop = &schema["properties"]["tags"];
1907 assert_eq!(tags_prop["type"], "array");
1908 assert_eq!(tags_prop["items"]["type"], "integer");
1909 assert_eq!(tags_prop["x-umbral-m2m"], true);
1910 assert_eq!(tags_prop["x-umbral-m2m-target"], "Tag");
1911 assert_eq!(tags_prop["x-umbral-m2m-target-table"], "tag");
1912 assert_eq!(
1913 tags_prop["x-umbral-m2m-target-ref"],
1914 "#/components/schemas/Tag",
1915 );
1916 let required = schema["required"].as_array();
1918 if let Some(req) = required {
1919 assert!(!req.iter().any(|v| v == "tags"));
1920 }
1921 }
1922
1923 #[test]
1931 fn auto_now_columns_are_optional_in_the_request_schema() {
1932 let mut model = note_model();
1933 let mut created = base_col("created_at", SqlType::Timestamptz);
1934 created.auto_now_add = true;
1935 let mut updated = base_col("updated_at", SqlType::Timestamptz);
1936 updated.auto_now = true;
1937 model.fields.push(created);
1938 model.fields.push(updated);
1939
1940 let schema = model_schema(&model, &std::collections::HashMap::new());
1941
1942 assert_eq!(
1946 schema["properties"]["created_at"]["x-umbral-auto-now-add"],
1947 true
1948 );
1949 assert_eq!(
1950 schema["properties"]["updated_at"]["x-umbral-auto-now"],
1951 true
1952 );
1953
1954 assert!(
1958 schema["properties"]["created_at"].get("readOnly").is_none(),
1959 "auto_now_add must not be readOnly; got {}",
1960 schema["properties"]["created_at"],
1961 );
1962 assert!(
1963 schema["properties"]["updated_at"].get("readOnly").is_none(),
1964 "auto_now must not be readOnly; got {}",
1965 schema["properties"]["updated_at"],
1966 );
1967
1968 let required = schema["required"].as_array().expect("required array");
1971 let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1972 assert!(
1973 !names.contains(&"created_at"),
1974 "auto_now_add should drop out of required; got {names:?}",
1975 );
1976 assert!(
1977 !names.contains(&"updated_at"),
1978 "auto_now should drop out of required; got {names:?}",
1979 );
1980 }
1981
1982 #[test]
1985 fn pagination_parameters_per_style() {
1986 use umbral_rest::PaginationStyle;
1987
1988 let none_params = pagination_parameters_for_style(PaginationStyle::None);
1990 assert!(
1991 none_params.is_empty(),
1992 "NoPagination should emit no pagination params; got {none_params:?}"
1993 );
1994
1995 let custom_params = pagination_parameters_for_style(PaginationStyle::Custom);
1997 assert!(
1998 custom_params.is_empty(),
1999 "Custom pagination should emit no params; got {custom_params:?}"
2000 );
2001
2002 let page_params = pagination_parameters_for_style(PaginationStyle::PageNumber);
2004 assert_eq!(page_params.len(), 2, "PageNumber should emit 2 params");
2005 assert_eq!(page_params[0]["name"], "page");
2006 assert_eq!(page_params[0]["in"], "query");
2007 assert_eq!(page_params[0]["schema"]["type"], "integer");
2008 assert_eq!(page_params[0]["schema"]["minimum"], 1);
2009 assert_eq!(page_params[0]["schema"]["default"], 1);
2010 assert_eq!(page_params[0]["x-umbral-pagination"], "page");
2011 assert_eq!(page_params[1]["name"], "page_size");
2012 assert_eq!(page_params[1]["schema"]["maximum"], 100);
2013 assert_eq!(page_params[1]["x-umbral-pagination"], "page_size");
2014
2015 let lo_params = pagination_parameters_for_style(PaginationStyle::LimitOffset);
2017 assert_eq!(lo_params.len(), 2, "LimitOffset should emit 2 params");
2018 assert_eq!(lo_params[0]["name"], "limit");
2019 assert_eq!(lo_params[0]["x-umbral-pagination"], "limit");
2020 assert_eq!(lo_params[1]["name"], "offset");
2021 assert_eq!(lo_params[1]["x-umbral-pagination"], "offset");
2022 assert_eq!(lo_params[1]["schema"]["minimum"], 0);
2023 }
2024}