summaryrefslogtreecommitdiffstats
path: root/src/model
diff options
context:
space:
mode:
authorSanto Cariotti <santo@dcariotti.me>2022-10-17 20:08:09 +0000
committerSanto Cariotti <santo@dcariotti.me>2022-10-17 20:08:09 +0000
commit74976dab57887a4d7e29b426cdf7422722fa58ee (patch)
tree5f1bbed3dbcf3ba520866cb4eb060aaf5a771915 /src/model
parent611293122213f83e82d851cd8dc83fd1e4f79dcd (diff)
Refactoring of mods
Diffstat (limited to 'src/model')
-rw-r--r--src/model/mod.rs2
-rw-r--r--src/model/models.rs479
-rw-r--r--src/model/routes.rs265
3 files changed, 746 insertions, 0 deletions
diff --git a/src/model/mod.rs b/src/model/mod.rs
new file mode 100644
index 0000000..a0e1883
--- /dev/null
+++ b/src/model/mod.rs
@@ -0,0 +1,2 @@
+pub mod models;
+pub mod routes;
diff --git a/src/model/models.rs b/src/model/models.rs
new file mode 100644
index 0000000..87e1997
--- /dev/null
+++ b/src/model/models.rs
@@ -0,0 +1,479 @@
+use crate::{config::CONFIG, db::get_client, errors::AppError, json::number_from_string};
+use serde_json::json;
+use sqlx::types::JsonValue;
+use sqlx::Row;
+
+use chrono::{Local, NaiveDateTime};
+use serde::{Deserialize, Serialize};
+use validator::Validate;
+
+/// Model for models.
+#[derive(Deserialize, Serialize, Validate, sqlx::FromRow)]
+pub struct Model {
+ id: i32,
+ #[validate(length(min = 2, message = "Can not be empty"))]
+ name: String,
+ description: Option<String>,
+ duration: f64,
+ height: f64,
+ weight: f64,
+ printer: Option<String>,
+ material: Option<String>,
+ author_id: i32,
+ created: NaiveDateTime,
+ updated: NaiveDateTime,
+}
+
+/// Payload used for model creation
+#[derive(Deserialize)]
+pub struct ModelCreate {
+ pub name: String,
+ pub description: Option<String>,
+ #[serde(deserialize_with = "number_from_string")]
+ pub duration: f64,
+ #[serde(deserialize_with = "number_from_string")]
+ pub height: f64,
+ #[serde(deserialize_with = "number_from_string")]
+ pub weight: f64,
+ pub printer: Option<String>,
+ pub material: Option<String>,
+}
+
+/// Payload used for model searching
+#[derive(Deserialize)]
+pub struct ModelFilter {
+ /// Stands for "query"
+ pub q: String,
+}
+
+#[derive(Serialize, sqlx::FromRow)]
+pub struct ModelUser {
+ pub id: i32,
+ name: String,
+ description: Option<String>,
+ duration: f64,
+ height: f64,
+ weight: f64,
+ printer: Option<String>,
+ material: Option<String>,
+ author_id: i32,
+ created: NaiveDateTime,
+ updated: NaiveDateTime,
+ author: Option<JsonValue>,
+ uploads: Option<JsonValue>,
+ likes: Option<JsonValue>,
+}
+
+#[derive(Deserialize, Serialize, sqlx::FromRow)]
+pub struct ModelUpload {
+ id: i32,
+ pub model_id: i32,
+ pub filepath: String,
+ created: NaiveDateTime,
+}
+
+impl Model {
+ #[allow(clippy::too_many_arguments)]
+ pub fn new(
+ name: String,
+ description: Option<String>,
+ duration: f64,
+ height: f64,
+ weight: f64,
+ printer: Option<String>,
+ material: Option<String>,
+ author_id: i32,
+ ) -> Self {
+ let now = Local::now().naive_utc();
+ Self {
+ id: 0,
+ name,
+ description,
+ duration,
+ height,
+ weight,
+ printer,
+ material,
+ author_id,
+ created: now,
+ updated: now,
+ }
+ }
+
+ /// Create a new model
+ pub async fn create(model: Model) -> Result<Model, AppError> {
+ let pool = unsafe { get_client() };
+
+ model
+ .validate()
+ .map_err(|error| AppError::BadRequest(error.to_string()))?;
+
+ let rec: Model = sqlx::query_as(
+ r#"
+ INSERT INTO models (name, description, duration, height, weight, printer, material, author_id, created, updated)
+ VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING *
+ "#)
+ .bind(model.name)
+ .bind(model.description)
+ .bind(model.duration)
+ .bind(model.height)
+ .bind(model.weight)
+ .bind(model.printer)
+ .bind(model.material)
+ .bind(model.author_id)
+ .bind(model.created)
+ .bind(model.updated)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(rec)
+ }
+
+ /// Edit a model
+ pub async fn edit(id: i32, model: Model) -> Result<Model, AppError> {
+ let pool = unsafe { get_client() };
+
+ model
+ .validate()
+ .map_err(|error| AppError::BadRequest(error.to_string()))?;
+
+ let rec: Model = sqlx::query_as(
+ r#"
+ UPDATE models SET name = $1, description = $2, duration = $3, height = $4, weight = $5, printer = $6, material = $7, updated = $8
+ WHERE id = $9
+ RETURNING *
+ "#)
+ .bind(model.name)
+ .bind(model.description)
+ .bind(model.duration)
+ .bind(model.height)
+ .bind(model.weight)
+ .bind(model.printer)
+ .bind(model.material)
+ .bind(model.updated)
+ .bind(id)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(rec)
+ }
+
+ /// Returns the model with id = `model_id`
+ pub async fn find_by_id(model_id: i32) -> Result<ModelUser, AppError> {
+ let pool = unsafe { get_client() };
+
+ let rec: ModelUser = sqlx::query_as(
+ r#"
+ WITH model_uploads AS (
+ SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads
+ FROM models
+ LEFT JOIN uploads ON uploads.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_likes AS (
+ SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes
+ FROM models
+ LEFT JOIN likes ON likes.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_author AS (
+ SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author
+ FROM models
+ JOIN users ON users.id = models.author_id
+ )
+ SELECT models.*, author, uploads, likes
+ FROM models
+ INNER JOIN model_author using (id)
+ INNER JOIN model_uploads using (id)
+ INNER JOIN model_likes using (id)
+ WHERE models.id = $1
+ "#)
+ .bind(model_id)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(rec)
+ }
+
+ /// List all models
+ pub async fn list(page: i64) -> Result<Vec<ModelUser>, AppError> {
+ let pool = unsafe { get_client() };
+ let rows: Vec<ModelUser> = sqlx::query_as(
+ r#"
+ WITH model_uploads AS (
+ SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads
+ FROM models
+ LEFT JOIN uploads ON uploads.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_likes AS (
+ SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes
+ FROM models
+ LEFT JOIN likes ON likes.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_author AS (
+ SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author
+ FROM models
+ JOIN users ON users.id = models.author_id
+ )
+ SELECT models.*, author, uploads, likes
+ FROM models
+ INNER JOIN model_author using (id)
+ INNER JOIN model_uploads using (id)
+ INNER JOIN model_likes using (id)
+ ORDER BY id DESC
+ LIMIT $1 OFFSET $2
+ "#)
+ .bind(CONFIG.page_limit)
+ .bind(CONFIG.page_limit * page)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(rows)
+ }
+
+ /// Filter models by some cols
+ pub async fn filter(page: i64, query: String) -> Result<Vec<ModelUser>, AppError> {
+ let pool = unsafe { get_client() };
+ let rows: Vec<ModelUser> = sqlx::query_as(
+ r#"
+ WITH model_uploads AS (
+ SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads
+ FROM models
+ LEFT JOIN uploads ON uploads.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_likes AS (
+ SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes
+ FROM models
+ LEFT JOIN likes ON likes.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_author AS (
+ SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author
+ FROM models
+ JOIN users ON users.id = models.author_id
+ )
+ SELECT models.*, author, uploads, likes
+ FROM models
+ INNER JOIN model_author using (id)
+ INNER JOIN model_uploads using (id)
+ INNER JOIN model_likes using (id)
+ WHERE models.name ILIKE $1 OR description ILIKE $1 OR printer ILIKE $1 OR material ILIKE $1
+ ORDER BY id DESC
+ LIMIT $2 OFFSET $3
+ "#)
+ .bind(format!("%{}%", query))
+ .bind(CONFIG.page_limit)
+ .bind(CONFIG.page_limit * page)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(rows)
+ }
+
+ /// List author's models
+ pub async fn list_from_author(page: i64, author: i32) -> Result<Vec<ModelUser>, AppError> {
+ let pool = unsafe { get_client() };
+ let rows: Vec<ModelUser> = sqlx::query_as(
+ r#"
+ WITH model_uploads AS (
+ SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads
+ FROM models
+ LEFT JOIN uploads ON uploads.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_likes AS (
+ SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes
+ FROM models
+ LEFT JOIN likes ON likes.model_id = models.id
+ GROUP BY models.id
+ ),
+ model_author AS (
+ SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author
+ FROM models
+ JOIN users ON users.id = models.author_id
+ )
+ SELECT models.*, author, uploads, likes
+ FROM models
+ INNER JOIN model_author using (id)
+ INNER JOIN model_uploads using (id)
+ INNER JOIN model_likes using (id)
+ WHERE models.author_id = $1
+ ORDER BY id DESC
+ LIMIT $2 OFFSET $3
+ "#)
+ .bind(author)
+ .bind(CONFIG.page_limit)
+ .bind(CONFIG.page_limit * page)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(rows)
+ }
+
+ /// Delete a model
+ pub async fn delete(model_id: i32) -> Result<(), AppError> {
+ let pool = unsafe { get_client() };
+
+ sqlx::query(
+ r#"
+ DELETE FROM models WHERE id = $1
+ "#,
+ )
+ .bind(model_id)
+ .execute(pool)
+ .await?;
+
+ Ok(())
+ }
+
+ /// Return the number of models.
+ pub async fn count() -> Result<i64, AppError> {
+ let pool = unsafe { get_client() };
+ let cursor = sqlx::query(r#"SELECT COUNT(id) as count FROM models"#)
+ .fetch_one(pool)
+ .await?;
+
+ let count: i64 = cursor.try_get(0).unwrap();
+ Ok(count)
+ }
+
+ /// Return the number of author models
+ pub async fn count_filter_by_author(author: i32) -> Result<i64, AppError> {
+ let pool = unsafe { get_client() };
+ let cursor = sqlx::query(r#"SELECT COUNT(id) as count FROM models WHERE author_id = $1"#)
+ .bind(author)
+ .fetch_one(pool)
+ .await?;
+
+ let count: i64 = cursor.try_get(0).unwrap();
+ Ok(count)
+ }
+
+ /// Return the number of models filtered by query
+ pub async fn count_filter(query: String) -> Result<i64, AppError> {
+ let pool = unsafe { get_client() };
+ let cursor = sqlx::query(
+ r#"
+ SELECT COUNT(id) as count FROM models
+ WHERE models.name ILIKE $1 OR description ILIKE $1 OR printer ILIKE $1 OR material ILIKE $1
+ "#
+ )
+ .bind(format!("%{}%", query))
+ .fetch_one(pool)
+ .await?;
+
+ let count: i64 = cursor.try_get(0).unwrap();
+ Ok(count)
+ }
+}
+
+impl ModelUser {
+ /// Returns the author id from the `JsonValue`
+ pub fn author_id(&self) -> JsonValue {
+ match &self.author {
+ Some(json) => json.get("id").unwrap().clone(),
+ None => json!(0),
+ }
+ }
+
+ /// Returns a vec of string made by all the filepaths from the model
+ pub async fn list_upload_filepaths(&self) -> Option<Vec<String>> {
+ // Raise a `None` if `self.uploads` is `None`
+ self.uploads.as_ref()?;
+
+ let uploads = ModelUpload::find_by_model(self.id)
+ .await
+ .unwrap_or_default();
+
+ let paths = uploads
+ .iter()
+ .map(|x| x.filepath.clone())
+ .collect::<Vec<String>>();
+
+ Some(paths)
+ }
+}
+
+impl ModelUpload {
+ pub fn new(filepath: String, model_id: i32) -> Self {
+ let now = Local::now().naive_utc();
+ Self {
+ id: 0,
+ filepath,
+ model_id,
+ created: now,
+ }
+ }
+
+ /// Create a new upload for model
+ pub async fn create(file: ModelUpload) -> Result<ModelUpload, AppError> {
+ let pool = unsafe { get_client() };
+
+ let rec: ModelUpload = sqlx::query_as(
+ r#"
+ INSERT INTO uploads (filepath, model_id, created)
+ VALUES ( $1, $2, $3)
+ RETURNING *
+ "#,
+ )
+ .bind(file.filepath)
+ .bind(file.model_id)
+ .bind(file.created)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(rec)
+ }
+
+ /// Find all paths of a model
+ pub async fn find_by_model(model_id: i32) -> Result<Vec<ModelUpload>, AppError> {
+ let pool = unsafe { get_client() };
+
+ let rec: Vec<ModelUpload> = sqlx::query_as(
+ r#"
+ SELECT * FROM uploads WHERE model_id = $1
+ "#,
+ )
+ .bind(model_id)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(rec)
+ }
+
+ /// Returns the model upload with id = `upload_id`
+ pub async fn find_by_id(id: i32) -> Result<ModelUpload, AppError> {
+ let pool = unsafe { get_client() };
+
+ let rec: ModelUpload = sqlx::query_as(
+ r#"
+ SELECT * FROM uploads WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(rec)
+ }
+
+ /// Delete a model upload
+ pub async fn delete(upload_id: i32) -> Result<(), AppError> {
+ let pool = unsafe { get_client() };
+
+ sqlx::query(
+ r#"
+ DELETE FROM uploads WHERE id = $1
+ "#,
+ )
+ .bind(upload_id)
+ .execute(pool)
+ .await?;
+
+ Ok(())
+ }
+}
diff --git a/src/model/routes.rs b/src/model/routes.rs
new file mode 100644
index 0000000..26d1bb5
--- /dev/null
+++ b/src/model/routes.rs
@@ -0,0 +1,265 @@
+use crate::{
+ auth::models::Claims,
+ errors::AppError,
+ files::{delete_upload, upload},
+ likes::models::Like,
+ model::models::{Model, ModelCreate, ModelFilter, ModelUpload, ModelUser},
+ pagination::{ModelPagination, Pagination},
+ routes::JsonCreate,
+ user::models::User,
+};
+use axum::{
+ extract::{ContentLengthLimit, Multipart, Path, Query},
+ http::StatusCode,
+ routing::{delete, get, post},
+ Json, Router,
+};
+
+/// Create routes for `/v1/models/` namespace
+pub fn create_route() -> Router {
+ Router::new()
+ .route("/", get(list_models).post(create_model))
+ .route("/filter", post(filter_models))
+ .route("/:id", get(get_model).delete(delete_model).put(edit_model))
+ .route("/:id/like", post(add_like).delete(delete_like))
+ .route("/:id/upload", post(upload_model_file))
+ .route("/:id/upload/:uid", delete(delete_model_file))
+}
+
+/// List models.
+async fn list_models(pagination: Query<Pagination>) -> Result<Json<ModelPagination>, AppError> {
+ let page = pagination.0.page.unwrap_or_default();
+ let results = Model::list(page).await?;
+ let count = Model::count().await?;
+
+ Ok(Json(ModelPagination { count, results }))
+}
+
+/// Create a model. Checks Authorization token
+async fn create_model(
+ Json(payload): Json<ModelCreate>,
+ claims: Claims,
+) -> Result<JsonCreate<Model>, AppError> {
+ let model = Model::new(
+ payload.name,
+ payload.description,
+ payload.duration,
+ payload.height,
+ payload.weight,
+ payload.printer,
+ payload.material,
+ claims.user_id,
+ );
+
+ let model_new = Model::create(model).await?;
+
+ Ok(JsonCreate(model_new))
+}
+
+/// Get a model with id = `model_id`
+async fn get_model(Path(model_id): Path<i32>) -> Result<Json<ModelUser>, AppError> {
+ match Model::find_by_id(model_id).await {
+ Ok(model) => Ok(Json(model)),
+ Err(_) => Err(AppError::NotFound("Model not found".to_string())),
+ }
+}
+
+/// The owner or a staffer can delete a model
+async fn delete_model(claims: Claims, Path(model_id): Path<i32>) -> Result<StatusCode, AppError> {
+ let model = match Model::find_by_id(model_id).await {
+ Ok(model) => model,
+ Err(_) => {
+ return Err(AppError::NotFound("Model not found".to_string()));
+ }
+ };
+
+ let user = User::find_by_id(claims.user_id).await?;
+
+ let uploads: Vec<String> = model.list_upload_filepaths().await.unwrap_or_default();
+
+ if !(model.author_id() == user.id || user.is_staff.unwrap()) {
+ return Err(AppError::Unauthorized);
+ }
+
+ // If the model has been deleted, remove all old uploads from the file system
+ if Model::delete(model_id).await.is_ok() {
+ uploads
+ .iter()
+ .for_each(|path: &String| delete_upload(path).unwrap_or_default());
+ }
+
+ Ok(StatusCode::NO_CONTENT)
+}
+
+/// The owner or a staffer can edit a model
+async fn edit_model(
+ Json(payload): Json<ModelCreate>,
+ claims: Claims,
+ Path(model_id): Path<i32>,
+) -> Result<Json<ModelUser>, AppError> {
+ let model = match Model::find_by_id(model_id).await {
+ Ok(model) => model,
+ Err(_) => {
+ return Err(AppError::NotFound("Model not found".to_string()));
+ }
+ };
+
+ let user = User::find_by_id(claims.user_id).await?;
+
+ if !(model.author_id() == user.id || user.is_staff.unwrap()) {
+ return Err(AppError::Unauthorized);
+ }
+
+ let model_body = Model::new(
+ payload.name,
+ payload.description,
+ payload.duration,
+ payload.height,
+ payload.weight,
+ payload.printer,
+ payload.material,
+ claims.user_id,
+ );
+
+ // NOTE: can we edit this as same as `user.edit_avatar()`?
+ Model::edit(model.id, model_body).await?;
+ Ok(Json(model))
+}
+
+/// Upload a file for a model
+async fn upload_model_file(
+ claims: Claims,
+ Path(model_id): Path<i32>,
+ ContentLengthLimit(multipart): ContentLengthLimit<Multipart, { 1024 * 1024 * 40 }>,
+) -> Result<Json<ModelUpload>, AppError> {
+ let model = match Model::find_by_id(model_id).await {
+ Ok(model) => model,
+ Err(_) => {
+ return Err(AppError::NotFound("Model not found".to_string()));
+ }
+ };
+
+ let user = User::find_by_id(claims.user_id).await?;
+
+ if !(model.author_id() == user.id || user.is_staff.unwrap()) {
+ return Err(AppError::Unauthorized);
+ }
+
+ let allowed_extensions = vec![
+ "stl",
+ "obj",
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "webp",
+ "blend",
+ "octet-stream",
+ "sla",
+ ];
+
+ match upload(multipart, allowed_extensions, None).await {
+ Ok(saved_file) => {
+ let model_file = ModelUpload::create(ModelUpload::new(saved_file, model_id)).await?;
+
+ Ok(Json(model_file))
+ }
+ Err(e) => Err(e),
+ }
+}
+
+/// The owner or a staffer can delete a model upload
+async fn delete_model_file(
+ claims: Claims,
+ Path((model_id, upload_id)): Path<(i32, i32)>,
+) -> Result<StatusCode, AppError> {
+ let model = match Model::find_by_id(model_id).await {
+ Ok(model) => model,
+ Err(_) => {
+ return Err(AppError::NotFound("Model not found".to_string()));
+ }
+ };
+
+ let user = User::find_by_id(claims.user_id).await?;
+
+ if !(model.author_id() == user.id || user.is_staff.unwrap()) {
+ return Err(AppError::Unauthorized);
+ }
+
+ let upload = match ModelUpload::find_by_id(upload_id).await {
+ Ok(upload) => upload,
+ Err(_) => {
+ return Err(AppError::NotFound("Upload not found".to_string()));
+ }
+ };
+
+ if upload.model_id != model.id {
+ return Err(AppError::NotFound("Upload not found".to_string()));
+ }
+
+ let filepath = upload.filepath.clone();
+
+ match ModelUpload::delete(upload_id).await {
+ Ok(_) => {
+ delete_upload(&filepath)?;
+
+ Ok(StatusCode::NO_CONTENT)
+ }
+ Err(e) => Err(e),
+ }
+}
+
+/// Assign a like to a model from the Authorization user
+async fn add_like(claims: Claims, Path(model_id): Path<i32>) -> Result<StatusCode, AppError> {
+ let model = match Model::find_by_id(model_id).await {
+ Ok(model) => model,
+ Err(_) => {
+ return Err(AppError::NotFound("Model not found".to_string()));
+ }
+ };
+
+ let user = User::find_by_id(claims.user_id).await?;
+
+ let like = Like::new(user.id, model.id);
+
+ match like.save().await {
+ Ok(_) => Ok(StatusCode::CREATED),
+ Err(e) => {
+ return Err(e);
+ }
+ }
+}
+
+/// Remove a like from a model and an Authorization user
+async fn delete_like(claims: Claims, Path(model_id): Path<i32>) -> Result<StatusCode, AppError> {
+ let model = match Model::find_by_id(model_id).await {
+ Ok(model) => model,
+ Err(_) => {
+ return Err(AppError::NotFound("Model not found".to_string()));
+ }
+ };
+
+ let user = User::find_by_id(claims.user_id).await?;
+
+ let like = Like::new(user.id, model.id);
+
+ match like.remove().await {
+ Ok(_) => Ok(StatusCode::NO_CONTENT),
+ Err(e) => {
+ return Err(e);
+ }
+ }
+}
+
+/// Filter models
+async fn filter_models(
+ pagination: Query<Pagination>,
+ Json(payload): Json<ModelFilter>,
+) -> Result<Json<ModelPagination>, AppError> {
+ let page = pagination.0.page.unwrap_or_default();
+
+ let results = Model::filter(page, payload.q.clone()).await?;
+ let count = Model::count_filter(payload.q).await?;
+
+ Ok(Json(ModelPagination { count, results }))
+}