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 let collection = collection_paths(&model.table, &schema_name, &list_params);
298 if has_operations(&collection) {
299 paths.insert(format!("{}/{}/", rest_base, model.table), collection);
300 }
301 let mut item_params = vec![fields_parameter(&model)];
305 if model.fields.iter().any(|c| c.fk_target.is_some()) {
306 item_params.push(include_parameter(&model));
307 }
308 let item = item_paths(&model.table, &schema_name, &item_params);
312 if has_operations(&item) {
313 paths.insert(format!("{}/{}/{{id}}", rest_base, model.table), item);
314 }
315 }
316 }
317
318 if let Some(entries) = umbral::routes::registered_openapi_paths() {
324 for (path, item) in entries {
325 paths.insert(path.clone(), item.clone());
326 }
327 }
328
329 for action in umbral_rest::registered_action_schemas() {
334 let path = if action.detail {
335 format!(
336 "{}/{}/{{id}}/{}/",
337 action.base_path, action.table, action.name
338 )
339 } else {
340 format!("{}/{}/{}/", action.base_path, action.table, action.name)
341 };
342 paths.insert(path, action_path_item(&action));
343 }
344
345 let mut info = Map::new();
346 info.insert("title".into(), Value::String(cfg.title.clone()));
347 info.insert("version".into(), Value::String(cfg.version.clone()));
348 if let Some(desc) = &cfg.description {
349 info.insert("description".into(), Value::String(desc.clone()));
350 }
351
352 let mut security_schemes = Map::new();
359 let mut security: Vec<Value> = Vec::new();
360 for (name, scheme) in umbral_rest::registered_security_schemes() {
361 security.push(json!({ name.clone(): [] }));
362 security_schemes.insert(name, scheme);
363 }
364 let mut components = Map::new();
365 components.insert("schemas".into(), Value::Object(schemas));
366 if !security_schemes.is_empty() {
367 components.insert("securitySchemes".into(), Value::Object(security_schemes));
368 }
369
370 let mut document = Map::new();
371 document.insert("openapi".into(), Value::String("3.0.3".into()));
372 document.insert("info".into(), Value::Object(info));
373 document.insert("paths".into(), Value::Object(paths));
374 document.insert("components".into(), Value::Object(components));
375 if !security.is_empty() {
376 document.insert("security".into(), Value::Array(security));
377 }
378 Value::Object(document)
379}
380
381fn action_path_item(a: &umbral_rest::ActionSchema) -> Value {
385 let mut op = Map::new();
386 op.insert(
387 "operationId".into(),
388 Value::String(format!("{}_{}", a.table, a.name)),
389 );
390 op.insert("tags".into(), json!([a.table]));
391 op.insert(
392 "summary".into(),
393 Value::String(format!("`{}` action on {}", a.name, a.table)),
394 );
395 if a.detail {
396 op.insert(
397 "parameters".into(),
398 json!([{
399 "name": "id", "in": "path", "required": true,
400 "schema": { "type": "string" },
401 "description": "Primary key of the target row"
402 }]),
403 );
404 }
405 if let Some(input) = &a.input_schema {
406 op.insert(
407 "requestBody".into(),
408 json!({ "required": true, "content": { "application/json": { "schema": input } } }),
409 );
410 }
411 let mut ok = Map::new();
412 ok.insert("description".into(), Value::String("Action result".into()));
413 if let Some(output) = &a.output_schema {
414 ok.insert(
415 "content".into(),
416 json!({ "application/json": { "schema": output } }),
417 );
418 }
419 op.insert("responses".into(), json!({ "200": Value::Object(ok) }));
420
421 let mut item = Map::new();
422 item.insert(a.method.to_lowercase(), Value::Object(op));
423 Value::Object(item)
424}
425
426fn model_schema(
427 model: &ModelMeta,
428 table_to_schema: &std::collections::HashMap<String, String>,
429) -> Value {
430 let mut properties = Map::new();
431 let mut required: Vec<Value> = Vec::new();
432 for col in &model.fields {
433 if umbral_rest::is_hidden(&model.table, &col.name) {
440 continue;
441 }
442 properties.insert(
443 col.name.clone(),
444 column_schema_with_refs(col, table_to_schema),
445 );
446 if !col.nullable && !col.primary_key && !col.auto_now && !col.auto_now_add && !col.noform {
456 required.push(Value::String(col.name.clone()));
457 }
458 }
459 for rel in &model.m2m_relations {
466 let target_schema = table_to_schema
467 .get(&rel.target_table)
468 .cloned()
469 .unwrap_or_else(|| pascal_case_from_ident(&rel.target_name));
470 let mut prop = serde_json::Map::new();
471 prop.insert("type".into(), Value::String("array".into()));
472 let (item_ty, item_fmt) = umbral::migrate::pk_meta_for_table(&rel.target_table)
475 .map(|(_, pk_ty)| openapi_type(pk_ty))
476 .unwrap_or(("integer", Some("int64")));
477 let items = match item_fmt {
478 Some(f) => json!({ "type": item_ty, "format": f }),
479 None => json!({ "type": item_ty }),
480 };
481 prop.insert("items".into(), items);
482 prop.insert(
483 "description".into(),
484 Value::String(format!(
485 "Many-to-many relation to {}. Send an array of child ids on \
486 create / update; the framework writes the junction table.",
487 target_schema,
488 )),
489 );
490 prop.insert("x-umbral-m2m".into(), Value::Bool(true));
493 prop.insert(
494 "x-umbral-m2m-target".into(),
495 Value::String(target_schema.clone()),
496 );
497 prop.insert(
498 "x-umbral-m2m-target-table".into(),
499 Value::String(rel.target_table.clone()),
500 );
501 if table_to_schema.contains_key(&rel.target_table) {
502 prop.insert(
503 "x-umbral-m2m-target-ref".into(),
504 Value::String(format!("#/components/schemas/{target_schema}")),
505 );
506 }
507 properties.insert(rel.field_name.clone(), Value::Object(prop));
508 }
509 let mut obj = Map::new();
510 obj.insert("type".into(), Value::String("object".into()));
511 obj.insert("properties".into(), Value::Object(properties));
512 if !required.is_empty() {
513 obj.insert("required".into(), Value::Array(required));
514 }
515 Value::Object(obj)
516}
517
518fn column_schema_with_refs(
522 col: &Column,
523 table_to_schema: &std::collections::HashMap<String, String>,
524) -> Value {
525 let mut value = column_schema(col);
526 if let Some(target_table) = &col.fk_target {
536 if let Some(schema_name) = table_to_schema.get(target_table) {
537 if let Some(obj) = value.as_object_mut() {
538 obj.insert(
539 "x-umbral-fk-ref".into(),
540 Value::String(format!("#/components/schemas/{schema_name}")),
541 );
542 }
543 }
544 }
545 value
546}
547
548fn column_schema(col: &Column) -> Value {
549 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
550 let mut obj = Map::new();
551 obj.insert("type".into(), Value::String(ty.into()));
552 if let Some(f) = format {
553 obj.insert("format".into(), Value::String(f.into()));
554 }
555 if col.nullable {
556 obj.insert("nullable".into(), Value::Bool(true));
557 }
558 if !col.help.is_empty() {
562 obj.insert("description".into(), Value::String(col.help.clone()));
563 }
564 if !col.example.is_empty() {
568 obj.insert("example".into(), Value::String(col.example.clone()));
569 }
570 if let Some(min) = col.min {
573 obj.insert(
574 "minimum".into(),
575 Value::Number(serde_json::Number::from(min)),
576 );
577 }
578 if let Some(max) = col.max {
579 obj.insert(
580 "maximum".into(),
581 Value::Number(serde_json::Number::from(max)),
582 );
583 }
584 if let Some(fmt) = col.text_format.as_deref() {
588 match fmt {
589 "email" => {
590 obj.insert("format".into(), Value::String("email".into()));
591 }
592 "url" => {
593 obj.insert("format".into(), Value::String("uri".into()));
594 }
595 "slug" => {
596 obj.insert("pattern".into(), Value::String("^[A-Za-z0-9_-]+$".into()));
600 }
601 _ => {}
602 }
603 }
604 if !col.choices.is_empty() && !col.is_multichoice {
610 obj.insert(
611 "enum".into(),
612 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
613 );
614 }
615 if col.max_length > 0 {
616 obj.insert(
617 "maxLength".into(),
618 Value::Number(serde_json::Number::from(col.max_length)),
619 );
620 }
621 if !col.default.is_empty() {
622 obj.insert("default".into(), Value::String(col.default.clone()));
627 }
628 if col.is_multichoice {
629 obj.insert("x-umbral-multichoice".into(), Value::Bool(true));
630 obj.insert(
631 "x-umbral-choices".into(),
632 Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
633 );
634 }
635 if !col.choice_labels.is_empty() {
636 obj.insert(
637 "x-umbral-choice-labels".into(),
638 Value::Array(
639 col.choice_labels
640 .iter()
641 .cloned()
642 .map(Value::String)
643 .collect(),
644 ),
645 );
646 }
647 if let Some(target) = &col.fk_target {
648 obj.insert("x-umbral-fk-target".into(), Value::String(target.clone()));
649 }
650 if col.is_string_repr {
654 obj.insert("x-umbral-string-repr".into(), Value::Bool(true));
655 }
656 if col.auto_now_add {
677 obj.insert("x-umbral-auto-now-add".into(), Value::Bool(true));
678 }
679 if col.auto_now {
680 obj.insert("x-umbral-auto-now".into(), Value::Bool(true));
681 }
682 if col.noform {
683 obj.insert("readOnly".into(), Value::Bool(true));
684 obj.insert("x-umbral-noform".into(), Value::Bool(true));
690 }
691 if col.noedit {
696 obj.insert("x-umbral-noedit".into(), Value::Bool(true));
697 }
698 Value::Object(obj)
699}
700
701fn openapi_type(ty: SqlType) -> (&'static str, Option<&'static str>) {
702 match ty {
703 SqlType::SmallInt => ("integer", Some("int32")),
704 SqlType::Integer => ("integer", Some("int32")),
705 SqlType::BigInt => ("integer", Some("int64")),
706 SqlType::Real => ("number", Some("float")),
707 SqlType::Double => ("number", Some("double")),
708 SqlType::Boolean => ("boolean", None),
709 SqlType::Text => ("string", None),
710 SqlType::Date => ("string", Some("date")),
711 SqlType::Time => ("string", Some("time")),
712 SqlType::Timestamptz => ("string", Some("date-time")),
713 SqlType::Uuid => ("string", Some("uuid")),
714 SqlType::Json => ("object", None),
719 SqlType::Array(_) => ("array", None),
726 SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => ("string", None),
731 SqlType::FullText => ("string", None),
734 SqlType::Xml | SqlType::Ltree | SqlType::Bit => ("string", None),
737 SqlType::ForeignKey => ("integer", Some("int64")),
740 SqlType::Bytes => ("array", Some("byte")),
747 SqlType::Decimal => ("string", Some("decimal")),
753 }
754}
755
756fn search_parameter() -> Value {
767 json!({
768 "name": "search",
769 "in": "query",
770 "required": false,
771 "description": "Free-text search across every searchable column. \
772 Text columns match via case-insensitive substring; \
773 numeric / FK / Boolean columns match exactly when \
774 the term parses as that type. Multiple matches are \
775 ORed.",
776 "schema": { "type": "string" },
777 "x-umbral-search": true,
778 })
779}
780
781fn fields_parameter(model: &ModelMeta) -> Value {
792 let columns: Vec<Value> = model
795 .fields
796 .iter()
797 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
798 .map(|c| Value::String(c.name.clone()))
799 .collect();
800 json!({
801 "name": "fields",
802 "in": "query",
803 "required": false,
804 "description": "Comma-separated list of column names to include in the \
805 response. Unknown names are silently dropped; an empty \
806 value falls back to the full row (BUG-81). Composes \
807 with hide / transform / computed — hide always wins, \
808 the rest are returned iff in the list.",
809 "schema": { "type": "string" },
810 "x-umbral-fields": true,
811 "x-umbral-fields-columns": Value::Array(columns),
812 })
813}
814
815fn include_parameter(model: &ModelMeta) -> Value {
822 let fks: Vec<Value> = model
826 .fields
827 .iter()
828 .filter(|c| c.fk_target.is_some())
829 .filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
830 .map(|c| Value::String(c.name.clone()))
831 .collect();
832 json!({
833 "name": "include",
834 "in": "query",
835 "required": false,
836 "description": "Comma-separated list of foreign-key columns to expand \
837 in the response. Each named FK gets replaced with the \
838 full related-row JSON object (one batched IN(...) query \
839 per FK — no N+1). Unknown or non-FK names return a 400. \
840 Example: `?include=user,billing_address`.",
841 "schema": { "type": "string" },
842 "x-umbral-include": true,
843 "x-umbral-include-fks": Value::Array(fks),
844 })
845}
846
847fn pagination_parameters_for_style(style: umbral_rest::PaginationStyle) -> Vec<Value> {
856 match style {
857 umbral_rest::PaginationStyle::PageNumber => vec![
858 json!({
859 "name": "page",
860 "in": "query",
861 "required": false,
862 "description": "1-indexed page number. Defaults to 1 when omitted.",
863 "schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 },
864 "x-umbral-pagination": "page",
865 }),
866 json!({
867 "name": "page_size",
868 "in": "query",
869 "required": false,
870 "description": "Rows per page. Capped at 100. Default 20.",
871 "schema": {
872 "type": "integer", "format": "int32",
873 "minimum": 1, "maximum": 100, "default": 20,
874 },
875 "x-umbral-pagination": "page_size",
876 }),
877 ],
878 umbral_rest::PaginationStyle::LimitOffset => vec![
879 json!({
880 "name": "limit",
881 "in": "query",
882 "required": false,
883 "description": "Maximum rows to return. Defaults to the configured page size.",
884 "schema": { "type": "integer", "format": "int32", "minimum": 1 },
885 "x-umbral-pagination": "limit",
886 }),
887 json!({
888 "name": "offset",
889 "in": "query",
890 "required": false,
891 "description": "Number of rows to skip from the start of the result set. Defaults to 0.",
892 "schema": { "type": "integer", "format": "int32", "minimum": 0, "default": 0 },
893 "x-umbral-pagination": "offset",
894 }),
895 ],
896 umbral_rest::PaginationStyle::None | umbral_rest::PaginationStyle::Custom => vec![],
897 }
898}
899
900fn filter_parameters(model: &ModelMeta) -> Vec<Value> {
909 let mut out: Vec<Value> = Vec::new();
910 for col in &model.fields {
911 if col.primary_key {
912 continue;
913 }
914 let lookups = umbral_rest::filtering::applicable_lookups(col);
915 for lookup in lookups {
916 let name = if lookup == "eq" {
917 col.name.clone()
918 } else {
919 format!("{}__{}", col.name, lookup)
920 };
921 out.push(filter_parameter(col, lookup, &name));
922 }
923 }
924 out
925}
926
927fn filter_parameter(col: &Column, lookup: &str, name: &str) -> Value {
938 let (schema, description) = match lookup {
939 "in" => (
940 json!({ "type": "string" }),
941 format!(
942 "Comma-separated `{}` values; matches rows where the column is in the set.",
943 col.name,
944 ),
945 ),
946 "isnull" => (
947 json!({ "type": "boolean" }),
948 format!(
949 "`true` matches rows where `{}` IS NULL; `false` matches IS NOT NULL.",
950 col.name,
951 ),
952 ),
953 "contains" | "icontains" | "startswith" => {
954 let phrase = match lookup {
955 "contains" => "case-sensitive substring",
956 "icontains" => "case-insensitive substring",
957 "startswith" => "case-sensitive prefix",
958 _ => unreachable!(),
959 };
960 (
961 json!({ "type": "string" }),
962 format!(
963 "Matches rows where `{}` contains the given {phrase}.",
964 col.name
965 ),
966 )
967 }
968 _ => {
970 let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
971 let mut schema_obj = Map::new();
972 schema_obj.insert("type".into(), Value::String(ty.into()));
973 if let Some(f) = format {
974 schema_obj.insert("format".into(), Value::String(f.into()));
975 }
976 let phrase = match lookup {
977 "eq" => "equals the value",
978 "ne" => "does not equal the value",
979 "gte" => "is greater than or equal to the value",
980 "lte" => "is less than or equal to the value",
981 "gt" => "is greater than the value",
982 "lt" => "is less than the value",
983 _ => "matches the value",
984 };
985 (
986 Value::Object(schema_obj),
987 format!("Matches rows where `{}` {phrase}.", col.name),
988 )
989 }
990 };
991
992 json!({
993 "name": name,
994 "in": "query",
995 "required": false,
996 "description": description,
997 "schema": schema,
998 "x-umbral-filter-field": col.name,
999 "x-umbral-filter-lookup": lookup,
1000 })
1001}
1002
1003fn collection_paths(table: &str, schema_name: &str, filter_params: &[Value]) -> Value {
1004 use umbral_rest::Action;
1005 let mut item = Map::new();
1006
1007 if umbral_rest::action_exposed(table, &Action::List) {
1012 let mut get_op = Map::new();
1013 get_op.insert(
1014 "operationId".into(),
1015 Value::String(format!("list_{}", table)),
1016 );
1017 get_op.insert("tags".into(), json!([table]));
1018 if !filter_params.is_empty() {
1019 get_op.insert("parameters".into(), Value::Array(filter_params.to_vec()));
1020 }
1021 get_op.insert(
1022 "responses".into(),
1023 json!({
1024 "200": {
1025 "description": "List of rows",
1026 "content": {
1027 "application/json": {
1028 "schema": list_envelope(schema_name)
1029 }
1030 }
1031 }
1032 }),
1033 );
1034 item.insert("get".into(), Value::Object(get_op));
1035 }
1036
1037 if umbral_rest::action_exposed(table, &Action::Create) {
1040 item.insert(
1041 "post".into(),
1042 json!({
1043 "operationId": format!("create_{}", table),
1044 "tags": [table],
1045 "requestBody": {
1046 "required": true,
1047 "content": {
1048 "application/json": {
1049 "schema": schema_ref(schema_name)
1050 }
1051 }
1052 },
1053 "responses": {
1054 "201": {
1055 "description": "Row created",
1056 "content": {
1057 "application/json": {
1058 "schema": schema_ref(schema_name)
1059 }
1060 }
1061 },
1062 "400": { "description": "Invalid input" }
1063 }
1064 }),
1065 );
1066 }
1067
1068 Value::Object(item)
1069}
1070
1071fn item_paths(table: &str, schema_name: &str, retrieve_query_params: &[Value]) -> Value {
1072 use umbral_rest::Action;
1073 let id_param = json!({
1074 "name": "id",
1075 "in": "path",
1076 "required": true,
1077 "schema": { "type": "string" }
1078 });
1079 let mut item = Map::new();
1080 item.insert("parameters".into(), json!([id_param]));
1081
1082 if umbral_rest::action_exposed(table, &Action::Retrieve) {
1088 let mut get_op = Map::new();
1089 get_op.insert(
1090 "operationId".into(),
1091 Value::String(format!("retrieve_{}", table)),
1092 );
1093 get_op.insert("tags".into(), json!([table]));
1094 if !retrieve_query_params.is_empty() {
1095 get_op.insert(
1096 "parameters".into(),
1097 Value::Array(retrieve_query_params.to_vec()),
1098 );
1099 }
1100 get_op.insert(
1101 "responses".into(),
1102 json!({
1103 "200": {
1104 "description": "Row found",
1105 "content": {
1106 "application/json": {
1107 "schema": schema_ref(schema_name)
1108 }
1109 }
1110 },
1111 "404": { "description": "Not found" }
1112 }),
1113 );
1114 item.insert("get".into(), Value::Object(get_op));
1115 }
1116
1117 if umbral_rest::action_exposed(table, &Action::Update) {
1119 item.insert(
1120 "put".into(),
1121 json!({
1122 "operationId": format!("update_{}", table),
1123 "tags": [table],
1124 "requestBody": {
1125 "required": true,
1126 "content": {
1127 "application/json": {
1128 "schema": schema_ref(schema_name)
1129 }
1130 }
1131 },
1132 "responses": {
1133 "200": {
1134 "description": "Row updated",
1135 "content": {
1136 "application/json": {
1137 "schema": schema_ref(schema_name)
1138 }
1139 }
1140 },
1141 "404": { "description": "Not found" }
1142 }
1143 }),
1144 );
1145 item.insert(
1146 "patch".into(),
1147 json!({
1148 "operationId": format!("partial_update_{}", table),
1149 "tags": [table],
1150 "requestBody": {
1151 "required": true,
1152 "content": {
1153 "application/json": {
1154 "schema": schema_ref(schema_name)
1155 }
1156 }
1157 },
1158 "responses": {
1159 "200": {
1160 "description": "Row partially updated",
1161 "content": {
1162 "application/json": {
1163 "schema": schema_ref(schema_name)
1164 }
1165 }
1166 },
1167 "404": { "description": "Not found" }
1168 }
1169 }),
1170 );
1171 }
1172
1173 if umbral_rest::action_exposed(table, &Action::Delete) {
1175 item.insert(
1176 "delete".into(),
1177 json!({
1178 "operationId": format!("destroy_{}", table),
1179 "tags": [table],
1180 "responses": {
1181 "204": { "description": "Row deleted" },
1182 "404": { "description": "Not found" }
1183 }
1184 }),
1185 );
1186 }
1187
1188 Value::Object(item)
1189}
1190
1191fn schema_ref(name: &str) -> Value {
1192 json!({ "$ref": format!("#/components/schemas/{}", name) })
1193}
1194
1195fn has_operations(path_item: &Value) -> bool {
1200 const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"];
1201 path_item
1202 .as_object()
1203 .is_some_and(|m| METHODS.iter().any(|verb| m.contains_key(*verb)))
1204}
1205
1206fn list_envelope(schema_name: &str) -> Value {
1207 json!({
1208 "type": "object",
1209 "properties": {
1210 "results": {
1211 "type": "array",
1212 "items": schema_ref(schema_name)
1213 },
1214 "count": { "type": "integer" }
1215 },
1216 "required": ["results", "count"]
1217 })
1218}
1219
1220#[doc(hidden)]
1224pub fn test_spec_url(p: &OpenApiPlugin) -> String {
1225 p.spec_url()
1226}
1227
1228#[doc(hidden)]
1229pub fn test_ui_route(p: &OpenApiPlugin) -> String {
1230 p.ui_route()
1231}
1232
1233#[cfg(test)]
1237mod tests {
1238 use super::*;
1239 use umbral::migrate::Column;
1240 use umbral::orm::SqlType;
1241
1242 fn base_col(name: &str, ty: SqlType) -> Column {
1243 Column {
1244 name: name.into(),
1245 ty,
1246 primary_key: false,
1247 nullable: false,
1248 fk_target: None,
1249 noform: false,
1250 db_constraint: true,
1251 noedit: false,
1252 is_string_repr: false,
1253 max_length: 0,
1254 choices: Vec::new(),
1255 choice_labels: Vec::new(),
1256 default: String::new(),
1257 is_multichoice: false,
1258 unique: false,
1259 on_delete: ::umbral::orm::FkAction::NoAction,
1260 on_update: ::umbral::orm::FkAction::NoAction,
1261 index: false,
1262 auto_now_add: false,
1263 auto_now: false,
1264 help: String::new(),
1265 example: String::new(),
1266 widget: None,
1267 supported_backends: Vec::new(),
1268 min: None,
1269 max: None,
1270 text_format: ::core::option::Option::None,
1271 slug_from: ::core::option::Option::None,
1272 }
1273 }
1274
1275 #[test]
1276 fn choices_render_as_openapi_enum_with_labels_extension() {
1277 let mut col = base_col("status", SqlType::Text);
1278 col.choices = vec!["draft".into(), "published".into(), "archived".into()];
1279 col.choice_labels = vec!["Draft".into(), "Published".into(), "Archived".into()];
1280 let schema = column_schema(&col);
1281 assert_eq!(schema["type"], "string");
1282 assert_eq!(
1283 schema["enum"],
1284 serde_json::json!(["draft", "published", "archived"])
1285 );
1286 assert_eq!(
1287 schema["x-umbral-choice-labels"],
1288 serde_json::json!(["Draft", "Published", "Archived"])
1289 );
1290 }
1291
1292 #[test]
1293 fn multichoice_skips_enum_and_uses_vendor_extension() {
1294 let mut col = base_col("tags", SqlType::Text);
1295 col.choices = vec!["rust".into(), "python".into()];
1296 col.is_multichoice = true;
1297 let schema = column_schema(&col);
1298 assert!(
1299 schema.get("enum").is_none(),
1300 "multichoice columns should not declare a flat enum (value is a CSV subset)"
1301 );
1302 assert_eq!(schema["x-umbral-multichoice"], true);
1303 assert_eq!(
1304 schema["x-umbral-choices"],
1305 serde_json::json!(["rust", "python"])
1306 );
1307 }
1308
1309 #[test]
1310 fn max_length_and_default_surface_as_standard_openapi_keys() {
1311 let mut col = base_col("title", SqlType::Text);
1312 col.max_length = 50;
1313 col.default = "untitled".into();
1314 let schema = column_schema(&col);
1315 assert_eq!(schema["maxLength"], 50);
1316 assert_eq!(schema["default"], "untitled");
1317 }
1318
1319 #[test]
1320 fn fk_target_emits_vendor_extension_for_playground_navigation() {
1321 let mut col = base_col("author_id", SqlType::ForeignKey);
1322 col.fk_target = Some("auth_user".into());
1323 let schema = column_schema(&col);
1324 assert_eq!(schema["type"], "integer");
1325 assert_eq!(schema["format"], "int64");
1326 assert_eq!(schema["x-umbral-fk-target"], "auth_user");
1327 }
1328
1329 #[test]
1330 fn noform_renders_as_read_only_and_carries_vendor_extension() {
1331 let mut col = base_col("internal_token", SqlType::Text);
1336 col.noform = true;
1337 let schema = column_schema(&col);
1338 assert_eq!(schema["readOnly"], true);
1339 assert_eq!(schema["x-umbral-noform"], true);
1340 }
1341
1342 #[test]
1343 fn noedit_does_NOT_render_as_read_only() {
1344 let mut col = base_col("email", SqlType::Text);
1350 col.noedit = true;
1351 let schema = column_schema(&col);
1352 assert!(
1353 schema.get("readOnly").is_none(),
1354 "noedit must NOT contaminate the API request-body contract; \
1355 got readOnly in schema: {schema:?}"
1356 );
1357 assert_eq!(schema["x-umbral-noedit"], true);
1360 }
1361
1362 #[test]
1363 fn plain_column_keeps_minimal_schema_no_extensions() {
1364 let col = base_col("body", SqlType::Text);
1365 let schema = column_schema(&col);
1366 let obj = schema.as_object().expect("object");
1367 assert_eq!(
1368 obj.len(),
1369 1,
1370 "plain column should only have `type`: {obj:?}"
1371 );
1372 assert_eq!(schema["type"], "string");
1373 }
1374
1375 #[test]
1380 fn help_attribute_flows_to_openapi_description() {
1381 let mut col = base_col("status", SqlType::Text);
1382 col.help = "Workflow step. Set by editors on Save.".to_string();
1383 let schema = column_schema(&col);
1384 assert_eq!(
1385 schema["description"], "Workflow step. Set by editors on Save.",
1386 "help should round-trip to OpenAPI description; got: {schema:?}",
1387 );
1388 }
1389
1390 #[test]
1391 fn empty_help_omits_description() {
1392 let col = base_col("body", SqlType::Text);
1393 let schema = column_schema(&col);
1394 assert!(
1395 schema.get("description").is_none(),
1396 "empty help should omit description; got: {schema:?}",
1397 );
1398 }
1399
1400 #[test]
1404 fn example_attribute_flows_to_openapi_example() {
1405 let mut col = base_col("status", SqlType::Text);
1406 col.example = "published".to_string();
1407 let schema = column_schema(&col);
1408 assert_eq!(
1409 schema["example"], "published",
1410 "example should round-trip; got: {schema:?}",
1411 );
1412 }
1413
1414 #[test]
1415 fn empty_example_omits_example() {
1416 let col = base_col("body", SqlType::Text);
1417 let schema = column_schema(&col);
1418 assert!(
1419 schema.get("example").is_none(),
1420 "empty example should omit example key; got: {schema:?}",
1421 );
1422 }
1423
1424 fn note_model() -> ModelMeta {
1429 let mut id = base_col("id", SqlType::BigInt);
1430 id.primary_key = true;
1431 let mut published_at = base_col("published_at", SqlType::Timestamptz);
1432 published_at.nullable = true;
1433 ModelMeta {
1434 name: "Note".to_string(),
1435 table: "note".to_string(),
1436 fields: vec![
1437 id,
1438 base_col("title", SqlType::Text),
1439 base_col("views", SqlType::Integer),
1440 published_at,
1441 ],
1442 display: "Note".to_string(),
1443 icon: "database".to_string(),
1444 database: None,
1445 singleton: false,
1446 unique_together: Vec::new(),
1447 indexes: Vec::new(),
1448 ordering: Vec::new(),
1449 m2m_relations: Vec::new(),
1450 soft_delete: false,
1451 app_label: "app".to_string(),
1452 }
1453 }
1454
1455 #[test]
1456 fn filter_parameters_skips_primary_key() {
1457 let params = filter_parameters(¬e_model());
1458 let names: Vec<&str> = params.iter().map(|p| p["name"].as_str().unwrap()).collect();
1459 assert!(
1460 !names.iter().any(|n| *n == "id" || n.starts_with("id__")),
1461 "PK column should be skipped; got {names:?}",
1462 );
1463 }
1464
1465 #[test]
1466 fn filter_parameters_eq_uses_bare_column_name_no_suffix() {
1467 let params = filter_parameters(¬e_model());
1468 let bare_title = params
1469 .iter()
1470 .find(|p| p["name"] == "title")
1471 .expect("title eq parameter should be present");
1472 assert_eq!(bare_title["x-umbral-filter-lookup"], "eq");
1473 assert_eq!(bare_title["x-umbral-filter-field"], "title");
1474 assert_eq!(bare_title["schema"]["type"], "string");
1475 }
1476
1477 #[test]
1478 fn filter_parameters_in_is_string_typed_with_csv_description() {
1479 let params = filter_parameters(¬e_model());
1480 let title_in = params
1481 .iter()
1482 .find(|p| p["name"] == "title__in")
1483 .expect("title__in parameter should be present");
1484 assert_eq!(title_in["schema"]["type"], "string");
1485 assert!(
1486 title_in["description"]
1487 .as_str()
1488 .unwrap()
1489 .to_lowercase()
1490 .contains("comma"),
1491 "__in description should mention the comma-separated format",
1492 );
1493 }
1494
1495 #[test]
1496 fn filter_parameters_isnull_only_on_nullable_columns() {
1497 let params = filter_parameters(¬e_model());
1498 let isnull_params: Vec<&str> = params
1499 .iter()
1500 .filter_map(|p| p["name"].as_str())
1501 .filter(|n| n.ends_with("__isnull"))
1502 .collect();
1503 assert_eq!(
1504 isnull_params,
1505 vec!["published_at__isnull"],
1506 "isnull lookup should only appear for nullable columns; got {isnull_params:?}",
1507 );
1508 }
1509
1510 #[test]
1511 fn filter_parameters_range_lookups_only_on_numeric_or_temporal() {
1512 let params = filter_parameters(¬e_model());
1513 let has_gte = |field: &str| params.iter().any(|p| p["name"] == format!("{field}__gte"));
1514 assert!(has_gte("views"), "integer column gets gte");
1515 assert!(has_gte("published_at"), "timestamp column gets gte");
1516 assert!(
1517 !has_gte("title"),
1518 "text column must NOT get gte; got {params:?}",
1519 );
1520 }
1521
1522 #[test]
1523 fn filter_parameters_string_lookups_only_on_text() {
1524 let params = filter_parameters(¬e_model());
1525 let has_contains = |field: &str| {
1526 params
1527 .iter()
1528 .any(|p| p["name"] == format!("{field}__contains"))
1529 };
1530 assert!(has_contains("title"), "text column gets contains");
1531 assert!(
1532 !has_contains("views"),
1533 "integer column must NOT get contains; got {params:?}",
1534 );
1535 }
1536
1537 #[test]
1538 fn collection_paths_omits_parameters_array_when_no_filters() {
1539 let value = collection_paths("note", "Note", &[]);
1540 let get_op = &value["get"];
1541 assert!(
1542 get_op.get("parameters").is_none(),
1543 "no filters → no parameters key; got {get_op:?}",
1544 );
1545 }
1546
1547 #[test]
1548 fn collection_paths_includes_parameters_when_filters_present() {
1549 let filter_params = filter_parameters(¬e_model());
1550 let value = collection_paths("note", "Note", &filter_params);
1551 let params = value["get"]["parameters"]
1552 .as_array()
1553 .expect("parameters array should be present when filters land");
1554 assert!(!params.is_empty());
1555 assert!(
1556 params.iter().all(|p| p["in"] == "query"),
1557 "every filter parameter is in: query",
1558 );
1559 }
1560
1561 #[test]
1566 fn fields_parameter_lists_model_columns() {
1567 let param = fields_parameter(¬e_model());
1568 assert_eq!(param["name"], "fields");
1569 assert_eq!(param["in"], "query");
1570 assert_eq!(param["x-umbral-fields"], true);
1571 let cols = param["x-umbral-fields-columns"]
1572 .as_array()
1573 .expect("x-umbral-fields-columns should be a list");
1574 let names: Vec<&str> = cols.iter().filter_map(|v| v.as_str()).collect();
1575 assert!(names.contains(&"title"));
1576 assert!(names.contains(&"views"));
1577 assert!(
1578 !names.is_empty(),
1579 "every column should land in the enum so the playground can offer it",
1580 );
1581 }
1582
1583 #[test]
1586 fn item_paths_advertises_fields_query_param_on_retrieve() {
1587 let value = item_paths("note", "Note", &[fields_parameter(¬e_model())]);
1588 let get_params = value["get"]["parameters"]
1589 .as_array()
1590 .expect("retrieve op should carry its query parameters");
1591 assert!(
1592 get_params.iter().any(|p| p["name"] == "fields"),
1593 "fields parameter should be on the retrieve op; got {get_params:?}",
1594 );
1595 }
1596
1597 #[test]
1602 fn fk_column_emits_schema_ref_when_target_known() {
1603 let mut col = base_col("author", SqlType::ForeignKey);
1604 col.fk_target = Some("auth_user".into());
1605 let mut map = std::collections::HashMap::new();
1606 map.insert("auth_user".to_string(), "AuthUser".to_string());
1607 let schema = column_schema_with_refs(&col, &map);
1608 assert_eq!(
1609 schema["x-umbral-fk-target"], "auth_user",
1610 "the table-name vendor extension stays for backward compat",
1611 );
1612 assert_eq!(
1613 schema["x-umbral-fk-ref"], "#/components/schemas/AuthUser",
1614 "the JSON pointer to the target schema should be emitted",
1615 );
1616 }
1617
1618 #[test]
1619 fn fk_column_without_known_target_omits_schema_ref() {
1620 let mut col = base_col("author", SqlType::ForeignKey);
1621 col.fk_target = Some("unknown_table".into());
1622 let map = std::collections::HashMap::new();
1623 let schema = column_schema_with_refs(&col, &map);
1624 assert!(
1625 schema.get("x-umbral-fk-ref").is_none(),
1626 "unknown FK target → no ref emitted; got: {schema:?}",
1627 );
1628 }
1629
1630 #[test]
1636 fn m2m_relation_lands_in_model_schema_with_target_extension() {
1637 let mut model = note_model();
1638 model.m2m_relations.push(umbral::migrate::M2MRelation {
1639 field_name: "tags".to_string(),
1640 target_table: "tag".to_string(),
1641 target_name: "Tag".to_string(),
1642 });
1643 let mut tts = std::collections::HashMap::new();
1647 tts.insert("tag".to_string(), "Tag".to_string());
1648 let schema = model_schema(&model, &tts);
1649 let tags_prop = &schema["properties"]["tags"];
1650 assert_eq!(tags_prop["type"], "array");
1651 assert_eq!(tags_prop["items"]["type"], "integer");
1652 assert_eq!(tags_prop["x-umbral-m2m"], true);
1653 assert_eq!(tags_prop["x-umbral-m2m-target"], "Tag");
1654 assert_eq!(tags_prop["x-umbral-m2m-target-table"], "tag");
1655 assert_eq!(
1656 tags_prop["x-umbral-m2m-target-ref"],
1657 "#/components/schemas/Tag",
1658 );
1659 let required = schema["required"].as_array();
1661 if let Some(req) = required {
1662 assert!(!req.iter().any(|v| v == "tags"));
1663 }
1664 }
1665
1666 #[test]
1674 fn auto_now_columns_are_optional_in_the_request_schema() {
1675 let mut model = note_model();
1676 let mut created = base_col("created_at", SqlType::Timestamptz);
1677 created.auto_now_add = true;
1678 let mut updated = base_col("updated_at", SqlType::Timestamptz);
1679 updated.auto_now = true;
1680 model.fields.push(created);
1681 model.fields.push(updated);
1682
1683 let schema = model_schema(&model, &std::collections::HashMap::new());
1684
1685 assert_eq!(
1689 schema["properties"]["created_at"]["x-umbral-auto-now-add"],
1690 true
1691 );
1692 assert_eq!(
1693 schema["properties"]["updated_at"]["x-umbral-auto-now"],
1694 true
1695 );
1696
1697 assert!(
1701 schema["properties"]["created_at"].get("readOnly").is_none(),
1702 "auto_now_add must not be readOnly; got {}",
1703 schema["properties"]["created_at"],
1704 );
1705 assert!(
1706 schema["properties"]["updated_at"].get("readOnly").is_none(),
1707 "auto_now must not be readOnly; got {}",
1708 schema["properties"]["updated_at"],
1709 );
1710
1711 let required = schema["required"].as_array().expect("required array");
1714 let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
1715 assert!(
1716 !names.contains(&"created_at"),
1717 "auto_now_add should drop out of required; got {names:?}",
1718 );
1719 assert!(
1720 !names.contains(&"updated_at"),
1721 "auto_now should drop out of required; got {names:?}",
1722 );
1723 }
1724
1725 #[test]
1728 fn pagination_parameters_per_style() {
1729 use umbral_rest::PaginationStyle;
1730
1731 let none_params = pagination_parameters_for_style(PaginationStyle::None);
1733 assert!(
1734 none_params.is_empty(),
1735 "NoPagination should emit no pagination params; got {none_params:?}"
1736 );
1737
1738 let custom_params = pagination_parameters_for_style(PaginationStyle::Custom);
1740 assert!(
1741 custom_params.is_empty(),
1742 "Custom pagination should emit no params; got {custom_params:?}"
1743 );
1744
1745 let page_params = pagination_parameters_for_style(PaginationStyle::PageNumber);
1747 assert_eq!(page_params.len(), 2, "PageNumber should emit 2 params");
1748 assert_eq!(page_params[0]["name"], "page");
1749 assert_eq!(page_params[0]["in"], "query");
1750 assert_eq!(page_params[0]["schema"]["type"], "integer");
1751 assert_eq!(page_params[0]["schema"]["minimum"], 1);
1752 assert_eq!(page_params[0]["schema"]["default"], 1);
1753 assert_eq!(page_params[0]["x-umbral-pagination"], "page");
1754 assert_eq!(page_params[1]["name"], "page_size");
1755 assert_eq!(page_params[1]["schema"]["maximum"], 100);
1756 assert_eq!(page_params[1]["x-umbral-pagination"], "page_size");
1757
1758 let lo_params = pagination_parameters_for_style(PaginationStyle::LimitOffset);
1760 assert_eq!(lo_params.len(), 2, "LimitOffset should emit 2 params");
1761 assert_eq!(lo_params[0]["name"], "limit");
1762 assert_eq!(lo_params[0]["x-umbral-pagination"], "limit");
1763 assert_eq!(lo_params[1]["name"], "offset");
1764 assert_eq!(lo_params[1]["x-umbral-pagination"], "offset");
1765 assert_eq!(lo_params[1]["schema"]["minimum"], 0);
1766 }
1767}