#![allow(dead_code, private_interfaces)]
use axum::body::Body;
use axum::http::{Request, StatusCode};
use chrono::{DateTime, Utc};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::OnceCell;
use tower::ServiceExt;
use umbral_auth::{AuthPlugin, AuthUser};
use umbral_openapi::OpenApiPlugin;
use umbral_rest::RestPlugin;
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
struct Note {
id: i64,
title: String,
body: String,
published_at: Option<DateTime<Utc>>,
}
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
struct Secret {
id: i64,
label: String,
token: String,
}
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "oa_cat")]
struct OaCat {
#[umbral(primary_key)]
slug: String,
name: String,
}
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "oa_article")]
struct OaArticle {
id: i64,
cat: umbral::orm::ForeignKey<OaCat>,
#[sqlx(skip)]
#[serde(skip)]
related: umbral::orm::M2M<OaCat>,
}
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "oa_readonly")]
struct OaReadonly {
id: i64,
name: String,
}
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
async fn boot() -> &'static axum::Router {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("openapi_integration.sqlite");
std::mem::forget(tmp);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.filename(&path)
.create_if_missing(true),
)
.await
.expect("pool");
let app = umbral::App::builder()
.settings(settings)
.database("default", pool)
.model::<Note>()
.model::<Secret>()
.model::<OaCat>()
.model::<OaArticle>()
.model::<OaReadonly>()
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(
RestPlugin::default().hide("secret", "token").resource(
umbral_rest::ResourceConfig::new("oa_readonly")
.views([umbral_rest::Action::List, umbral_rest::Action::Retrieve]),
),
)
.plugin(OpenApiPlugin::default())
.build()
.expect("App::build with RestPlugin + OpenApiPlugin");
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE note (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
title TEXT NOT NULL,\
body TEXT NOT NULL,\
published_at TEXT\
)",
)
.execute(&pool)
.await
.expect("create note");
sqlx::query(
"CREATE TABLE secret (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
label TEXT NOT NULL,\
token TEXT NOT NULL\
)",
)
.execute(&pool)
.await
.expect("create secret");
app.into_router()
})
.await
}
async fn get_request(router: axum::Router, uri: &str) -> (StatusCode, String) {
let req = Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.expect("oneshot");
let status = resp.status();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::test]
async fn openapi_json_serves_a_valid_openapi_3_0_document() {
let router = boot().await.clone();
let (status, body) = get_request(router, "/openapi/openapi.json").await;
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_str(&body).expect("body is JSON");
let openapi = v["openapi"].as_str().expect("openapi field is a string");
assert!(
openapi.starts_with("3.0"),
"expected an OpenAPI 3.0.x doc, got {openapi}"
);
assert!(v["info"].is_object(), "info should be an object");
assert!(v["paths"].is_object(), "paths should be an object");
assert!(
v["components"].is_object(),
"components should be an object"
);
}
#[tokio::test]
async fn every_registered_model_appears_in_components_schemas() {
let router = boot().await.clone();
let (_, body) = get_request(router, "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let schemas = v["components"]["schemas"]
.as_object()
.expect("components.schemas is an object");
assert!(
schemas.contains_key("Note"),
"expected Note in schemas; got {:?}",
schemas.keys().collect::<Vec<_>>()
);
let note = &schemas["Note"];
let props = note["properties"].as_object().expect("properties");
for field in ["id", "title", "body", "published_at"] {
assert!(props.contains_key(field), "Note missing property `{field}`");
}
assert_eq!(
note["properties"]["published_at"]["nullable"], true,
"published_at should be nullable"
);
let required = note["required"].as_array().expect("required is array");
let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
assert!(names.contains(&"title"), "title should be required");
assert!(names.contains(&"body"), "body should be required");
assert!(!names.contains(&"id"), "id (PK) should NOT be required");
assert!(
!names.contains(&"published_at"),
"published_at (nullable) should NOT be required"
);
}
#[tokio::test]
async fn default_block_list_keeps_auth_user_out_of_the_spec() {
let router = boot().await.clone();
let (_, body) = get_request(router, "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let schemas = v["components"]["schemas"].as_object().expect("schemas");
assert!(
!schemas.contains_key("AuthUser"),
"AuthUser should be hidden by the default block-list; got {:?}",
schemas.keys().collect::<Vec<_>>()
);
let paths = v["paths"].as_object().expect("paths");
assert!(
!paths.contains_key("/api/auth_user/"),
"/api/auth_user/ should be absent from paths"
);
}
#[tokio::test]
async fn rest_hidden_field_is_excluded_from_the_model_schema() {
let router = boot().await.clone();
let (_, body) = get_request(router, "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let secret = &v["components"]["schemas"]["Secret"];
let props = secret["properties"]
.as_object()
.expect("Secret schema properties");
assert!(
!props.contains_key("token"),
"hidden `token` leaked into the Secret schema properties: {:?}",
props.keys().collect::<Vec<_>>()
);
if let Some(required) = secret["required"].as_array() {
let names: Vec<&str> = required.iter().filter_map(|x| x.as_str()).collect();
assert!(
!names.contains(&"token"),
"hidden `token` leaked into Secret.required: {names:?}"
);
}
assert!(
props.contains_key("label"),
"non-hidden `label` should still be a property; got {:?}",
props.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
async fn rest_hidden_field_is_excluded_from_the_fields_picker() {
let router = boot().await.clone();
let (_, body) = get_request(router, "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let list_params = v["paths"]["/api/secret/"]["get"]["parameters"]
.as_array()
.expect("list params array");
let fields_param = list_params
.iter()
.find(|p| p["name"] == "fields")
.expect("fields parameter present on /api/secret/ list op");
let cols: Vec<&str> = fields_param["x-umbral-fields-columns"]
.as_array()
.expect("x-umbral-fields-columns array")
.iter()
.filter_map(|x| x.as_str())
.collect();
assert!(
!cols.contains(&"token"),
"hidden `token` should not be offered in the ?fields= picker; got {cols:?}"
);
assert!(
cols.contains(&"label"),
"visible `label` should still be in the ?fields= picker; got {cols:?}"
);
}
#[tokio::test]
async fn every_rest_operation_appears_in_paths() {
let router = boot().await.clone();
let (_, body) = get_request(router, "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let paths = v["paths"].as_object().expect("paths");
let collection = paths
.get("/api/note/")
.expect("/api/note/ should be present");
assert!(
collection["get"]["operationId"].as_str() == Some("list_note"),
"list_note operationId missing"
);
assert!(
collection["post"]["operationId"].as_str() == Some("create_note"),
"create_note operationId missing"
);
assert!(
collection["post"]["responses"]["201"].is_object(),
"POST should advertise 201"
);
let item = paths
.get("/api/note/{id}")
.expect("/api/note/{id} should be present");
let ops = [
("get", "retrieve_note", "200"),
("put", "update_note", "200"),
("patch", "partial_update_note", "200"),
("delete", "destroy_note", "204"),
];
for (verb, op_id, success_code) in ops {
let op = item.get(verb).unwrap_or_else(|| panic!("missing {verb}"));
assert_eq!(
op["operationId"].as_str(),
Some(op_id),
"{verb}'s operationId should be {op_id}"
);
assert!(
op["responses"][success_code].is_object(),
"{verb} should advertise {success_code}"
);
assert!(
op["responses"]["404"].is_object(),
"{verb} should advertise 404"
);
}
}
#[tokio::test]
async fn view_scoped_resource_omits_write_operations() {
let router = boot().await.clone();
let (_, body) = get_request(router, "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let paths = v["paths"].as_object().expect("paths");
let collection = paths
.get("/api/oa_readonly/")
.expect("read-only collection still present (it serves GET)");
assert!(
collection.get("get").is_some(),
"List is exposed → `get` present: {collection}"
);
assert!(
collection.get("post").is_none(),
"Create scoped out → `post` must be absent: {collection}"
);
let item = paths
.get("/api/oa_readonly/{id}")
.expect("read-only detail still present (it serves GET)");
assert!(
item.get("get").is_some(),
"Retrieve exposed → `get` present"
);
for verb in ["put", "patch", "delete"] {
assert!(
item.get(verb).is_none(),
"{verb} scoped out → must be absent from the detail path item: {item}"
);
}
}
#[tokio::test]
async fn swagger_ui_html_page_loads_and_references_the_spec_url() {
let router = boot().await.clone();
let (status, body) = get_request(router, "/openapi/").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("/openapi/openapi.json"),
"swagger UI should point at /openapi/openapi.json; got\n{body}"
);
assert!(
body.contains("swagger-ui"),
"expected swagger-ui asset references in body"
);
}
#[test]
fn base_path_override_changes_both_routes() {
let with_slash = OpenApiPlugin::new().at("/api/docs/");
let without_slash = OpenApiPlugin::new().at("/api/docs");
assert_eq!(with_slash.spec_url_for_test(), "/api/docs/openapi.json");
assert_eq!(with_slash.ui_route_for_test(), "/api/docs/");
assert_eq!(without_slash.spec_url_for_test(), "/api/docs/openapi.json");
assert_eq!(without_slash.ui_route_for_test(), "/api/docs/");
let default_plugin = OpenApiPlugin::new();
assert_eq!(default_plugin.spec_url_for_test(), "/openapi/openapi.json");
assert_eq!(default_plugin.ui_route_for_test(), "/openapi/");
}
trait PluginInspect {
fn spec_url_for_test(&self) -> String;
fn ui_route_for_test(&self) -> String;
}
impl PluginInspect for OpenApiPlugin {
fn spec_url_for_test(&self) -> String {
umbral_openapi::test_spec_url(self)
}
fn ui_route_for_test(&self) -> String {
umbral_openapi::test_ui_route(self)
}
}
#[tokio::test]
async fn no_pagination_style_emits_no_pagination_params() {
let (_, body) = get_request(boot().await.clone(), "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let list_params = v["paths"]["/api/note/"]["get"]["parameters"]
.as_array()
.expect("list params array on /api/note/");
let param_names: Vec<&str> = list_params
.iter()
.filter_map(|p| p["name"].as_str())
.collect();
assert!(
!param_names.contains(&"page"),
"NoPagination should not emit a `page` param; got {param_names:?}"
);
assert!(
!param_names.contains(&"page_size"),
"NoPagination should not emit a `page_size` param; got {param_names:?}"
);
}
#[tokio::test]
async fn spec_paths_use_registered_rest_base_path() {
let (_, body) = get_request(boot().await.clone(), "/openapi/openapi.json").await;
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
let paths = v["paths"].as_object().expect("paths");
assert!(
paths.contains_key("/api/note/"),
"/api/note/ must be in paths when REST base is /api; keys: {:?}",
paths.keys().collect::<Vec<_>>()
);
assert!(
paths.contains_key("/api/note/{id}"),
"/api/note/{{id}} must be in paths; keys: {:?}",
paths.keys().collect::<Vec<_>>()
);
let non_api: Vec<&str> = paths
.keys()
.map(|k| k.as_str())
.filter(|k| k.starts_with("/note/"))
.collect();
assert!(
non_api.is_empty(),
"No bare /note/ paths should appear (only /api/note/); got {non_api:?}"
);
}
#[tokio::test]
async fn fk_and_m2m_to_string_pk_render_as_string_schema() {
let (status, body) = get_request(boot().await.clone(), "/openapi/openapi.json").await;
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_str(&body).expect("valid json");
let props = &v["components"]["schemas"]["OaArticle"]["properties"];
assert_eq!(
props["cat"]["type"], "string",
"FK to a String-PK target must be `string`; got {}",
props["cat"]
);
assert_eq!(
props["related"]["type"], "array",
"M2M is an array; got {}",
props["related"]
);
assert_eq!(
props["related"]["items"]["type"], "string",
"M2M to a String-PK child → string items; got {}",
props["related"]
);
}