Add a RON API to search recipes by title + a bit of refactoring

This commit is contained in:
Greg Burri 2025-05-21 19:58:31 +02:00
parent a3f61e3711
commit 084f7ef445
26 changed files with 499 additions and 333 deletions

View file

@ -132,6 +132,10 @@ pub fn make_service(
// Disabled: update user profile is now made with a post data ('edit_user_post').
// .route("/user/update", put(services::ron::update_user))
.route("/lang", put(services::ron::set_lang))
.route(
"/recipe/search",
get(services::ron::recipe::search_by_title),
)
.route("/recipe/titles", get(services::ron::recipe::get_titles))
.route(
"/recipe/{id}/title",

View file

@ -1,7 +1,7 @@
use std::u32;
use chrono::prelude::*;
use common::ron_api::Difficulty;
use common::web_api::Difficulty;
use itertools::Itertools;
use sqlx::{Error, Sqlite};
@ -410,8 +410,8 @@ WHERE [Recipe].[user_id] = $1
sqlx::query_scalar(
r#"
SELECT [name], COUNT([name]) as [nb_used] FROM [Tag]
INNER JOIN [RecipeTag] ON [RecipeTag].[tag_id] = [Tag].[id]
INNER JOIN [Recipe] ON [Recipe].[id] = [RecipeTag].[recipe_id]
INNER JOIN [RecipeTag] ON [RecipeTag].[tag_id] = [Tag].[id]
INNER JOIN [Recipe] ON [Recipe].[id] = [RecipeTag].[recipe_id]
WHERE [Recipe].[lang] = $1
GROUP BY [Tag].[name]
ORDER BY [nb_used] DESC, [name]

View file

@ -1,5 +1,5 @@
use chrono::prelude::*;
use common::ron_api::Difficulty;
use common::web_api::Difficulty;
use sqlx::{self, FromRow};
#[derive(Debug, Clone, FromRow)]

View file

@ -3,7 +3,7 @@ use axum::{
http::{StatusCode, header},
response::{ErrorResponse, IntoResponse, Response},
};
use common::ron_api;
use common::web_api;
use ron::de::from_bytes;
use serde::{Serialize, de::DeserializeOwned};
@ -16,7 +16,7 @@ pub struct RonError {
impl axum::response::IntoResponse for RonError {
fn into_response(self) -> Response {
match ron_api::to_string(&self) {
match web_api::to_string(&self) {
Ok(ron_as_str) => (
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)],
@ -62,7 +62,7 @@ pub fn ron_response<T>(status: StatusCode, ron: T) -> Response
where
T: Serialize,
{
match ron_api::to_string(&ron) {
match web_api::to_string(&ron) {
Ok(ron_as_str) => (
status,
[(header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)],

View file

@ -4,7 +4,7 @@ use axum::{
extract::{Extension, Query, State},
response::{Html, IntoResponse},
};
use serde::Deserialize;
use common::web_api;
use crate::{
app::{Context, Result},
@ -12,15 +12,10 @@ use crate::{
html_templates::*,
};
#[derive(Deserialize)]
pub struct CurrentRecipeId {
current_recipe_id: Option<i64>,
}
#[debug_handler]
pub async fn recipes_list_fragments(
State(connection): State<db::Connection>,
current_recipe: Query<CurrentRecipeId>,
params: Query<web_api::RecipesListFragmentsParams>,
Extension(context): Extension<Context>,
) -> Result<impl IntoResponse> {
Ok(Html(
@ -29,7 +24,7 @@ pub async fn recipes_list_fragments(
connection,
&context.user,
context.tr.current_lang_code(),
current_recipe.current_recipe_id,
params.current_recipe_id,
)
.await?,
context,

View file

@ -6,7 +6,7 @@ use axum::{
middleware::Next,
response::{Html, IntoResponse, Response},
};
use serde::Deserialize;
use serde::{self, Deserialize};
use crate::{
app::{AppState, Context, Result},
@ -109,7 +109,7 @@ pub async fn dev_panel(
///// LOGS /////
#[derive(Deserialize)]
pub struct LogFile {
pub struct LogsParams {
#[serde(default)]
pub log_file: String,
}
@ -119,7 +119,7 @@ pub async fn logs(
State(connection): State<db::Connection>,
State(log): State<Log>,
Extension(context): Extension<Context>,
log_file: Query<LogFile>,
Query(params): Query<LogsParams>,
) -> Result<Response> {
if context.user.is_some() && context.user.as_ref().unwrap().is_admin {
Ok(Html(
@ -133,11 +133,11 @@ pub async fn logs(
.await?,
context,
current_log_file: match (
log_file.log_file.is_empty(),
params.log_file.is_empty(),
log.file_names().unwrap_or_default(),
) {
(true, file_names) if !file_names.is_empty() => file_names[0].clone(),
_ => log_file.log_file.clone(),
_ => params.log_file.clone(),
},
log,
}

View file

@ -20,10 +20,10 @@ use super::rights::*;
pub async fn get_scheduled_recipes(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
date_range: Query<common::ron_api::DateRange>,
date_range: Query<common::web_api::DateRange>,
) -> Result<impl IntoResponse> {
if let Some(user) = context.user {
Ok(ron_response_ok(common::ron_api::ScheduledRecipes {
Ok(ron_response_ok(common::web_api::ScheduledRecipes {
recipes: connection
.get_scheduled_recipes(user.id, date_range.start_date, date_range.end_date)
.await?,
@ -36,7 +36,7 @@ pub async fn get_scheduled_recipes(
}
}
impl From<data::db::recipe::AddScheduledRecipeResult> for common::ron_api::ScheduleRecipeResult {
impl From<data::db::recipe::AddScheduledRecipeResult> for common::web_api::ScheduleRecipeResult {
fn from(db_res: data::db::recipe::AddScheduledRecipeResult) -> Self {
match db_res {
db::recipe::AddScheduledRecipeResult::Ok => Self::Ok,
@ -51,7 +51,7 @@ impl From<data::db::recipe::AddScheduledRecipeResult> for common::ron_api::Sched
pub async fn add_scheduled_recipe(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
ExtractRon(ron): ExtractRon<common::ron_api::ScheduleRecipe>,
ExtractRon(ron): ExtractRon<common::web_api::ScheduleRecipe>,
) -> Result<Response> {
check_user_rights_recipe(&connection, &context.user, ron.recipe_id).await?;
if let Some(user) = context.user {
@ -65,7 +65,7 @@ pub async fn add_scheduled_recipe(
)
.await
.map(|res| {
ron_response_ok(common::ron_api::ScheduleRecipeResult::from(res)).into_response()
ron_response_ok(common::web_api::ScheduleRecipeResult::from(res)).into_response()
})
.map_err(ErrorResponse::from)
} else {
@ -77,7 +77,7 @@ pub async fn add_scheduled_recipe(
pub async fn rm_scheduled_recipe(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
ExtractRon(ron): ExtractRon<common::ron_api::RemoveScheduledRecipe>,
ExtractRon(ron): ExtractRon<common::web_api::RemoveScheduledRecipe>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe(&connection, &context.user, ron.recipe_id).await?;
if let Some(user) = context.user {

View file

@ -15,6 +15,7 @@ use crate::{
};
pub mod calendar;
mod model_converter;
pub mod recipe;
mod rights;
pub mod shopping_list;

View file

@ -0,0 +1,50 @@
use common::web_api;
use crate::data::model;
impl From<model::Group> for web_api::Group {
fn from(group: model::Group) -> Self {
Self {
id: group.id,
name: group.name,
comment: group.comment,
steps: group.steps.into_iter().map(web_api::Step::from).collect(),
}
}
}
impl From<model::Step> for web_api::Step {
fn from(step: model::Step) -> Self {
Self {
id: step.id,
action: step.action,
ingredients: step
.ingredients
.into_iter()
.map(web_api::Ingredient::from)
.collect(),
}
}
}
impl From<model::Ingredient> for web_api::Ingredient {
fn from(ingredient: model::Ingredient) -> Self {
Self {
id: ingredient.id,
name: ingredient.name,
comment: ingredient.comment,
quantity_value: ingredient.quantity_value,
quantity_unit: ingredient.quantity_unit,
}
}
}
impl From<model::RecipeSearchResult> for web_api::RecipeSearchResult {
fn from(result: model::RecipeSearchResult) -> Self {
Self {
recipe_id: result.id,
title: result.title,
title_highlighted: result.title_highlighted,
}
}
}

View file

@ -5,24 +5,39 @@ use axum::{
response::{IntoResponse, Result},
};
use axum_extra::extract::Query;
use common::ron_api;
use common::web_api;
use serde::Deserialize;
use tracing::warn;
use crate::{
app::Context, data::db, data::model, ron_extractor::ExtractRon, ron_utils::ron_response_ok,
};
use crate::{app::Context, data::db, ron_extractor::ExtractRon, ron_utils::ron_response_ok};
use super::rights::*;
#[debug_handler]
pub async fn search_by_title(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
Query(params): Query<web_api::SearchByTitleParams>,
) -> Result<impl IntoResponse> {
Ok(ron_response_ok(
connection
.search_recipes(context.tr.current_lang_code(), &params.search_term)
.await?
.into_iter()
.map(web_api::RecipeSearchResult::from)
.collect::<Vec<_>>(),
))
}
/// Ask recipe titles associated with each given id. The returned titles are in the same order
/// as the given ids.
#[debug_handler]
pub async fn get_titles(
State(connection): State<db::Connection>,
recipe_ids: Query<Vec<i64>>,
Query(params): Query<web_api::GetTitlesParams>,
) -> Result<impl IntoResponse> {
Ok(ron_response_ok(
connection.get_recipe_titles(&recipe_ids).await?,
connection.get_recipe_titles(&params.ids).await?,
))
}
@ -88,16 +103,23 @@ pub async fn get_tags(
))
}
#[derive(Deserialize)]
pub struct GetAllTagsParams {
nb_max_tags: Option<u32>,
lang: Option<String>,
}
#[debug_handler]
pub async fn get_all_tags(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
nb_max_tags: Query<Option<u32>>,
lang: Query<Option<String>>,
Query(params): Query<GetAllTagsParams>,
) -> Result<impl IntoResponse> {
let lang = lang.0.unwrap_or(context.tr.current_lang_code().to_string());
let lang = params
.lang
.unwrap_or(context.tr.current_lang_code().to_string());
Ok(ron_response_ok(
connection.get_all_tags(&lang, nb_max_tags.0).await?,
connection.get_all_tags(&lang, params.nb_max_tags).await?,
))
}
@ -130,7 +152,7 @@ pub async fn set_difficulty(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
Path(recipe_id): Path<i64>,
ExtractRon(difficulty): ExtractRon<ron_api::Difficulty>,
ExtractRon(difficulty): ExtractRon<web_api::Difficulty>,
) -> Result<StatusCode> {
check_user_rights_recipe(&connection, &context.user, recipe_id).await?;
connection
@ -184,43 +206,6 @@ pub async fn rm(
Ok(StatusCode::OK)
}
impl From<model::Group> for ron_api::Group {
fn from(group: model::Group) -> Self {
Self {
id: group.id,
name: group.name,
comment: group.comment,
steps: group.steps.into_iter().map(ron_api::Step::from).collect(),
}
}
}
impl From<model::Step> for ron_api::Step {
fn from(step: model::Step) -> Self {
Self {
id: step.id,
action: step.action,
ingredients: step
.ingredients
.into_iter()
.map(ron_api::Ingredient::from)
.collect(),
}
}
}
impl From<model::Ingredient> for ron_api::Ingredient {
fn from(ingredient: model::Ingredient) -> Self {
Self {
id: ingredient.id,
name: ingredient.name,
comment: ingredient.comment,
quantity_value: ingredient.quantity_value,
quantity_unit: ingredient.quantity_unit,
}
}
}
#[debug_handler]
pub async fn get_groups(
State(connection): State<db::Connection>,
@ -232,7 +217,7 @@ pub async fn get_groups(
.get_groups(recipe_id)
.await?
.into_iter()
.map(ron_api::Group::from)
.map(web_api::Group::from)
.collect::<Vec<_>>(),
))
}

View file

@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
response::{ErrorResponse, IntoResponse, Result},
};
use common::ron_api;
use common::web_api;
use crate::{
app::Context,
@ -17,7 +17,7 @@ use crate::{
use super::rights::*;
impl From<model::ShoppingListItem> for common::ron_api::ShoppingListItem {
impl From<model::ShoppingListItem> for common::web_api::ShoppingListItem {
fn from(item: model::ShoppingListItem) -> Self {
Self {
id: item.id,
@ -43,7 +43,7 @@ pub async fn get(
.get_shopping_list(user.id)
.await?
.into_iter()
.map(common::ron_api::ShoppingListItem::from)
.map(common::web_api::ShoppingListItem::from)
.collect::<Vec<_>>(),
))
} else {
@ -58,7 +58,7 @@ pub async fn get(
pub async fn set_entry_checked(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
ExtractRon(ron): ExtractRon<ron_api::KeyValue<bool>>,
ExtractRon(ron): ExtractRon<web_api::KeyValue<bool>>,
) -> Result<impl IntoResponse> {
check_user_rights_shopping_list_entry(&connection, &context.user, ron.id).await?;
Ok(ron_response_ok(

View file

@ -42,10 +42,10 @@
<label for="select-difficulty">{{ context.tr.t(Sentence::RecipeDifficulty) }}</label>
<select id="select-difficulty">
<option value="0" {%~ call is_difficulty(common::ron_api::Difficulty::Unknown) %}> - </option>
<option value="1" {%~ call is_difficulty(common::ron_api::Difficulty::Easy) %}>{{ context.tr.t(Sentence::RecipeDifficultyEasy) }}</option>
<option value="2" {%~ call is_difficulty(common::ron_api::Difficulty::Medium) %}>{{ context.tr.t(Sentence::RecipeDifficultyMedium) }}</option>
<option value="3" {%~ call is_difficulty(common::ron_api::Difficulty::Hard) %}>{{ context.tr.t(Sentence::RecipeDifficultyHard) }}</option>
<option value="0" {%~ call is_difficulty(common::web_api::Difficulty::Unknown) %}> - </option>
<option value="1" {%~ call is_difficulty(common::web_api::Difficulty::Easy) %}>{{ context.tr.t(Sentence::RecipeDifficultyEasy) }}</option>
<option value="2" {%~ call is_difficulty(common::web_api::Difficulty::Medium) %}>{{ context.tr.t(Sentence::RecipeDifficultyMedium) }}</option>
<option value="3" {%~ call is_difficulty(common::web_api::Difficulty::Hard) %}>{{ context.tr.t(Sentence::RecipeDifficultyHard) }}</option>
</select>
<div id="container-tags">

View file

@ -39,12 +39,12 @@
<span class="difficulty">
{% match recipe.difficulty %}
{% when common::ron_api::Difficulty::Unknown %}
{% when common::ron_api::Difficulty::Easy %}
{% when common::web_api::Difficulty::Unknown %}
{% when common::web_api::Difficulty::Easy %}
{{ context.tr.t(Sentence::RecipeDifficultyEasy) }}
{% when common::ron_api::Difficulty::Medium %}
{% when common::web_api::Difficulty::Medium %}
{{ context.tr.t(Sentence::RecipeDifficultyMedium) }}
{% when common::ron_api::Difficulty::Hard %}
{% when common::web_api::Difficulty::Hard %}
{{ context.tr.t(Sentence::RecipeDifficultyHard) }}
{% endmatch %}
</span>

View file

@ -2,7 +2,7 @@ use std::{error::Error, sync::Arc};
use axum::http;
use axum_test::TestServer;
use common::ron_api;
use common::web_api;
use cookie::Cookie;
use scraper::{ElementRef, Html, Selector};
use serde::Serialize;
@ -221,7 +221,13 @@ async fn sign_in() -> Result<(), Box<dyn Error>> {
// Assert.
response.assert_status_see_other(); // Redirection after successful sign in.
response.assert_text("");
response.assert_header("location", "/?user_message=16&user_message_icon=0");
response.assert_header(
"location",
format!(
"/?user_message={}&user_message_icon=0",
common::translation::Sentence::SignInSuccess as i64
),
);
Ok(())
}
@ -260,7 +266,7 @@ async fn create_recipe_and_edit_it() -> Result<(), Box<dyn Error>> {
.patch(&format!("/ron-api/recipe/{recipe_id}/title"))
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(ron_api::to_string("AAA").unwrap().into())
.bytes(web_api::to_string("AAA").unwrap().into())
.await;
response.assert_status_ok();
@ -268,7 +274,7 @@ async fn create_recipe_and_edit_it() -> Result<(), Box<dyn Error>> {
.patch(&format!("/ron-api/recipe/{recipe_id}/description"))
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(ron_api::to_string("BBB").unwrap().into())
.bytes(web_api::to_string("BBB").unwrap().into())
.await;
response.assert_status_ok();
@ -276,7 +282,7 @@ async fn create_recipe_and_edit_it() -> Result<(), Box<dyn Error>> {
.patch(&format!("/ron-api/recipe/{recipe_id}/servings"))
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(ron_api::to_string(Some(42)).unwrap().into())
.bytes(web_api::to_string(Some(42)).unwrap().into())
.await;
response.assert_status_ok();
@ -284,7 +290,7 @@ async fn create_recipe_and_edit_it() -> Result<(), Box<dyn Error>> {
.patch(&format!("/ron-api/recipe/{recipe_id}/estimated_time"))
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(ron_api::to_string(Some(420)).unwrap().into())
.bytes(web_api::to_string(Some(420)).unwrap().into())
.await;
response.assert_status_ok();
@ -293,7 +299,7 @@ async fn create_recipe_and_edit_it() -> Result<(), Box<dyn Error>> {
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(
ron_api::to_string(ron_api::Difficulty::Hard)
web_api::to_string(web_api::Difficulty::Hard)
.unwrap()
.into(),
)
@ -304,7 +310,7 @@ async fn create_recipe_and_edit_it() -> Result<(), Box<dyn Error>> {
.patch(&format!("/ron-api/recipe/{recipe_id}/language"))
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(ron_api::to_string("fr").unwrap().into())
.bytes(web_api::to_string("fr").unwrap().into())
.await;
response.assert_status_ok();
@ -312,7 +318,7 @@ async fn create_recipe_and_edit_it() -> Result<(), Box<dyn Error>> {
.patch(&format!("/ron-api/recipe/{recipe_id}/is_public"))
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(ron_api::to_string(true).unwrap().into())
.bytes(web_api::to_string(true).unwrap().into())
.await;
response.assert_status_ok();
@ -399,7 +405,7 @@ async fn recipe_tags() -> Result<(), Box<dyn Error>> {
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(
ron_api::to_string(vec!["ABC".to_string(), "xyz".to_string()])
web_api::to_string(vec!["ABC".to_string(), "xyz".to_string()])
.unwrap()
.into(),
)
@ -424,7 +430,7 @@ async fn recipe_tags() -> Result<(), Box<dyn Error>> {
.add_cookie(cookie.clone())
.add_header(http::header::CONTENT_TYPE, common::consts::MIME_TYPE_RON)
.bytes(
ron_api::to_string(vec!["XYZ".to_string(), "qwe".to_string()])
web_api::to_string(vec!["XYZ".to_string(), "qwe".to_string()])
.unwrap()
.into(),
)

View file

@ -21,6 +21,8 @@
(DatabaseError, "Database error"),
(TemplateError, "Template error"),
(SearchPlaceholder, "Search by title"),
(SignInMenu, "Sign in"),
(SignInTitle, "Sign in"),
(SignInButton, "Sign in"),

View file

@ -21,6 +21,8 @@
(DatabaseError, "Erreur de la base de données (Database error)"),
(TemplateError, "Erreur du moteur de modèles (Template error)"),
(SearchPlaceholder, "Recherche par titre"),
(SignInMenu, "Se connecter"),
(SignInTitle, "Se connecter"),
(SignInButton, "Se connecter"),