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}
48
49impl Default for OpenApiPlugin {
50 fn default() -> Self {
51 Self::new()
52 }
53}
54
55impl OpenApiPlugin {
56 pub fn new() -> Self {
57 Self {
58 base_path: "/openapi".to_string(),
59 title: "umbral API".to_string(),
60 version: "0.0.1".to_string(),
61 description: None,
62 extra_exclude: Vec::new(),
63 }
64 }
65
66 pub fn at(mut self, path: &str) -> Self {
70 let trimmed = path.trim_end_matches('/');
71 self.base_path = if trimmed.is_empty() {
72 "/".to_string()
73 } else {
74 trimmed.to_string()
75 };
76 self
77 }
78
79 pub fn title(mut self, s: impl Into<String>) -> Self {
81 self.title = s.into();
82 self
83 }
84
85 pub fn version(mut self, s: impl Into<String>) -> Self {
87 self.version = s.into();
88 self
89 }
90
91 pub fn description(mut self, s: impl Into<String>) -> Self {
97 self.description = Some(s.into());
98 self
99 }
100
101 pub fn exclude<I, S>(mut self, tables: I) -> Self
104 where
105 I: IntoIterator<Item = S>,
106 S: Into<String>,
107 {
108 for t in tables {
109 self.extra_exclude.push(t.into());
110 }
111 self
112 }
113
114 fn is_exposed(&self, table: &str) -> bool {
115 !self.extra_exclude.iter().any(|t| t == table)
121 }
122
123 fn spec_url(&self) -> String {
124 if self.base_path == "/" {
125 "/openapi.json".to_string()
126 } else {
127 format!("{}/openapi.json", self.base_path)
128 }
129 }
130
131 fn ui_route(&self) -> String {
132 if self.base_path == "/" {
133 "/".to_string()
134 } else {
135 format!("{}/", self.base_path)
136 }
137 }
138}
139
140static CONFIG: OnceLock<OpenApiPlugin> = OnceLock::new();
144
145pub fn spec_url() -> Option<String> {
158 CONFIG.get().map(|cfg| cfg.spec_url())
159}
160
161impl Plugin for OpenApiPlugin {
162 fn name(&self) -> &'static str {
163 "openapi"
164 }
165
166 fn dependencies(&self) -> &'static [&'static str] {
167 &["rest"]
168 }
169
170 fn routes(&self) -> Router {
171 let _ = CONFIG.set(self.clone());
172 umbral::routes::init_openapi_spec_url(self.spec_url());
177 let mut router = Router::new()
178 .route(&self.spec_url(), get(spec_handler))
179 .route(&self.ui_route(), get(swagger_ui_handler));
180 if self.base_path != "/" {
188 router = router.route(&self.base_path, get(swagger_ui_handler));
189 }
190 router
191 }
192}
193
194async fn spec_handler() -> Response {
199 let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
200 let spec = build_spec(cfg);
201 (
204 StatusCode::OK,
205 [(header::CONTENT_TYPE, "application/json")],
206 Json(spec),
207 )
208 .into_response()
209}
210
211async fn swagger_ui_handler() -> Response {
212 let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
213 let body = SWAGGER_UI_HTML.replace("{SPEC_URL}", &cfg.spec_url());
214 Html(body).into_response()
215}
216
217fn build_spec(cfg: &OpenApiPlugin) -> Value {
224 let mut schemas = Map::new();
225 let mut paths = Map::new();
226
227 let mut table_to_schema: std::collections::HashMap<String, String> =
235 std::collections::HashMap::new();
236 for plugin in umbral::migrate::registered_plugins() {
237 for model in umbral::migrate::models_for_plugin(&plugin) {
238 table_to_schema.insert(model.table.clone(), pascal_case_from_ident(&model.name));
239 }
240 }
241
242 let rest_base = umbral_rest::registered_base_path().to_owned();
246
247 for plugin in umbral::migrate::registered_plugins() {
248 for model in umbral::migrate::models_for_plugin(&plugin) {
249 if !umbral_rest::is_exposed(&model.table) {
258 continue;
259 }
260 if !cfg.is_exposed(&model.table) {
261 continue;
262 }
263 let schema_name = pascal_case_from_ident(&model.name);
264 schemas.insert(schema_name.clone(), model_schema(&model, &table_to_schema));
265 let mut list_params = Vec::new();
272 list_params.extend(pagination_parameters_for_style(
276 umbral_rest::registered_pagination_style(),
277 ));
278 if umbral_rest::search_enabled_for(&model.table) {
279 list_params.push(search_parameter());
280 }
281 list_params.push(fields_parameter(&model));
284 if model.fields.iter().any(|c| c.fk_target.is_some()) {
289 list_params.push(include_parameter(&model));
290 }
291 if umbral_rest::filters_enabled_for(&model.table) {
292 list_params.extend(filter_parameters(&model));
293 }
294 paths.insert(
295 format!("{}/{}/", rest_base, model.table),
296 collection_paths(&model.table, &schema_name, &list_params),
297 );
298 let mut item_params = vec![fields_parameter(&model)];
302 if model.fields.iter().any(|c| c.fk_target.is_some()) {
303 item_params.push(include_parameter(&model));
304 }
305 paths.insert(
306 format!("{}/{}/{{id}}", rest_base, model.table),
307 item_paths(&model.table, &schema_name, &item_params),
308 );
309 }
310 }
311
312 if let Some(entries) = umbral::routes::registered_openapi_paths() {
318 for (path, item) in entries {
319 paths.insert(path.clone(), item.clone());
320 }
321 }
322
323 for action in umbral_rest::registered_action_schemas() {
327 let path = if action.detail {
328 format!(
329 "{}/{}/{{id}}/{}/",
330 action.base_path, action.table, action.name
331 )
332 } else {
333 format!("{}/{}/{}/", action.base_path, action.table, action.name)
334 };
335 paths.insert(path, action_path_item(&action));
336 }
337
338 let mut info = Map::new();
339 info.insert("title".into(), Value::String(cfg.title.clone()));
340 info.insert("version".into(), Value::String(cfg.version.clone()));
341 if let Some(desc) = &cfg.description {
342 info.insert("description".into(), Value::String(desc.clone()));
343 }
344
345 let mut security_schemes = Map::new();
352 let mut security: Vec<Value> = Vec::new();
353 for (name, scheme) in umbral_rest::registered_security_schemes() {
354 security.push(json!({ name.clone(): [] }));
355 security_schemes.insert(name, scheme);
356 }
357 let mut components = Map::new();
358 components.insert("schemas".into(), Value::Object(schemas));
359 if !security_schemes.is_empty() {
360 components.insert("securitySchemes".into(), Value::Object(security_schemes));
361 }
362
363 let mut document = Map::new();
364 document.insert("openapi".into(), Value::String("3.0.3".into()));
365 document.insert("info".into(), Value::Object(info));
366 document.insert("paths".into(), Value::Object(paths));
367 document.insert("components".into(), Value::Object(components));
368 if !security.is_empty() {
369 document.insert("security".into(), Value::Array(security));
370 }
371 Value::Object(document)
372}
373
374fn action_path_item(a: &umbral_rest::ActionSchema) -> Value {
378 let mut op = Map::new();
379 op.insert(
380 "operationId".into(),
381 Value::String(format!("{}_{}", a.table, a.name)),
382 );
383 op.insert("tags".into(), json!([a.table]));
384 op.insert(
385 "summary".into(),
386 Value::String(format!("`{}` action on {}", a.name, a.table)),
387 );
388 if a.detail {
389 op.insert(
390 "parameters".into(),
391 json!([{
392 "name": "id", "in": "path", "required": true,
393 "schema": { "type": "string" },
394 "description": "Primary key of the target row"
395 }]),
396 );
397 }
398 if let Some(input) = &a.input_schema {
399 op.insert(
400 "requestBody".into(),
401 json!({ "required": true, "content": { "application/json": { "schema": input } } }),
402 );
403 }
404 let mut ok = Map::new();
405 ok.insert("description".into(), Value::String("Action result".into()));
406 if let Some(output) = &a.output_schema {
407 ok.insert(
408 "content".into(),
409 json!({ "application/json": { "schema": output } }),
410 );
411 }
412 op.insert("responses".into(), json!({ "200": Value::Object(ok) }));
413
414 let mut item = Map::new();
415 item.insert(a.method.to_lowercase(), Value::Object(op));
416 Value::Object(item)
417}
418
419fn model_schema(
420 model: &ModelMeta,
421 table_to_schema: &std::collections::HashMap<String, String>,
422) -> Value {
423 let mut properties = Map::new();
424 let mut required: Vec<Value> = Vec::new();
425 for col in &model.fields {
426 if umbral_rest::is_hidden(&model.table, &col.name) {
433 continue;
434 }
435 properties.insert(
436 col.name.clone(),
437 column_schema_with_refs(col, table_to_schema),
438 );
439 if !col.nullable && !col.primary_key && !col.auto_now && !col.auto_now_add && !col.noform {
449 required.push(Value::String(col.name.clone()));
450 }
451 }
452 for rel in &model.m2m_relations {
459 let target_schema = table_to_schema
460 .get(&rel.target_table)
461 .cloned()
462 .unwrap_or_else(|| pascal_case_from_ident(&rel.target_name));
463 let mut prop = serde_json::Map::new();
464 prop.insert("type".into(), Value::String("array".into()));
465 let (item_ty, item_fmt) = umbral::migrate::pk_meta_for_table(&rel.target_table)
468 .map(|(_, pk_ty)| openapi_type(pk_ty))
469 .unwrap_or(("integer", Some("int64")));
470 let items = match item_fmt {
471 Some(f) => json!({ "type": item_ty, "format": f }),
472 None => json!({ "type": item_ty }),
473 };
474 prop.insert("items".into(), items);
475 prop.insert(
476 "description".into(),
477 Value::String(format!(
478 "Many-to-many relation to {}. Send an array of child ids on \
479 create / update; the framework writes the junction table.",
480 target_schema,
481 )),
482 );
483 prop.insert("x-umbral-m2m".into(), Value::Bool(true));
486 prop.insert(
487 "x-umbral-m2m-target".into(),
488 Value::String(target_schema.clone()),
489 );
490 prop.insert(
491 "x-umbral-m2m-target-table".into(),
492 Value::String(rel.target_table.clone()),
493 );
494 if table_to_schema.contains_key(&rel.target_table) {
495 prop.insert(
496 "x-umbral-m2m-target-ref".into(),
497 Value::String(format!("#/components/schemas/{target_schema}")),
498 );
499 }
500 properties.insert(rel.field_name.clone(), Value::Object(prop));
501 }
502 let mut obj = Map::new();
503 obj.insert("type".into(), Value::String("object".into()));
504 obj.insert("properties".into(), Value::Object(properties));
505 if !required.is_empty() {
506 obj.insert("required".into(), Value::Array(required));
507 }
508 Value::Object(obj)
509}
510
511fn column_schema_with_refs(
515 col: &Column,
516 table_to_schema: &std::collections::HashMap<String, String>,
517) -> Value {
518 let mut value = column_schema(col);
519 if let Some(target_table) = &col.fk_target {
529 if let Some(schema_name) = table_to_schema.get(target_table) {
530 if let Some(obj) = value.as_object_mut() {
531 obj.insert(
532 "x-umbral-fk-ref".into(),
533 Value::String(format!("#/components/schemas/{schema_name}")),
534 );
535 }
536 }
537 }
538 value
539}
540
541fn column_schema(col: &Column) -> Value {
542 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
543 let mut obj = Map::new();
544 obj.insert("type".into(), Value::String(ty.into()));
545 if let Some(f) = format {
546 obj.insert("format".into(), Value::String(f.into()));
547 }
548 if col.nullable {
549 obj.insert("nullable".into(), Value::Bool(true));
550 }
551 if !col.help.is_empty() {
555 obj.insert("description".into(), Value::String(col.help.clone()));
556 }
557 if !col.example.is_empty() {
561 obj.insert("example".into(), Value::String(col.example.clone()));
562 }
563 if let Some(min) = col.min {
566 obj.insert(
567 "minimum".into(),
568 Value::Number(serde_json::Number::from(min)),
569 );
570 }
571 if let Some(max) = col.max {
572 obj.insert(
573 "maximum".into(),
574 Value::Number(serde_json::Number::from(max)),
575 );
576 }
577 if let Some(fmt) = col.text_format.as_deref() {
581 match fmt {
582 "email" => {
583 obj.insert("format".into(), Value::String("email".into()));
584 }
585 "url" => {
586 obj.insert("format".into(), Value::String("uri".into()));
587 }
588 "slug" => {
589 obj.insert("pattern".into(), Value::String("^[A-Za-z0-9_-]+$".into()));
593 }
594 _ => {}
595 }
596 }
597 if !col.choices.is_empty() && !col.is_multichoice {
603 obj.insert(
604 "enum".into(),
605 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
606 );
607 }
608 if col.max_length > 0 {
609 obj.insert(
610 "maxLength".into(),
611 Value::Number(serde_json::Number::from(col.max_length)),
612 );
613 }
614 if !col.default.is_empty() {
615 obj.insert("default".into(), Value::String(col.default.clone()));
620 }
621 if col.is_multichoice {
622 obj.insert("x-umbral-multichoice".into(), Value::Bool(true));
623 obj.insert(
624 "x-umbral-choices".into(),
625 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
626 );
627 }
628 if !col.choice_labels.is_empty() {
629 obj.insert(
630 "x-umbral-choice-labels".into(),
631 Value::Array(
632 col.choice_labels
633 .iter()
634 .cloned()
635 .map(Value::String)
636 .collect(),
637 ),
638 );
639 }
640 if let Some(target) = &col.fk_target {
641 obj.insert("x-umbral-fk-target".into(), Value::String(target.clone()));
642 }
643 if col.is_string_repr {
647 obj.insert("x-umbral-string-repr".into(), Value::Bool(true));
648 }
649 if col.auto_now_add {
670 obj.insert("x-umbral-auto-now-add".into(), Value::Bool(true));
671 }
672 if col.auto_now {
673 obj.insert("x-umbral-auto-now".into(), Value::Bool(true));
674 }
675 if col.noform {
676 obj.insert("readOnly".into(), Value::Bool(true));
677 obj.insert("x-umbral-noform".into(), Value::Bool(true));
683 }
684 if col.noedit {
689 obj.insert("x-umbral-noedit".into(), Value::Bool(true));
690 }
691 Value::Object(obj)
692}
693
694fn openapi_type(ty: SqlType) -> (&'static str, Option<&'static str>) {
695 match ty {
696 SqlType::SmallInt => ("integer", Some("int32")),
697 SqlType::Integer => ("integer", Some("int32")),
698 SqlType::BigInt => ("integer", Some("int64")),
699 SqlType::Real => ("number", Some("float")),
700 SqlType::Double => ("number", Some("double")),
701 SqlType::Boolean => ("boolean", None),
702 SqlType::Text => ("string", None),
703 SqlType::Date => ("string", Some("date")),
704 SqlType::Time => ("string", Some("time")),
705 SqlType::Timestamptz => ("string", Some("date-time")),
706 SqlType::Uuid => ("string", Some("uuid")),
707 SqlType::Json => ("object", None),
712 SqlType::Array(_) => ("array", None),
719 SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => ("string", None),
724 SqlType::FullText => ("string", None),
727 SqlType::Xml | SqlType::Ltree | SqlType::Bit => ("string", None),
730 SqlType::ForeignKey => ("integer", Some("int64")),
733 SqlType::Bytes => ("array", Some("byte")),
740 SqlType::Decimal => ("string", Some("decimal")),
746 }
747}
748
749fn search_parameter() -> Value {
760 json!({
761 "name": "search",
762 "in": "query",
763 "required": false,
764 "description": "Free-text search across every searchable column. \
765 Text columns match via case-insensitive substring; \
766 numeric / FK / Boolean columns match exactly when \
767 the term parses as that type. Multiple matches are \
768 ORed.",
769 "schema": { "type": "string" },
770 "x-umbral-search": true,
771 })
772}
773
774fn fields_parameter(model: &ModelMeta) -> Value {
785 let columns: Vec<Value> = model
788 .fields
789 .iter()
790 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
791 .map(|c| Value::String(c.name.clone()))
792 .collect();
793 json!({
794 "name": "fields",
795 "in": "query",
796 "required": false,
797 "description": "Comma-separated list of column names to include in the \
798 response. Unknown names are silently dropped; an empty \
799 value falls back to the full row (BUG-81). Composes \
800 with hide / transform / computed — hide always wins, \
801 the rest are returned iff in the list.",
802 "schema": { "type": "string" },
803 "x-umbral-fields": true,
804 "x-umbral-fields-columns": Value::Array(columns),
805 })
806}
807
808fn include_parameter(model: &ModelMeta) -> Value {
815 let fks: Vec<Value> = model
819 .fields
820 .iter()
821 .filter(|c| c.fk_target.is_some())
822 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
823 .map(|c| Value::String(c.name.clone()))
824 .collect();
825 json!({
826 "name": "include",
827 "in": "query",
828 "required": false,
829 "description": "Comma-separated list of foreign-key columns to expand \
830 in the response. Each named FK gets replaced with the \
831 full related-row JSON object (one batched IN(...) query \
832 per FK — no N+1). Unknown or non-FK names return a 400. \
833 Example: `?include=user,billing_address`.",
834 "schema": { "type": "string" },
835 "x-umbral-include": true,
836 "x-umbral-include-fks": Value::Array(fks),
837 })
838}
839
840fn pagination_parameters_for_style(style: umbral_rest::PaginationStyle) -> Vec<Value> {
849 match style {
850 umbral_rest::PaginationStyle::PageNumber => vec![
851 json!({
852 "name": "page",
853 "in": "query",
854 "required": false,
855 "description": "1-indexed page number. Defaults to 1 when omitted.",
856 "schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 },
857 "x-umbral-pagination": "page",
858 }),
859 json!({
860 "name": "page_size",
861 "in": "query",
862 "required": false,
863 "description": "Rows per page. Capped at 100. Default 20.",
864 "schema": {
865 "type": "integer", "format": "int32",
866 "minimum": 1, "maximum": 100, "default": 20,
867 },
868 "x-umbral-pagination": "page_size",
869 }),
870 ],
871 umbral_rest::PaginationStyle::LimitOffset => vec![
872 json!({
873 "name": "limit",
874 "in": "query",
875 "required": false,
876 "description": "Maximum rows to return. Defaults to the configured page size.",
877 "schema": { "type": "integer", "format": "int32", "minimum": 1 },
878 "x-umbral-pagination": "limit",
879 }),
880 json!({
881 "name": "offset",
882 "in": "query",
883 "required": false,
884 "description": "Number of rows to skip from the start of the result set. Defaults to 0.",
885 "schema": { "type": "integer", "format": "int32", "minimum": 0, "default": 0 },
886 "x-umbral-pagination": "offset",
887 }),
888 ],
889 umbral_rest::PaginationStyle::None | umbral_rest::PaginationStyle::Custom => vec![],
890 }
891}
892
893fn filter_parameters(model: &ModelMeta) -> Vec<Value> {
902 let mut out: Vec<Value> = Vec::new();
903 for col in &model.fields {
904 if col.primary_key {
905 continue;
906 }
907 let lookups = umbral_rest::filtering::applicable_lookups(col);
908 for lookup in lookups {
909 let name = if lookup == "eq" {
910 col.name.clone()
911 } else {
912 format!("{}__{}", col.name, lookup)
913 };
914 out.push(filter_parameter(col, lookup, &name));
915 }
916 }
917 out
918}
919
920fn filter_parameter(col: &Column, lookup: &str, name: &str) -> Value {
931 let (schema, description) = match lookup {
932 "in" => (
933 json!({ "type": "string" }),
934 format!(
935 "Comma-separated `{}` values; matches rows where the column is in the set.",
936 col.name,
937 ),
938 ),
939 "isnull" => (
940 json!({ "type": "boolean" }),
941 format!(
942 "`true` matches rows where `{}` IS NULL; `false` matches IS NOT NULL.",
943 col.name,
944 ),
945 ),
946 "contains" | "icontains" | "startswith" => {
947 let phrase = match lookup {
948 "contains" => "case-sensitive substring",
949 "icontains" => "case-insensitive substring",
950 "startswith" => "case-sensitive prefix",
951 _ => unreachable!(),
952 };
953 (
954 json!({ "type": "string" }),
955 format!(
956 "Matches rows where `{}` contains the given {phrase}.",
957 col.name
958 ),
959 )
960 }
961 _ => {
963 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
964 let mut schema_obj = Map::new();
965 schema_obj.insert("type".into(), Value::String(ty.into()));
966 if let Some(f) = format {
967 schema_obj.insert("format".into(), Value::String(f.into()));
968 }
969 let phrase = match lookup {
970 "eq" => "equals the value",
971 "ne" => "does not equal the value",
972 "gte" => "is greater than or equal to the value",
973 "lte" => "is less than or equal to the value",
974 "gt" => "is greater than the value",
975 "lt" => "is less than the value",
976 _ => "matches the value",
977 };
978 (
979 Value::Object(schema_obj),
980 format!("Matches rows where `{}` {phrase}.", col.name),
981 )
982 }
983 };
984
985 json!({
986 "name": name,
987 "in": "query",
988 "required": false,
989 "description": description,
990 "schema": schema,
991 "x-umbral-filter-field": col.name,
992 "x-umbral-filter-lookup": lookup,
993 })
994}
995
996fn collection_paths(table: &str, schema_name: &str, filter_params: &[Value]) -> Value {
997 let mut get_op = Map::new();
1001 get_op.insert(
1002 "operationId".into(),
1003 Value::String(format!("list_{}", table)),
1004 );
1005 get_op.insert("tags".into(), json!([table]));
1006 if !filter_params.is_empty() {
1007 get_op.insert("parameters".into(), Value::Array(filter_params.to_vec()));
1008 }
1009 get_op.insert(
1010 "responses".into(),
1011 json!({
1012 "200": {
1013 "description": "List of rows",
1014 "content": {
1015 "application/json": {
1016 "schema": list_envelope(schema_name)
1017 }
1018 }
1019 }
1020 }),
1021 );
1022
1023 json!({
1024 "get": Value::Object(get_op),
1025 "post": {
1026 "operationId": format!("create_{}", table),
1027 "tags": [table],
1028 "requestBody": {
1029 "required": true,
1030 "content": {
1031 "application/json": {
1032 "schema": schema_ref(schema_name)
1033 }
1034 }
1035 },
1036 "responses": {
1037 "201": {
1038 "description": "Row created",
1039 "content": {
1040 "application/json": {
1041 "schema": schema_ref(schema_name)
1042 }
1043 }
1044 },
1045 "400": { "description": "Invalid input" }
1046 }
1047 }
1048 })
1049}
1050
1051fn item_paths(table: &str, schema_name: &str, retrieve_query_params: &[Value]) -> Value {
1052 let id_param = json!({
1053 "name": "id",
1054 "in": "path",
1055 "required": true,
1056 "schema": { "type": "string" }
1057 });
1058 let mut get_op = Map::new();
1064 get_op.insert(
1065 "operationId".into(),
1066 Value::String(format!("retrieve_{}", table)),
1067 );
1068 get_op.insert("tags".into(), json!([table]));
1069 if !retrieve_query_params.is_empty() {
1070 get_op.insert(
1071 "parameters".into(),
1072 Value::Array(retrieve_query_params.to_vec()),
1073 );
1074 }
1075 get_op.insert(
1076 "responses".into(),
1077 json!({
1078 "200": {
1079 "description": "Row found",
1080 "content": {
1081 "application/json": {
1082 "schema": schema_ref(schema_name)
1083 }
1084 }
1085 },
1086 "404": { "description": "Not found" }
1087 }),
1088 );
1089 json!({
1090 "parameters": [id_param],
1091 "get": Value::Object(get_op),
1092 "put": {
1093 "operationId": format!("update_{}", table),
1094 "tags": [table],
1095 "requestBody": {
1096 "required": true,
1097 "content": {
1098 "application/json": {
1099 "schema": schema_ref(schema_name)
1100 }
1101 }
1102 },
1103 "responses": {
1104 "200": {
1105 "description": "Row updated",
1106 "content": {
1107 "application/json": {
1108 "schema": schema_ref(schema_name)
1109 }
1110 }
1111 },
1112 "404": { "description": "Not found" }
1113 }
1114 },
1115 "patch": {
1116 "operationId": format!("partial_update_{}", table),
1117 "tags": [table],
1118 "requestBody": {
1119 "required": true,
1120 "content": {
1121 "application/json": {
1122 "schema": schema_ref(schema_name)
1123 }
1124 }
1125 },
1126 "responses": {
1127 "200": {
1128 "description": "Row partially updated",
1129 "content": {
1130 "application/json": {
1131 "schema": schema_ref(schema_name)
1132 }
1133 }
1134 },
1135 "404": { "description": "Not found" }
1136 }
1137 },
1138 "delete": {
1139 "operationId": format!("destroy_{}", table),
1140 "tags": [table],
1141 "responses": {
1142 "204": { "description": "Row deleted" },
1143 "404": { "description": "Not found" }
1144 }
1145 }
1146 })
1147}
1148
1149fn schema_ref(name: &str) -> Value {
1150 json!({ "$ref": format!("#/components/schemas/{}", name) })
1151}
1152
1153fn list_envelope(schema_name: &str) -> Value {
1154 json!({
1155 "type": "object",
1156 "properties": {
1157 "results": {
1158 "type": "array",
1159 "items": schema_ref(schema_name)
1160 },
1161 "count": { "type": "integer" }
1162 },
1163 "required": ["results", "count"]
1164 })
1165}
1166
1167#[doc(hidden)]
1171pub fn test_spec_url(p: &OpenApiPlugin) -> String {
1172 p.spec_url()
1173}
1174
1175#[doc(hidden)]
1176pub fn test_ui_route(p: &OpenApiPlugin) -> String {
1177 p.ui_route()
1178}
1179
1180#[cfg(test)]
1184mod tests {
1185 use super::*;
1186 use umbral::migrate::Column;
1187 use umbral::orm::SqlType;
1188
1189 fn base_col(name: &str, ty: SqlType) -> Column {
1190 Column {
1191 name: name.into(),
1192 ty,
1193 primary_key: false,
1194 nullable: false,
1195 fk_target: None,
1196 noform: false,
1197 db_constraint: true,
1198 noedit: false,
1199 is_string_repr: false,
1200 max_length: 0,
1201 choices: Vec::new(),
1202 choice_labels: Vec::new(),
1203 default: String::new(),
1204 is_multichoice: false,
1205 unique: false,
1206 on_delete: ::umbral::orm::FkAction::NoAction,
1207 on_update: ::umbral::orm::FkAction::NoAction,
1208 index: false,
1209 auto_now_add: false,
1210 auto_now: false,
1211 help: String::new(),
1212 example: String::new(),
1213 widget: None,
1214 supported_backends: Vec::new(),
1215 min: None,
1216 max: None,
1217 text_format: ::core::option::Option::None,
1218 slug_from: ::core::option::Option::None,
1219 }
1220 }
1221
1222 #[test]
1223 fn choices_render_as_openapi_enum_with_labels_extension() {
1224 let mut col = base_col("status", SqlType::Text);
1225 col.choices = vec!["draft".into(), "published".into(), "archived".into()];
1226 col.choice_labels = vec!["Draft".into(), "Published".into(), "Archived".into()];
1227 let schema = column_schema(&col);
1228 assert_eq!(schema["type"], "string");
1229 assert_eq!(
1230 schema["enum"],
1231 serde_json::json!(["draft", "published", "archived"])
1232 );
1233 assert_eq!(
1234 schema["x-umbral-choice-labels"],
1235 serde_json::json!(["Draft", "Published", "Archived"])
1236 );
1237 }
1238
1239 #[test]
1240 fn multichoice_skips_enum_and_uses_vendor_extension() {
1241 let mut col = base_col("tags", SqlType::Text);
1242 col.choices = vec!["rust".into(), "python".into()];
1243 col.is_multichoice = true;
1244 let schema = column_schema(&col);
1245 assert!(
1246 schema.get("enum").is_none(),
1247 "multichoice columns should not declare a flat enum (value is a CSV subset)"
1248 );
1249 assert_eq!(schema["x-umbral-multichoice"], true);
1250 assert_eq!(
1251 schema["x-umbral-choices"],
1252 serde_json::json!(["rust", "python"])
1253 );
1254 }
1255
1256 #[test]
1257 fn max_length_and_default_surface_as_standard_openapi_keys() {
1258 let mut col = base_col("title", SqlType::Text);
1259 col.max_length = 50;
1260 col.default = "untitled".into();
1261 let schema = column_schema(&col);
1262 assert_eq!(schema["maxLength"], 50);
1263 assert_eq!(schema["default"], "untitled");
1264 }
1265
1266 #[test]
1267 fn fk_target_emits_vendor_extension_for_playground_navigation() {
1268 let mut col = base_col("author_id", SqlType::ForeignKey);
1269 col.fk_target = Some("auth_user".into());
1270 let schema = column_schema(&col);
1271 assert_eq!(schema["type"], "integer");
1272 assert_eq!(schema["format"], "int64");
1273 assert_eq!(schema["x-umbral-fk-target"], "auth_user");
1274 }
1275
1276 #[test]
1277 fn noform_renders_as_read_only_and_carries_vendor_extension() {
1278 let mut col = base_col("internal_token", SqlType::Text);
1283 col.noform = true;
1284 let schema = column_schema(&col);
1285 assert_eq!(schema["readOnly"], true);
1286 assert_eq!(schema["x-umbral-noform"], true);
1287 }
1288
1289 #[test]
1290 fn noedit_does_NOT_render_as_read_only() {
1291 let mut col = base_col("email", SqlType::Text);
1297 col.noedit = true;
1298 let schema = column_schema(&col);
1299 assert!(
1300 schema.get("readOnly").is_none(),
1301 "noedit must NOT contaminate the API request-body contract; \
1302 got readOnly in schema: {schema:?}"
1303 );
1304 assert_eq!(schema["x-umbral-noedit"], true);
1307 }
1308
1309 #[test]
1310 fn plain_column_keeps_minimal_schema_no_extensions() {
1311 let col = base_col("body", SqlType::Text);
1312 let schema = column_schema(&col);
1313 let obj = schema.as_object().expect("object");
1314 assert_eq!(
1315 obj.len(),
1316 1,
1317 "plain column should only have `type`: {obj:?}"
1318 );
1319 assert_eq!(schema["type"], "string");
1320 }
1321
1322 #[test]
1327 fn help_attribute_flows_to_openapi_description() {
1328 let mut col = base_col("status", SqlType::Text);
1329 col.help = "Workflow step. Set by editors on Save.".to_string();
1330 let schema = column_schema(&col);
1331 assert_eq!(
1332 schema["description"], "Workflow step. Set by editors on Save.",
1333 "help should round-trip to OpenAPI description; got: {schema:?}",
1334 );
1335 }
1336
1337 #[test]
1338 fn empty_help_omits_description() {
1339 let col = base_col("body", SqlType::Text);
1340 let schema = column_schema(&col);
1341 assert!(
1342 schema.get("description").is_none(),
1343 "empty help should omit description; got: {schema:?}",
1344 );
1345 }
1346
1347 #[test]
1351 fn example_attribute_flows_to_openapi_example() {
1352 let mut col = base_col("status", SqlType::Text);
1353 col.example = "published".to_string();
1354 let schema = column_schema(&col);
1355 assert_eq!(
1356 schema["example"], "published",
1357 "example should round-trip; got: {schema:?}",
1358 );
1359 }
1360
1361 #[test]
1362 fn empty_example_omits_example() {
1363 let col = base_col("body", SqlType::Text);
1364 let schema = column_schema(&col);
1365 assert!(
1366 schema.get("example").is_none(),
1367 "empty example should omit example key; got: {schema:?}",
1368 );
1369 }
1370
1371 fn note_model() -> ModelMeta {
1376 let mut id = base_col("id", SqlType::BigInt);
1377 id.primary_key = true;
1378 let mut published_at = base_col("published_at", SqlType::Timestamptz);
1379 published_at.nullable = true;
1380 ModelMeta {
1381 name: "Note".to_string(),
1382 table: "note".to_string(),
1383 fields: vec![
1384 id,
1385 base_col("title", SqlType::Text),
1386 base_col("views", SqlType::Integer),
1387 published_at,
1388 ],
1389 display: "Note".to_string(),
1390 icon: "database".to_string(),
1391 database: None,
1392 singleton: false,
1393 unique_together: Vec::new(),
1394 indexes: Vec::new(),
1395 ordering: Vec::new(),
1396 m2m_relations: Vec::new(),
1397 soft_delete: false,
1398 app_label: "app".to_string(),
1399 }
1400 }
1401
1402 #[test]
1403 fn filter_parameters_skips_primary_key() {
1404 let params = filter_parameters(¬e_model());
1405 let names: Vec<&str> = params.iter().map(|p| p["name"].as_str().unwrap()).collect();
1406 assert!(
1407 !names.iter().any(|n| *n == "id" || n.starts_with("id__")),
1408 "PK column should be skipped; got {names:?}",
1409 );
1410 }
1411
1412 #[test]
1413 fn filter_parameters_eq_uses_bare_column_name_no_suffix() {
1414 let params = filter_parameters(¬e_model());
1415 let bare_title = params
1416 .iter()
1417 .find(|p| p["name"] == "title")
1418 .expect("title eq parameter should be present");
1419 assert_eq!(bare_title["x-umbral-filter-lookup"], "eq");
1420 assert_eq!(bare_title["x-umbral-filter-field"], "title");
1421 assert_eq!(bare_title["schema"]["type"], "string");
1422 }
1423
1424 #[test]
1425 fn filter_parameters_in_is_string_typed_with_csv_description() {
1426 let params = filter_parameters(¬e_model());
1427 let title_in = params
1428 .iter()
1429 .find(|p| p["name"] == "title__in")
1430 .expect("title__in parameter should be present");
1431 assert_eq!(title_in["schema"]["type"], "string");
1432 assert!(
1433 title_in["description"]
1434 .as_str()
1435 .unwrap()
1436 .to_lowercase()
1437 .contains("comma"),
1438 "__in description should mention the comma-separated format",
1439 );
1440 }
1441
1442 #[test]
1443 fn filter_parameters_isnull_only_on_nullable_columns() {
1444 let params = filter_parameters(¬e_model());
1445 let isnull_params: Vec<&str> = params
1446 .iter()
1447 .filter_map(|p| p["name"].as_str())
1448 .filter(|n| n.ends_with("__isnull"))
1449 .collect();
1450 assert_eq!(
1451 isnull_params,
1452 vec!["published_at__isnull"],
1453 "isnull lookup should only appear for nullable columns; got {isnull_params:?}",
1454 );
1455 }
1456
1457 #[test]
1458 fn filter_parameters_range_lookups_only_on_numeric_or_temporal() {
1459 let params = filter_parameters(¬e_model());
1460 let has_gte = |field: &str| params.iter().any(|p| p["name"] == format!("{field}__gte"));
1461 assert!(has_gte("views"), "integer column gets gte");
1462 assert!(has_gte("published_at"), "timestamp column gets gte");
1463 assert!(
1464 !has_gte("title"),
1465 "text column must NOT get gte; got {params:?}",
1466 );
1467 }
1468
1469 #[test]
1470 fn filter_parameters_string_lookups_only_on_text() {
1471 let params = filter_parameters(¬e_model());
1472 let has_contains = |field: &str| {
1473 params
1474 .iter()
1475 .any(|p| p["name"] == format!("{field}__contains"))
1476 };
1477 assert!(has_contains("title"), "text column gets contains");
1478 assert!(
1479 !has_contains("views"),
1480 "integer column must NOT get contains; got {params:?}",
1481 );
1482 }
1483
1484 #[test]
1485 fn collection_paths_omits_parameters_array_when_no_filters() {
1486 let value = collection_paths("note", "Note", &[]);
1487 let get_op = &value["get"];
1488 assert!(
1489 get_op.get("parameters").is_none(),
1490 "no filters → no parameters key; got {get_op:?}",
1491 );
1492 }
1493
1494 #[test]
1495 fn collection_paths_includes_parameters_when_filters_present() {
1496 let filter_params = filter_parameters(¬e_model());
1497 let value = collection_paths("note", "Note", &filter_params);
1498 let params = value["get"]["parameters"]
1499 .as_array()
1500 .expect("parameters array should be present when filters land");
1501 assert!(!params.is_empty());
1502 assert!(
1503 params.iter().all(|p| p["in"] == "query"),
1504 "every filter parameter is in: query",
1505 );
1506 }
1507
1508 #[test]
1513 fn fields_parameter_lists_model_columns() {
1514 let param = fields_parameter(¬e_model());
1515 assert_eq!(param["name"], "fields");
1516 assert_eq!(param["in"], "query");
1517 assert_eq!(param["x-umbral-fields"], true);
1518 let cols = param["x-umbral-fields-columns"]
1519 .as_array()
1520 .expect("x-umbral-fields-columns should be a list");
1521 let names: Vec<&str> = cols.iter().filter_map(|v| v.as_str()).collect();
1522 assert!(names.contains(&"title"));
1523 assert!(names.contains(&"views"));
1524 assert!(
1525 !names.is_empty(),
1526 "every column should land in the enum so the playground can offer it",
1527 );
1528 }
1529
1530 #[test]
1533 fn item_paths_advertises_fields_query_param_on_retrieve() {
1534 let value = item_paths("note", "Note", &[fields_parameter(¬e_model())]);
1535 let get_params = value["get"]["parameters"]
1536 .as_array()
1537 .expect("retrieve op should carry its query parameters");
1538 assert!(
1539 get_params.iter().any(|p| p["name"] == "fields"),
1540 "fields parameter should be on the retrieve op; got {get_params:?}",
1541 );
1542 }
1543
1544 #[test]
1549 fn fk_column_emits_schema_ref_when_target_known() {
1550 let mut col = base_col("author", SqlType::ForeignKey);
1551 col.fk_target = Some("auth_user".into());
1552 let mut map = std::collections::HashMap::new();
1553 map.insert("auth_user".to_string(), "AuthUser".to_string());
1554 let schema = column_schema_with_refs(&col, &map);
1555 assert_eq!(
1556 schema["x-umbral-fk-target"], "auth_user",
1557 "the table-name vendor extension stays for backward compat",
1558 );
1559 assert_eq!(
1560 schema["x-umbral-fk-ref"], "#/components/schemas/AuthUser",
1561 "the JSON pointer to the target schema should be emitted",
1562 );
1563 }
1564
1565 #[test]
1566 fn fk_column_without_known_target_omits_schema_ref() {
1567 let mut col = base_col("author", SqlType::ForeignKey);
1568 col.fk_target = Some("unknown_table".into());
1569 let map = std::collections::HashMap::new();
1570 let schema = column_schema_with_refs(&col, &map);
1571 assert!(
1572 schema.get("x-umbral-fk-ref").is_none(),
1573 "unknown FK target → no ref emitted; got: {schema:?}",
1574 );
1575 }
1576
1577 #[test]
1583 fn m2m_relation_lands_in_model_schema_with_target_extension() {
1584 let mut model = note_model();
1585 model.m2m_relations.push(umbral::migrate::M2MRelation {
1586 field_name: "tags".to_string(),
1587 target_table: "tag".to_string(),
1588 target_name: "Tag".to_string(),
1589 });
1590 let mut tts = std::collections::HashMap::new();
1594 tts.insert("tag".to_string(), "Tag".to_string());
1595 let schema = model_schema(&model, &tts);
1596 let tags_prop = &schema["properties"]["tags"];
1597 assert_eq!(tags_prop["type"], "array");
1598 assert_eq!(tags_prop["items"]["type"], "integer");
1599 assert_eq!(tags_prop["x-umbral-m2m"], true);
1600 assert_eq!(tags_prop["x-umbral-m2m-target"], "Tag");
1601 assert_eq!(tags_prop["x-umbral-m2m-target-table"], "tag");
1602 assert_eq!(
1603 tags_prop["x-umbral-m2m-target-ref"],
1604 "#/components/schemas/Tag",
1605 );
1606 let required = schema["required"].as_array();
1608 if let Some(req) = required {
1609 assert!(!req.iter().any(|v| v == "tags"));
1610 }
1611 }
1612
1613 #[test]
1621 fn auto_now_columns_are_optional_in_the_request_schema() {
1622 let mut model = note_model();
1623 let mut created = base_col("created_at", SqlType::Timestamptz);
1624 created.auto_now_add = true;
1625 let mut updated = base_col("updated_at", SqlType::Timestamptz);
1626 updated.auto_now = true;
1627 model.fields.push(created);
1628 model.fields.push(updated);
1629
1630 let schema = model_schema(&model, &std::collections::HashMap::new());
1631
1632 assert_eq!(
1636 schema["properties"]["created_at"]["x-umbral-auto-now-add"],
1637 true
1638 );
1639 assert_eq!(schema["properties"]["updated_at"]["x-umbral-auto-now"], true);
1640
1641 assert!(
1645 schema["properties"]["created_at"].get("readOnly").is_none(),
1646 "auto_now_add must not be readOnly; got {}",
1647 schema["properties"]["created_at"],
1648 );
1649 assert!(
1650 schema["properties"]["updated_at"].get("readOnly").is_none(),
1651 "auto_now must not be readOnly; got {}",
1652 schema["properties"]["updated_at"],
1653 );
1654
1655 let required = schema["required"].as_array().expect("required array");
1658 let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1659 assert!(
1660 !names.contains(&"created_at"),
1661 "auto_now_add should drop out of required; got {names:?}",
1662 );
1663 assert!(
1664 !names.contains(&"updated_at"),
1665 "auto_now should drop out of required; got {names:?}",
1666 );
1667 }
1668
1669 #[test]
1672 fn pagination_parameters_per_style() {
1673 use umbral_rest::PaginationStyle;
1674
1675 let none_params = pagination_parameters_for_style(PaginationStyle::None);
1677 assert!(
1678 none_params.is_empty(),
1679 "NoPagination should emit no pagination params; got {none_params:?}"
1680 );
1681
1682 let custom_params = pagination_parameters_for_style(PaginationStyle::Custom);
1684 assert!(
1685 custom_params.is_empty(),
1686 "Custom pagination should emit no params; got {custom_params:?}"
1687 );
1688
1689 let page_params = pagination_parameters_for_style(PaginationStyle::PageNumber);
1691 assert_eq!(page_params.len(), 2, "PageNumber should emit 2 params");
1692 assert_eq!(page_params[0]["name"], "page");
1693 assert_eq!(page_params[0]["in"], "query");
1694 assert_eq!(page_params[0]["schema"]["type"], "integer");
1695 assert_eq!(page_params[0]["schema"]["minimum"], 1);
1696 assert_eq!(page_params[0]["schema"]["default"], 1);
1697 assert_eq!(page_params[0]["x-umbral-pagination"], "page");
1698 assert_eq!(page_params[1]["name"], "page_size");
1699 assert_eq!(page_params[1]["schema"]["maximum"], 100);
1700 assert_eq!(page_params[1]["x-umbral-pagination"], "page_size");
1701
1702 let lo_params = pagination_parameters_for_style(PaginationStyle::LimitOffset);
1704 assert_eq!(lo_params.len(), 2, "LimitOffset should emit 2 params");
1705 assert_eq!(lo_params[0]["name"], "limit");
1706 assert_eq!(lo_params[0]["x-umbral-pagination"], "limit");
1707 assert_eq!(lo_params[1]["name"], "offset");
1708 assert_eq!(lo_params[1]["x-umbral-pagination"], "offset");
1709 assert_eq!(lo_params[1]["schema"]["minimum"], 0);
1710 }
1711}