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
68impl Default for OpenApiPlugin {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl OpenApiPlugin {
75 pub fn new() -> Self {
76 Self {
77 base_path: "/openapi".to_string(),
78 title: "umbral API".to_string(),
79 version: "0.0.1".to_string(),
80 description: None,
81 extra_exclude: Vec::new(),
82 allow_in_prod: false,
83 swagger_asset_base: DEFAULT_SWAGGER_ASSET_BASE.to_string(),
84 }
85 }
86
87 pub fn swagger_asset_base(mut self, base: impl Into<String>) -> Self {
92 self.swagger_asset_base = base.into();
93 self
94 }
95
96 pub fn allow_in_prod(mut self) -> Self {
100 self.allow_in_prod = true;
101 self
102 }
103
104 pub fn at(mut self, path: &str) -> Self {
108 let trimmed = path.trim_end_matches('/');
109 self.base_path = if trimmed.is_empty() {
110 "/".to_string()
111 } else {
112 trimmed.to_string()
113 };
114 self
115 }
116
117 pub fn title(mut self, s: impl Into<String>) -> Self {
119 self.title = s.into();
120 self
121 }
122
123 pub fn version(mut self, s: impl Into<String>) -> Self {
125 self.version = s.into();
126 self
127 }
128
129 pub fn description(mut self, s: impl Into<String>) -> Self {
135 self.description = Some(s.into());
136 self
137 }
138
139 pub fn exclude<I, S>(mut self, tables: I) -> Self
142 where
143 I: IntoIterator<Item = S>,
144 S: Into<String>,
145 {
146 for t in tables {
147 self.extra_exclude.push(t.into());
148 }
149 self
150 }
151
152 fn is_exposed(&self, table: &str) -> bool {
153 !self.extra_exclude.iter().any(|t| t == table)
159 }
160
161 fn spec_url(&self) -> String {
162 if self.base_path == "/" {
163 "/openapi.json".to_string()
164 } else {
165 format!("{}/openapi.json", self.base_path)
166 }
167 }
168
169 fn ui_route(&self) -> String {
170 if self.base_path == "/" {
171 "/".to_string()
172 } else {
173 format!("{}/", self.base_path)
174 }
175 }
176}
177
178static CONFIG: OnceLock<OpenApiPlugin> = OnceLock::new();
182
183pub fn spec_url() -> Option<String> {
196 CONFIG.get().map(|cfg| cfg.spec_url())
197}
198
199impl Plugin for OpenApiPlugin {
200 fn name(&self) -> &'static str {
201 "openapi"
202 }
203
204 fn dependencies(&self) -> &'static [&'static str] {
205 &["rest"]
206 }
207
208 fn routes(&self) -> Router {
209 let is_prod = matches!(
212 umbral::settings::get_opt().map(|s| &s.environment),
213 Some(umbral::Environment::Prod)
214 );
215 if is_prod && !self.allow_in_prod {
216 tracing::warn!(
217 "umbral-openapi: not mounting in Environment::Prod (the OpenAPI spec maps your \
218 entire API surface for unauthenticated callers). Call \
219 OpenApiPlugin::new().allow_in_prod() to override, ideally behind a firewall.",
220 );
221 return Router::new();
222 }
223 let _ = CONFIG.set(self.clone());
224 umbral::routes::init_openapi_spec_url(self.spec_url());
229 let mut router = Router::new()
230 .route(&self.spec_url(), get(spec_handler))
231 .route(&self.ui_route(), get(swagger_ui_handler));
232 if self.base_path != "/" {
240 router = router.route(&self.base_path, get(swagger_ui_handler));
241 }
242 router
243 }
244}
245
246async fn spec_handler() -> Response {
251 let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
252 let spec = build_spec(cfg);
253 (
256 StatusCode::OK,
257 [(header::CONTENT_TYPE, "application/json")],
258 Json(spec),
259 )
260 .into_response()
261}
262
263async fn swagger_ui_handler() -> Response {
264 let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
265 let body = SWAGGER_UI_HTML
266 .replace("{ASSET_BASE}", &cfg.swagger_asset_base)
267 .replace("{SPEC_URL}", &cfg.spec_url());
268 Html(body).into_response()
269}
270
271fn build_spec(cfg: &OpenApiPlugin) -> Value {
278 let mut schemas = Map::new();
279 let mut paths = Map::new();
280
281 let mut table_to_schema: std::collections::HashMap<String, String> =
289 std::collections::HashMap::new();
290 for plugin in umbral::migrate::registered_plugins() {
291 for model in umbral::migrate::models_for_plugin(&plugin) {
292 table_to_schema.insert(model.table.clone(), pascal_case_from_ident(&model.name));
293 }
294 }
295
296 let rest_base = umbral_rest::registered_base_path().to_owned();
300
301 for plugin in umbral::migrate::registered_plugins() {
302 for model in umbral::migrate::models_for_plugin(&plugin) {
303 if !umbral_rest::is_exposed(&model.table) {
312 continue;
313 }
314 if !cfg.is_exposed(&model.table) {
315 continue;
316 }
317 let schema_name = pascal_case_from_ident(&model.name);
318 schemas.insert(schema_name.clone(), model_schema(&model, &table_to_schema));
319 let mut list_params = Vec::new();
326 list_params.extend(pagination_parameters_for_style(
330 umbral_rest::registered_pagination_style(),
331 ));
332 if umbral_rest::search_enabled_for(&model.table) {
333 list_params.push(search_parameter());
334 }
335 list_params.push(fields_parameter(&model));
338 if model.fields.iter().any(|c| c.fk_target.is_some()) {
343 list_params.push(include_parameter(&model));
344 }
345 if umbral_rest::filters_enabled_for(&model.table) {
346 list_params.extend(filter_parameters(&model));
347 }
348 let collection = collection_paths(&model.table, &schema_name, &list_params);
352 if has_operations(&collection) {
353 paths.insert(format!("{}/{}/", rest_base, model.table), collection);
354 }
355 let mut item_params = vec![fields_parameter(&model)];
359 if model.fields.iter().any(|c| c.fk_target.is_some()) {
360 item_params.push(include_parameter(&model));
361 }
362 let item = item_paths(&model.table, &schema_name, &item_params);
366 if has_operations(&item) {
367 paths.insert(format!("{}/{}/{{id}}", rest_base, model.table), item);
368 }
369 }
370 }
371
372 if let Some(entries) = umbral::routes::registered_openapi_paths() {
378 for (path, item) in entries {
379 paths.insert(path.clone(), item.clone());
380 }
381 }
382
383 for action in umbral_rest::registered_action_schemas() {
388 let path = if action.detail {
389 format!(
390 "{}/{}/{{id}}/{}/",
391 action.base_path, action.table, action.name
392 )
393 } else {
394 format!("{}/{}/{}/", action.base_path, action.table, action.name)
395 };
396 paths.insert(path, action_path_item(&action));
397 }
398
399 let mut info = Map::new();
400 info.insert("title".into(), Value::String(cfg.title.clone()));
401 info.insert("version".into(), Value::String(cfg.version.clone()));
402 if let Some(desc) = &cfg.description {
403 info.insert("description".into(), Value::String(desc.clone()));
404 }
405
406 let mut security_schemes = Map::new();
413 let mut security: Vec<Value> = Vec::new();
414 for (name, scheme) in umbral_rest::registered_security_schemes() {
415 security.push(json!({ name.clone(): [] }));
416 security_schemes.insert(name, scheme);
417 }
418 let mut components = Map::new();
419 components.insert("schemas".into(), Value::Object(schemas));
420 if !security_schemes.is_empty() {
421 components.insert("securitySchemes".into(), Value::Object(security_schemes));
422 }
423
424 let mut document = Map::new();
425 document.insert("openapi".into(), Value::String("3.0.3".into()));
426 document.insert("info".into(), Value::Object(info));
427 document.insert("paths".into(), Value::Object(paths));
428 document.insert("components".into(), Value::Object(components));
429 if !security.is_empty() {
430 document.insert("security".into(), Value::Array(security));
431 }
432 Value::Object(document)
433}
434
435fn action_path_item(a: &umbral_rest::ActionSchema) -> Value {
439 let mut op = Map::new();
440 op.insert(
441 "operationId".into(),
442 Value::String(format!("{}_{}", a.table, a.name)),
443 );
444 op.insert("tags".into(), json!([a.table]));
445 op.insert(
446 "summary".into(),
447 Value::String(format!("`{}` action on {}", a.name, a.table)),
448 );
449 if a.detail {
450 op.insert(
451 "parameters".into(),
452 json!([{
453 "name": "id", "in": "path", "required": true,
454 "schema": { "type": "string" },
455 "description": "Primary key of the target row"
456 }]),
457 );
458 }
459 if let Some(input) = &a.input_schema {
460 op.insert(
461 "requestBody".into(),
462 json!({ "required": true, "content": { "application/json": { "schema": input } } }),
463 );
464 }
465 let mut ok = Map::new();
466 ok.insert("description".into(), Value::String("Action result".into()));
467 if let Some(output) = &a.output_schema {
468 ok.insert(
469 "content".into(),
470 json!({ "application/json": { "schema": output } }),
471 );
472 }
473 op.insert("responses".into(), json!({ "200": Value::Object(ok) }));
474
475 let mut item = Map::new();
476 item.insert(a.method.to_lowercase(), Value::Object(op));
477 Value::Object(item)
478}
479
480fn model_schema(
481 model: &ModelMeta,
482 table_to_schema: &std::collections::HashMap<String, String>,
483) -> Value {
484 let mut properties = Map::new();
485 let mut required: Vec<Value> = Vec::new();
486 for col in &model.fields {
487 if umbral_rest::is_hidden(&model.table, &col.name) {
494 continue;
495 }
496 properties.insert(
497 col.name.clone(),
498 column_schema_with_refs(col, table_to_schema),
499 );
500 if !col.nullable && !col.primary_key && !col.auto_now && !col.auto_now_add && !col.noform {
510 required.push(Value::String(col.name.clone()));
511 }
512 }
513 for rel in &model.m2m_relations {
520 let target_schema = table_to_schema
521 .get(&rel.target_table)
522 .cloned()
523 .unwrap_or_else(|| pascal_case_from_ident(&rel.target_name));
524 let mut prop = serde_json::Map::new();
525 prop.insert("type".into(), Value::String("array".into()));
526 let (item_ty, item_fmt) = umbral::migrate::pk_meta_for_table(&rel.target_table)
529 .map(|(_, pk_ty)| openapi_type(pk_ty))
530 .unwrap_or(("integer", Some("int64")));
531 let items = match item_fmt {
532 Some(f) => json!({ "type": item_ty, "format": f }),
533 None => json!({ "type": item_ty }),
534 };
535 prop.insert("items".into(), items);
536 prop.insert(
537 "description".into(),
538 Value::String(format!(
539 "Many-to-many relation to {}. Send an array of child ids on \
540 create / update; the framework writes the junction table.",
541 target_schema,
542 )),
543 );
544 prop.insert("x-umbral-m2m".into(), Value::Bool(true));
547 prop.insert(
548 "x-umbral-m2m-target".into(),
549 Value::String(target_schema.clone()),
550 );
551 prop.insert(
552 "x-umbral-m2m-target-table".into(),
553 Value::String(rel.target_table.clone()),
554 );
555 if table_to_schema.contains_key(&rel.target_table) {
556 prop.insert(
557 "x-umbral-m2m-target-ref".into(),
558 Value::String(format!("#/components/schemas/{target_schema}")),
559 );
560 }
561 properties.insert(rel.field_name.clone(), Value::Object(prop));
562 }
563 let mut obj = Map::new();
564 obj.insert("type".into(), Value::String("object".into()));
565 obj.insert("properties".into(), Value::Object(properties));
566 if !required.is_empty() {
567 obj.insert("required".into(), Value::Array(required));
568 }
569 Value::Object(obj)
570}
571
572fn column_schema_with_refs(
576 col: &Column,
577 table_to_schema: &std::collections::HashMap<String, String>,
578) -> Value {
579 let mut value = column_schema(col);
580 if let Some(target_table) = &col.fk_target {
590 if let Some(schema_name) = table_to_schema.get(target_table) {
591 if let Some(obj) = value.as_object_mut() {
592 obj.insert(
593 "x-umbral-fk-ref".into(),
594 Value::String(format!("#/components/schemas/{schema_name}")),
595 );
596 }
597 }
598 }
599 value
600}
601
602fn column_schema(col: &Column) -> Value {
603 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
604 let mut obj = Map::new();
605 obj.insert("type".into(), Value::String(ty.into()));
606 if let Some(f) = format {
607 obj.insert("format".into(), Value::String(f.into()));
608 }
609 if col.nullable {
610 obj.insert("nullable".into(), Value::Bool(true));
611 }
612 if !col.help.is_empty() {
616 obj.insert("description".into(), Value::String(col.help.clone()));
617 }
618 if !col.example.is_empty() {
622 obj.insert("example".into(), Value::String(col.example.clone()));
623 }
624 if let Some(min) = col.min {
627 obj.insert(
628 "minimum".into(),
629 Value::Number(serde_json::Number::from(min)),
630 );
631 }
632 if let Some(max) = col.max {
633 obj.insert(
634 "maximum".into(),
635 Value::Number(serde_json::Number::from(max)),
636 );
637 }
638 if let Some(fmt) = col.text_format.as_deref() {
642 match fmt {
643 "email" => {
644 obj.insert("format".into(), Value::String("email".into()));
645 }
646 "url" => {
647 obj.insert("format".into(), Value::String("uri".into()));
648 }
649 "slug" => {
650 obj.insert("pattern".into(), Value::String("^[A-Za-z0-9_-]+$".into()));
654 }
655 _ => {}
656 }
657 }
658 if !col.choices.is_empty() && !col.is_multichoice {
664 obj.insert(
665 "enum".into(),
666 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
667 );
668 }
669 if col.max_length > 0 {
670 obj.insert(
671 "maxLength".into(),
672 Value::Number(serde_json::Number::from(col.max_length)),
673 );
674 }
675 if !col.default.is_empty() {
676 obj.insert("default".into(), Value::String(col.default.clone()));
681 }
682 if col.is_multichoice {
683 obj.insert("x-umbral-multichoice".into(), Value::Bool(true));
684 obj.insert(
685 "x-umbral-choices".into(),
686 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
687 );
688 }
689 if !col.choice_labels.is_empty() {
690 obj.insert(
691 "x-umbral-choice-labels".into(),
692 Value::Array(
693 col.choice_labels
694 .iter()
695 .cloned()
696 .map(Value::String)
697 .collect(),
698 ),
699 );
700 }
701 if let Some(target) = &col.fk_target {
702 obj.insert("x-umbral-fk-target".into(), Value::String(target.clone()));
703 }
704 if col.is_string_repr {
708 obj.insert("x-umbral-string-repr".into(), Value::Bool(true));
709 }
710 if col.auto_now_add {
731 obj.insert("x-umbral-auto-now-add".into(), Value::Bool(true));
732 }
733 if col.auto_now {
734 obj.insert("x-umbral-auto-now".into(), Value::Bool(true));
735 }
736 if col.noform {
737 obj.insert("readOnly".into(), Value::Bool(true));
738 obj.insert("x-umbral-noform".into(), Value::Bool(true));
744 }
745 if col.noedit {
750 obj.insert("x-umbral-noedit".into(), Value::Bool(true));
751 }
752 Value::Object(obj)
753}
754
755fn openapi_type(ty: SqlType) -> (&'static str, Option<&'static str>) {
756 match ty {
757 SqlType::SmallInt => ("integer", Some("int32")),
758 SqlType::Integer => ("integer", Some("int32")),
759 SqlType::BigInt => ("integer", Some("int64")),
760 SqlType::Real => ("number", Some("float")),
761 SqlType::Double => ("number", Some("double")),
762 SqlType::Boolean => ("boolean", None),
763 SqlType::Text => ("string", None),
764 SqlType::Date => ("string", Some("date")),
765 SqlType::Time => ("string", Some("time")),
766 SqlType::Timestamptz => ("string", Some("date-time")),
767 SqlType::Uuid => ("string", Some("uuid")),
768 SqlType::Json => ("object", None),
773 SqlType::Array(_) => ("array", None),
780 SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => ("string", None),
785 SqlType::FullText => ("string", None),
788 SqlType::Xml | SqlType::Ltree | SqlType::Bit => ("string", None),
791 SqlType::ForeignKey => ("integer", Some("int64")),
794 SqlType::Bytes => ("array", Some("byte")),
801 SqlType::Decimal => ("string", Some("decimal")),
807 }
808}
809
810fn search_parameter() -> Value {
821 json!({
822 "name": "search",
823 "in": "query",
824 "required": false,
825 "description": "Free-text search across every searchable column. \
826 Text columns match via case-insensitive substring; \
827 numeric / FK / Boolean columns match exactly when \
828 the term parses as that type. Multiple matches are \
829 ORed.",
830 "schema": { "type": "string" },
831 "x-umbral-search": true,
832 })
833}
834
835fn fields_parameter(model: &ModelMeta) -> Value {
846 let columns: Vec<Value> = model
849 .fields
850 .iter()
851 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
852 .map(|c| Value::String(c.name.clone()))
853 .collect();
854 json!({
855 "name": "fields",
856 "in": "query",
857 "required": false,
858 "description": "Comma-separated list of column names to include in the \
859 response. Unknown names are silently dropped; an empty \
860 value falls back to the full row (BUG-81). Composes \
861 with hide / transform / computed — hide always wins, \
862 the rest are returned iff in the list.",
863 "schema": { "type": "string" },
864 "x-umbral-fields": true,
865 "x-umbral-fields-columns": Value::Array(columns),
866 })
867}
868
869fn include_parameter(model: &ModelMeta) -> Value {
876 let fks: Vec<Value> = model
880 .fields
881 .iter()
882 .filter(|c| c.fk_target.is_some())
883 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
884 .map(|c| Value::String(c.name.clone()))
885 .collect();
886 json!({
887 "name": "include",
888 "in": "query",
889 "required": false,
890 "description": "Comma-separated list of foreign-key columns to expand \
891 in the response. Each named FK gets replaced with the \
892 full related-row JSON object (one batched IN(...) query \
893 per FK — no N+1). Unknown or non-FK names return a 400. \
894 Example: `?include=user,billing_address`.",
895 "schema": { "type": "string" },
896 "x-umbral-include": true,
897 "x-umbral-include-fks": Value::Array(fks),
898 })
899}
900
901fn pagination_parameters_for_style(style: umbral_rest::PaginationStyle) -> Vec<Value> {
910 match style {
911 umbral_rest::PaginationStyle::PageNumber => vec![
912 json!({
913 "name": "page",
914 "in": "query",
915 "required": false,
916 "description": "1-indexed page number. Defaults to 1 when omitted.",
917 "schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 },
918 "x-umbral-pagination": "page",
919 }),
920 json!({
921 "name": "page_size",
922 "in": "query",
923 "required": false,
924 "description": "Rows per page. Capped at 100. Default 20.",
925 "schema": {
926 "type": "integer", "format": "int32",
927 "minimum": 1, "maximum": 100, "default": 20,
928 },
929 "x-umbral-pagination": "page_size",
930 }),
931 ],
932 umbral_rest::PaginationStyle::LimitOffset => vec![
933 json!({
934 "name": "limit",
935 "in": "query",
936 "required": false,
937 "description": "Maximum rows to return. Defaults to the configured page size.",
938 "schema": { "type": "integer", "format": "int32", "minimum": 1 },
939 "x-umbral-pagination": "limit",
940 }),
941 json!({
942 "name": "offset",
943 "in": "query",
944 "required": false,
945 "description": "Number of rows to skip from the start of the result set. Defaults to 0.",
946 "schema": { "type": "integer", "format": "int32", "minimum": 0, "default": 0 },
947 "x-umbral-pagination": "offset",
948 }),
949 ],
950 umbral_rest::PaginationStyle::None | umbral_rest::PaginationStyle::Custom => vec![],
951 }
952}
953
954fn filter_parameters(model: &ModelMeta) -> Vec<Value> {
963 let mut out: Vec<Value> = Vec::new();
964 for col in &model.fields {
965 if col.primary_key {
966 continue;
967 }
968 let lookups = umbral_rest::filtering::applicable_lookups(col);
969 for lookup in lookups {
970 let name = if lookup == "eq" {
971 col.name.clone()
972 } else {
973 format!("{}__{}", col.name, lookup)
974 };
975 out.push(filter_parameter(col, lookup, &name));
976 }
977 }
978 out
979}
980
981fn filter_parameter(col: &Column, lookup: &str, name: &str) -> Value {
992 let (schema, description) = match lookup {
993 "in" => (
994 json!({ "type": "string" }),
995 format!(
996 "Comma-separated `{}` values; matches rows where the column is in the set.",
997 col.name,
998 ),
999 ),
1000 "isnull" => (
1001 json!({ "type": "boolean" }),
1002 format!(
1003 "`true` matches rows where `{}` IS NULL; `false` matches IS NOT NULL.",
1004 col.name,
1005 ),
1006 ),
1007 "contains" | "icontains" | "startswith" => {
1008 let phrase = match lookup {
1009 "contains" => "case-sensitive substring",
1010 "icontains" => "case-insensitive substring",
1011 "startswith" => "case-sensitive prefix",
1012 _ => unreachable!(),
1013 };
1014 (
1015 json!({ "type": "string" }),
1016 format!(
1017 "Matches rows where `{}` contains the given {phrase}.",
1018 col.name
1019 ),
1020 )
1021 }
1022 _ => {
1024 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
1025 let mut schema_obj = Map::new();
1026 schema_obj.insert("type".into(), Value::String(ty.into()));
1027 if let Some(f) = format {
1028 schema_obj.insert("format".into(), Value::String(f.into()));
1029 }
1030 let phrase = match lookup {
1031 "eq" => "equals the value",
1032 "ne" => "does not equal the value",
1033 "gte" => "is greater than or equal to the value",
1034 "lte" => "is less than or equal to the value",
1035 "gt" => "is greater than the value",
1036 "lt" => "is less than the value",
1037 _ => "matches the value",
1038 };
1039 (
1040 Value::Object(schema_obj),
1041 format!("Matches rows where `{}` {phrase}.", col.name),
1042 )
1043 }
1044 };
1045
1046 json!({
1047 "name": name,
1048 "in": "query",
1049 "required": false,
1050 "description": description,
1051 "schema": schema,
1052 "x-umbral-filter-field": col.name,
1053 "x-umbral-filter-lookup": lookup,
1054 })
1055}
1056
1057fn collection_paths(table: &str, schema_name: &str, filter_params: &[Value]) -> Value {
1058 use umbral_rest::Action;
1059 let mut item = Map::new();
1060
1061 if umbral_rest::action_exposed(table, &Action::List) {
1066 let mut get_op = Map::new();
1067 get_op.insert(
1068 "operationId".into(),
1069 Value::String(format!("list_{}", table)),
1070 );
1071 get_op.insert("tags".into(), json!([table]));
1072 if !filter_params.is_empty() {
1073 get_op.insert("parameters".into(), Value::Array(filter_params.to_vec()));
1074 }
1075 get_op.insert(
1076 "responses".into(),
1077 json!({
1078 "200": {
1079 "description": "List of rows",
1080 "content": {
1081 "application/json": {
1082 "schema": list_envelope(schema_name)
1083 }
1084 }
1085 }
1086 }),
1087 );
1088 item.insert("get".into(), Value::Object(get_op));
1089 }
1090
1091 if umbral_rest::action_exposed(table, &Action::Create) {
1094 item.insert(
1095 "post".into(),
1096 json!({
1097 "operationId": format!("create_{}", table),
1098 "tags": [table],
1099 "requestBody": {
1100 "required": true,
1101 "content": {
1102 "application/json": {
1103 "schema": schema_ref(schema_name)
1104 }
1105 }
1106 },
1107 "responses": {
1108 "201": {
1109 "description": "Row created",
1110 "content": {
1111 "application/json": {
1112 "schema": schema_ref(schema_name)
1113 }
1114 }
1115 },
1116 "400": { "description": "Invalid input" }
1117 }
1118 }),
1119 );
1120 }
1121
1122 Value::Object(item)
1123}
1124
1125fn item_paths(table: &str, schema_name: &str, retrieve_query_params: &[Value]) -> Value {
1126 use umbral_rest::Action;
1127 let id_param = json!({
1128 "name": "id",
1129 "in": "path",
1130 "required": true,
1131 "schema": { "type": "string" }
1132 });
1133 let mut item = Map::new();
1134 item.insert("parameters".into(), json!([id_param]));
1135
1136 if umbral_rest::action_exposed(table, &Action::Retrieve) {
1142 let mut get_op = Map::new();
1143 get_op.insert(
1144 "operationId".into(),
1145 Value::String(format!("retrieve_{}", table)),
1146 );
1147 get_op.insert("tags".into(), json!([table]));
1148 if !retrieve_query_params.is_empty() {
1149 get_op.insert(
1150 "parameters".into(),
1151 Value::Array(retrieve_query_params.to_vec()),
1152 );
1153 }
1154 get_op.insert(
1155 "responses".into(),
1156 json!({
1157 "200": {
1158 "description": "Row found",
1159 "content": {
1160 "application/json": {
1161 "schema": schema_ref(schema_name)
1162 }
1163 }
1164 },
1165 "404": { "description": "Not found" }
1166 }),
1167 );
1168 item.insert("get".into(), Value::Object(get_op));
1169 }
1170
1171 if umbral_rest::action_exposed(table, &Action::Update) {
1173 item.insert(
1174 "put".into(),
1175 json!({
1176 "operationId": format!("update_{}", table),
1177 "tags": [table],
1178 "requestBody": {
1179 "required": true,
1180 "content": {
1181 "application/json": {
1182 "schema": schema_ref(schema_name)
1183 }
1184 }
1185 },
1186 "responses": {
1187 "200": {
1188 "description": "Row updated",
1189 "content": {
1190 "application/json": {
1191 "schema": schema_ref(schema_name)
1192 }
1193 }
1194 },
1195 "404": { "description": "Not found" }
1196 }
1197 }),
1198 );
1199 item.insert(
1200 "patch".into(),
1201 json!({
1202 "operationId": format!("partial_update_{}", table),
1203 "tags": [table],
1204 "requestBody": {
1205 "required": true,
1206 "content": {
1207 "application/json": {
1208 "schema": schema_ref(schema_name)
1209 }
1210 }
1211 },
1212 "responses": {
1213 "200": {
1214 "description": "Row partially updated",
1215 "content": {
1216 "application/json": {
1217 "schema": schema_ref(schema_name)
1218 }
1219 }
1220 },
1221 "404": { "description": "Not found" }
1222 }
1223 }),
1224 );
1225 }
1226
1227 if umbral_rest::action_exposed(table, &Action::Delete) {
1229 item.insert(
1230 "delete".into(),
1231 json!({
1232 "operationId": format!("destroy_{}", table),
1233 "tags": [table],
1234 "responses": {
1235 "204": { "description": "Row deleted" },
1236 "404": { "description": "Not found" }
1237 }
1238 }),
1239 );
1240 }
1241
1242 Value::Object(item)
1243}
1244
1245fn schema_ref(name: &str) -> Value {
1246 json!({ "$ref": format!("#/components/schemas/{}", name) })
1247}
1248
1249fn has_operations(path_item: &Value) -> bool {
1254 const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"];
1255 path_item
1256 .as_object()
1257 .is_some_and(|m| METHODS.iter().any(|verb| m.contains_key(*verb)))
1258}
1259
1260fn list_envelope(schema_name: &str) -> Value {
1261 json!({
1262 "type": "object",
1263 "properties": {
1264 "results": {
1265 "type": "array",
1266 "items": schema_ref(schema_name)
1267 },
1268 "count": { "type": "integer" }
1269 },
1270 "required": ["results", "count"]
1271 })
1272}
1273
1274#[doc(hidden)]
1278pub fn test_spec_url(p: &OpenApiPlugin) -> String {
1279 p.spec_url()
1280}
1281
1282#[doc(hidden)]
1283pub fn test_ui_route(p: &OpenApiPlugin) -> String {
1284 p.ui_route()
1285}
1286
1287#[cfg(test)]
1291mod tests {
1292 use super::*;
1293 use umbral::migrate::Column;
1294 use umbral::orm::SqlType;
1295
1296 #[test]
1299 fn swagger_asset_base_is_pinned_and_configurable() {
1300 assert!(
1302 DEFAULT_SWAGGER_ASSET_BASE.contains("@5.17"),
1303 "default asset base must pin an exact version, got {DEFAULT_SWAGGER_ASSET_BASE}"
1304 );
1305 assert!(!SWAGGER_UI_HTML.contains("unpkg.com/swagger-ui-dist@5/"));
1306 assert!(SWAGGER_UI_HTML.contains("{ASSET_BASE}"));
1307 assert!(SWAGGER_UI_HTML.contains("crossorigin=\"anonymous\""));
1308
1309 let p = OpenApiPlugin::new().swagger_asset_base("/static/swagger");
1311 let rendered = SWAGGER_UI_HTML
1312 .replace("{ASSET_BASE}", &p.swagger_asset_base)
1313 .replace("{SPEC_URL}", "/openapi/openapi.json");
1314 assert!(rendered.contains("/static/swagger/swagger-ui-bundle.js"));
1315 assert!(!rendered.contains("{ASSET_BASE}"));
1316 }
1317
1318 fn base_col(name: &str, ty: SqlType) -> Column {
1319 Column {
1320 name: name.into(),
1321 ty,
1322 primary_key: false,
1323 nullable: false,
1324 fk_target: None,
1325 noform: false,
1326 privileged: false,
1327 db_constraint: true,
1328 noedit: false,
1329 is_string_repr: false,
1330 max_length: 0,
1331 choices: Vec::new(),
1332 choice_labels: Vec::new(),
1333 default: String::new(),
1334 is_multichoice: false,
1335 unique: false,
1336 on_delete: ::umbral::orm::FkAction::NoAction,
1337 on_update: ::umbral::orm::FkAction::NoAction,
1338 index: false,
1339 auto_now_add: false,
1340 auto_now: false,
1341 help: String::new(),
1342 example: String::new(),
1343 widget: None,
1344 supported_backends: Vec::new(),
1345 min: None,
1346 max: None,
1347 text_format: ::core::option::Option::None,
1348 slug_from: ::core::option::Option::None,
1349 }
1350 }
1351
1352 #[test]
1353 fn choices_render_as_openapi_enum_with_labels_extension() {
1354 let mut col = base_col("status", SqlType::Text);
1355 col.choices = vec!["draft".into(), "published".into(), "archived".into()];
1356 col.choice_labels = vec!["Draft".into(), "Published".into(), "Archived".into()];
1357 let schema = column_schema(&col);
1358 assert_eq!(schema["type"], "string");
1359 assert_eq!(
1360 schema["enum"],
1361 serde_json::json!(["draft", "published", "archived"])
1362 );
1363 assert_eq!(
1364 schema["x-umbral-choice-labels"],
1365 serde_json::json!(["Draft", "Published", "Archived"])
1366 );
1367 }
1368
1369 #[test]
1370 fn multichoice_skips_enum_and_uses_vendor_extension() {
1371 let mut col = base_col("tags", SqlType::Text);
1372 col.choices = vec!["rust".into(), "python".into()];
1373 col.is_multichoice = true;
1374 let schema = column_schema(&col);
1375 assert!(
1376 schema.get("enum").is_none(),
1377 "multichoice columns should not declare a flat enum (value is a CSV subset)"
1378 );
1379 assert_eq!(schema["x-umbral-multichoice"], true);
1380 assert_eq!(
1381 schema["x-umbral-choices"],
1382 serde_json::json!(["rust", "python"])
1383 );
1384 }
1385
1386 #[test]
1387 fn max_length_and_default_surface_as_standard_openapi_keys() {
1388 let mut col = base_col("title", SqlType::Text);
1389 col.max_length = 50;
1390 col.default = "untitled".into();
1391 let schema = column_schema(&col);
1392 assert_eq!(schema["maxLength"], 50);
1393 assert_eq!(schema["default"], "untitled");
1394 }
1395
1396 #[test]
1397 fn fk_target_emits_vendor_extension_for_playground_navigation() {
1398 let mut col = base_col("author_id", SqlType::ForeignKey);
1399 col.fk_target = Some("auth_user".into());
1400 let schema = column_schema(&col);
1401 assert_eq!(schema["type"], "integer");
1402 assert_eq!(schema["format"], "int64");
1403 assert_eq!(schema["x-umbral-fk-target"], "auth_user");
1404 }
1405
1406 #[test]
1407 fn noform_renders_as_read_only_and_carries_vendor_extension() {
1408 let mut col = base_col("internal_token", SqlType::Text);
1413 col.noform = true;
1414 let schema = column_schema(&col);
1415 assert_eq!(schema["readOnly"], true);
1416 assert_eq!(schema["x-umbral-noform"], true);
1417 }
1418
1419 #[test]
1420 fn noedit_does_NOT_render_as_read_only() {
1421 let mut col = base_col("email", SqlType::Text);
1427 col.noedit = true;
1428 let schema = column_schema(&col);
1429 assert!(
1430 schema.get("readOnly").is_none(),
1431 "noedit must NOT contaminate the API request-body contract; \
1432 got readOnly in schema: {schema:?}"
1433 );
1434 assert_eq!(schema["x-umbral-noedit"], true);
1437 }
1438
1439 #[test]
1440 fn plain_column_keeps_minimal_schema_no_extensions() {
1441 let col = base_col("body", SqlType::Text);
1442 let schema = column_schema(&col);
1443 let obj = schema.as_object().expect("object");
1444 assert_eq!(
1445 obj.len(),
1446 1,
1447 "plain column should only have `type`: {obj:?}"
1448 );
1449 assert_eq!(schema["type"], "string");
1450 }
1451
1452 #[test]
1457 fn help_attribute_flows_to_openapi_description() {
1458 let mut col = base_col("status", SqlType::Text);
1459 col.help = "Workflow step. Set by editors on Save.".to_string();
1460 let schema = column_schema(&col);
1461 assert_eq!(
1462 schema["description"], "Workflow step. Set by editors on Save.",
1463 "help should round-trip to OpenAPI description; got: {schema:?}",
1464 );
1465 }
1466
1467 #[test]
1468 fn empty_help_omits_description() {
1469 let col = base_col("body", SqlType::Text);
1470 let schema = column_schema(&col);
1471 assert!(
1472 schema.get("description").is_none(),
1473 "empty help should omit description; got: {schema:?}",
1474 );
1475 }
1476
1477 #[test]
1481 fn example_attribute_flows_to_openapi_example() {
1482 let mut col = base_col("status", SqlType::Text);
1483 col.example = "published".to_string();
1484 let schema = column_schema(&col);
1485 assert_eq!(
1486 schema["example"], "published",
1487 "example should round-trip; got: {schema:?}",
1488 );
1489 }
1490
1491 #[test]
1492 fn empty_example_omits_example() {
1493 let col = base_col("body", SqlType::Text);
1494 let schema = column_schema(&col);
1495 assert!(
1496 schema.get("example").is_none(),
1497 "empty example should omit example key; got: {schema:?}",
1498 );
1499 }
1500
1501 fn note_model() -> ModelMeta {
1506 let mut id = base_col("id", SqlType::BigInt);
1507 id.primary_key = true;
1508 let mut published_at = base_col("published_at", SqlType::Timestamptz);
1509 published_at.nullable = true;
1510 ModelMeta {
1511 name: "Note".to_string(),
1512 table: "note".to_string(),
1513 fields: vec![
1514 id,
1515 base_col("title", SqlType::Text),
1516 base_col("views", SqlType::Integer),
1517 published_at,
1518 ],
1519 display: "Note".to_string(),
1520 icon: "database".to_string(),
1521 database: None,
1522 singleton: false,
1523 unique_together: Vec::new(),
1524 indexes: Vec::new(),
1525 ordering: Vec::new(),
1526 m2m_relations: Vec::new(),
1527 soft_delete: false,
1528 app_label: "app".to_string(),
1529 }
1530 }
1531
1532 #[test]
1533 fn filter_parameters_skips_primary_key() {
1534 let params = filter_parameters(¬e_model());
1535 let names: Vec<&str> = params.iter().map(|p| p["name"].as_str().unwrap()).collect();
1536 assert!(
1537 !names.iter().any(|n| *n == "id" || n.starts_with("id__")),
1538 "PK column should be skipped; got {names:?}",
1539 );
1540 }
1541
1542 #[test]
1543 fn filter_parameters_eq_uses_bare_column_name_no_suffix() {
1544 let params = filter_parameters(¬e_model());
1545 let bare_title = params
1546 .iter()
1547 .find(|p| p["name"] == "title")
1548 .expect("title eq parameter should be present");
1549 assert_eq!(bare_title["x-umbral-filter-lookup"], "eq");
1550 assert_eq!(bare_title["x-umbral-filter-field"], "title");
1551 assert_eq!(bare_title["schema"]["type"], "string");
1552 }
1553
1554 #[test]
1555 fn filter_parameters_in_is_string_typed_with_csv_description() {
1556 let params = filter_parameters(¬e_model());
1557 let title_in = params
1558 .iter()
1559 .find(|p| p["name"] == "title__in")
1560 .expect("title__in parameter should be present");
1561 assert_eq!(title_in["schema"]["type"], "string");
1562 assert!(
1563 title_in["description"]
1564 .as_str()
1565 .unwrap()
1566 .to_lowercase()
1567 .contains("comma"),
1568 "__in description should mention the comma-separated format",
1569 );
1570 }
1571
1572 #[test]
1573 fn filter_parameters_isnull_only_on_nullable_columns() {
1574 let params = filter_parameters(¬e_model());
1575 let isnull_params: Vec<&str> = params
1576 .iter()
1577 .filter_map(|p| p["name"].as_str())
1578 .filter(|n| n.ends_with("__isnull"))
1579 .collect();
1580 assert_eq!(
1581 isnull_params,
1582 vec!["published_at__isnull"],
1583 "isnull lookup should only appear for nullable columns; got {isnull_params:?}",
1584 );
1585 }
1586
1587 #[test]
1588 fn filter_parameters_range_lookups_only_on_numeric_or_temporal() {
1589 let params = filter_parameters(¬e_model());
1590 let has_gte = |field: &str| params.iter().any(|p| p["name"] == format!("{field}__gte"));
1591 assert!(has_gte("views"), "integer column gets gte");
1592 assert!(has_gte("published_at"), "timestamp column gets gte");
1593 assert!(
1594 !has_gte("title"),
1595 "text column must NOT get gte; got {params:?}",
1596 );
1597 }
1598
1599 #[test]
1600 fn filter_parameters_string_lookups_only_on_text() {
1601 let params = filter_parameters(¬e_model());
1602 let has_contains = |field: &str| {
1603 params
1604 .iter()
1605 .any(|p| p["name"] == format!("{field}__contains"))
1606 };
1607 assert!(has_contains("title"), "text column gets contains");
1608 assert!(
1609 !has_contains("views"),
1610 "integer column must NOT get contains; got {params:?}",
1611 );
1612 }
1613
1614 #[test]
1615 fn collection_paths_omits_parameters_array_when_no_filters() {
1616 let value = collection_paths("note", "Note", &[]);
1617 let get_op = &value["get"];
1618 assert!(
1619 get_op.get("parameters").is_none(),
1620 "no filters → no parameters key; got {get_op:?}",
1621 );
1622 }
1623
1624 #[test]
1625 fn collection_paths_includes_parameters_when_filters_present() {
1626 let filter_params = filter_parameters(¬e_model());
1627 let value = collection_paths("note", "Note", &filter_params);
1628 let params = value["get"]["parameters"]
1629 .as_array()
1630 .expect("parameters array should be present when filters land");
1631 assert!(!params.is_empty());
1632 assert!(
1633 params.iter().all(|p| p["in"] == "query"),
1634 "every filter parameter is in: query",
1635 );
1636 }
1637
1638 #[test]
1643 fn fields_parameter_lists_model_columns() {
1644 let param = fields_parameter(¬e_model());
1645 assert_eq!(param["name"], "fields");
1646 assert_eq!(param["in"], "query");
1647 assert_eq!(param["x-umbral-fields"], true);
1648 let cols = param["x-umbral-fields-columns"]
1649 .as_array()
1650 .expect("x-umbral-fields-columns should be a list");
1651 let names: Vec<&str> = cols.iter().filter_map(|v| v.as_str()).collect();
1652 assert!(names.contains(&"title"));
1653 assert!(names.contains(&"views"));
1654 assert!(
1655 !names.is_empty(),
1656 "every column should land in the enum so the playground can offer it",
1657 );
1658 }
1659
1660 #[test]
1663 fn item_paths_advertises_fields_query_param_on_retrieve() {
1664 let value = item_paths("note", "Note", &[fields_parameter(¬e_model())]);
1665 let get_params = value["get"]["parameters"]
1666 .as_array()
1667 .expect("retrieve op should carry its query parameters");
1668 assert!(
1669 get_params.iter().any(|p| p["name"] == "fields"),
1670 "fields parameter should be on the retrieve op; got {get_params:?}",
1671 );
1672 }
1673
1674 #[test]
1679 fn fk_column_emits_schema_ref_when_target_known() {
1680 let mut col = base_col("author", SqlType::ForeignKey);
1681 col.fk_target = Some("auth_user".into());
1682 let mut map = std::collections::HashMap::new();
1683 map.insert("auth_user".to_string(), "AuthUser".to_string());
1684 let schema = column_schema_with_refs(&col, &map);
1685 assert_eq!(
1686 schema["x-umbral-fk-target"], "auth_user",
1687 "the table-name vendor extension stays for backward compat",
1688 );
1689 assert_eq!(
1690 schema["x-umbral-fk-ref"], "#/components/schemas/AuthUser",
1691 "the JSON pointer to the target schema should be emitted",
1692 );
1693 }
1694
1695 #[test]
1696 fn fk_column_without_known_target_omits_schema_ref() {
1697 let mut col = base_col("author", SqlType::ForeignKey);
1698 col.fk_target = Some("unknown_table".into());
1699 let map = std::collections::HashMap::new();
1700 let schema = column_schema_with_refs(&col, &map);
1701 assert!(
1702 schema.get("x-umbral-fk-ref").is_none(),
1703 "unknown FK target → no ref emitted; got: {schema:?}",
1704 );
1705 }
1706
1707 #[test]
1713 fn m2m_relation_lands_in_model_schema_with_target_extension() {
1714 let mut model = note_model();
1715 model.m2m_relations.push(umbral::migrate::M2MRelation {
1716 field_name: "tags".to_string(),
1717 target_table: "tag".to_string(),
1718 target_name: "Tag".to_string(),
1719 });
1720 let mut tts = std::collections::HashMap::new();
1724 tts.insert("tag".to_string(), "Tag".to_string());
1725 let schema = model_schema(&model, &tts);
1726 let tags_prop = &schema["properties"]["tags"];
1727 assert_eq!(tags_prop["type"], "array");
1728 assert_eq!(tags_prop["items"]["type"], "integer");
1729 assert_eq!(tags_prop["x-umbral-m2m"], true);
1730 assert_eq!(tags_prop["x-umbral-m2m-target"], "Tag");
1731 assert_eq!(tags_prop["x-umbral-m2m-target-table"], "tag");
1732 assert_eq!(
1733 tags_prop["x-umbral-m2m-target-ref"],
1734 "#/components/schemas/Tag",
1735 );
1736 let required = schema["required"].as_array();
1738 if let Some(req) = required {
1739 assert!(!req.iter().any(|v| v == "tags"));
1740 }
1741 }
1742
1743 #[test]
1751 fn auto_now_columns_are_optional_in_the_request_schema() {
1752 let mut model = note_model();
1753 let mut created = base_col("created_at", SqlType::Timestamptz);
1754 created.auto_now_add = true;
1755 let mut updated = base_col("updated_at", SqlType::Timestamptz);
1756 updated.auto_now = true;
1757 model.fields.push(created);
1758 model.fields.push(updated);
1759
1760 let schema = model_schema(&model, &std::collections::HashMap::new());
1761
1762 assert_eq!(
1766 schema["properties"]["created_at"]["x-umbral-auto-now-add"],
1767 true
1768 );
1769 assert_eq!(
1770 schema["properties"]["updated_at"]["x-umbral-auto-now"],
1771 true
1772 );
1773
1774 assert!(
1778 schema["properties"]["created_at"].get("readOnly").is_none(),
1779 "auto_now_add must not be readOnly; got {}",
1780 schema["properties"]["created_at"],
1781 );
1782 assert!(
1783 schema["properties"]["updated_at"].get("readOnly").is_none(),
1784 "auto_now must not be readOnly; got {}",
1785 schema["properties"]["updated_at"],
1786 );
1787
1788 let required = schema["required"].as_array().expect("required array");
1791 let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1792 assert!(
1793 !names.contains(&"created_at"),
1794 "auto_now_add should drop out of required; got {names:?}",
1795 );
1796 assert!(
1797 !names.contains(&"updated_at"),
1798 "auto_now should drop out of required; got {names:?}",
1799 );
1800 }
1801
1802 #[test]
1805 fn pagination_parameters_per_style() {
1806 use umbral_rest::PaginationStyle;
1807
1808 let none_params = pagination_parameters_for_style(PaginationStyle::None);
1810 assert!(
1811 none_params.is_empty(),
1812 "NoPagination should emit no pagination params; got {none_params:?}"
1813 );
1814
1815 let custom_params = pagination_parameters_for_style(PaginationStyle::Custom);
1817 assert!(
1818 custom_params.is_empty(),
1819 "Custom pagination should emit no params; got {custom_params:?}"
1820 );
1821
1822 let page_params = pagination_parameters_for_style(PaginationStyle::PageNumber);
1824 assert_eq!(page_params.len(), 2, "PageNumber should emit 2 params");
1825 assert_eq!(page_params[0]["name"], "page");
1826 assert_eq!(page_params[0]["in"], "query");
1827 assert_eq!(page_params[0]["schema"]["type"], "integer");
1828 assert_eq!(page_params[0]["schema"]["minimum"], 1);
1829 assert_eq!(page_params[0]["schema"]["default"], 1);
1830 assert_eq!(page_params[0]["x-umbral-pagination"], "page");
1831 assert_eq!(page_params[1]["name"], "page_size");
1832 assert_eq!(page_params[1]["schema"]["maximum"], 100);
1833 assert_eq!(page_params[1]["x-umbral-pagination"], "page_size");
1834
1835 let lo_params = pagination_parameters_for_style(PaginationStyle::LimitOffset);
1837 assert_eq!(lo_params.len(), 2, "LimitOffset should emit 2 params");
1838 assert_eq!(lo_params[0]["name"], "limit");
1839 assert_eq!(lo_params[0]["x-umbral-pagination"], "limit");
1840 assert_eq!(lo_params[1]["name"], "offset");
1841 assert_eq!(lo_params[1]["x-umbral-pagination"], "offset");
1842 assert_eq!(lo_params[1]["schema"]["minimum"], 0);
1843 }
1844}