Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Use Backend for Recipe Post Actions #4163

Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/docs/documentation/getting-started/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ Unlike notifiers, which are event-driven notifications, Webhooks allow you to se

Recipe Actions are custom actions you can add to all recipes in Mealie. This is a great way to add custom integrations that are fired manually. There are two types of recipe actions:

1. link - these actions will take you directly to an external page
1. link - these actions will take you directly to an external page. Merge fields can be used to customize the URL for each recipe
2. post - these actions will send a `POST` request to the specified URL, with the recipe JSON in the request body. These can be used, for instance, to manually trigger a webhook in Home Assistant

Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug:
When using the "link" action type, Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug:
```
https://www.google.com/search?q=${slug}
```
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/Domain/Recipe/RecipeContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ export default defineComponent({
const response = await groupRecipeActionsStore.execute(action, props.recipe);

if (action.actionType === "post") {
if (!response || (response.status >= 200 && response.status < 300)) {
if (!response?.error) {
michael-genson marked this conversation as resolved.
Show resolved Hide resolved
alert.success(i18n.tc("events.message-sent"));
} else {
alert.error(i18n.tc("events.something-went-wrong"));
Expand Down
18 changes: 4 additions & 14 deletions frontend/composables/use-group-recipe-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { computed, reactive, ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "./partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { GroupRecipeActionOut, GroupRecipeActionType } from "~/lib/api/types/household";
import { RequestResponse } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";

const groupRecipeActions = ref<GroupRecipeActionOut[] | null>(null);
Expand Down Expand Up @@ -54,26 +55,15 @@ export const useGroupRecipeActions = function (
/* eslint-enable no-template-curly-in-string */
};

async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | Response> {
async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | RequestResponse<unknown>> {
const url = parseRecipeActionUrl(action.url, recipe);

switch (action.actionType) {
case "link":
window.open(url, "_blank")?.focus();
break;
return;
case "post":
return await fetch(url, {
method: "POST",
headers: {
// The "text/plain" content type header is used here to skip the CORS preflight request,
// since it may fail. This is fine, since we don't care about the response, we just want
// the request to get sent.
"Content-Type": "text/plain",
},
body: JSON.stringify(recipe),
}).catch((error) => {
console.error(error);
});
return await api.groupRecipeActions.triggerAction(action.id, recipe.slug || "");
default:
break;
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/lib/api/types/household.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ export interface GroupRecipeActionOut {
householdId: string;
id: string;
}
export interface GroupRecipeActionPayload {
action: GroupRecipeActionOut;
content: unknown;
}
export interface HouseholdCreate {
groupId?: string | null;
name: string;
Expand Down
5 changes: 5 additions & 0 deletions frontend/lib/api/user/group-recipe-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ const prefix = "/api";
const routes = {
groupRecipeActions: `${prefix}/households/recipe-actions`,
groupRecipeActionsId: (id: string | number) => `${prefix}/households/recipe-actions/${id}`,
groupRecipeActionsIdTriggerRecipeSlug: (id: string | number, recipeSlug: string) => `${prefix}/households/recipe-actions/${id}/trigger/${recipeSlug}`,
};

export class GroupRecipeActionsAPI extends BaseCRUDAPI<CreateGroupRecipeAction, GroupRecipeActionOut> {
baseRoute = routes.groupRecipeActions;
itemRoute = routes.groupRecipeActionsId;

async triggerAction(id: string | number, recipeSlug: string) {
return await this.requests.post(routes.groupRecipeActionsIdTriggerRecipeSlug(id, recipeSlug), {});
}
}
49 changes: 48 additions & 1 deletion mealie/routes/households/controller_group_recipe_actions.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
from functools import cached_property

from fastapi import APIRouter, Depends
import requests
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from pydantic import UUID4

from mealie.core.exceptions import NoEntryFound
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.household.group_recipe_action import (
CreateGroupRecipeAction,
GroupRecipeActionOut,
GroupRecipeActionPagination,
GroupRecipeActionPayload,
GroupRecipeActionType,
SaveGroupRecipeAction,
)
from mealie.schema.response import ErrorResponse
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.recipe.recipe_service import RecipeService

router = APIRouter(prefix="/households/recipe-actions", tags=["Households: Recipe Actions"])

Expand All @@ -27,6 +34,9 @@ def repo(self):
def mixins(self):
return HttpRepo[CreateGroupRecipeAction, GroupRecipeActionOut, SaveGroupRecipeAction](self.repo, self.logger)

# ==================================================================================================================
# CRUD

@router.get("", response_model=GroupRecipeActionPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all(
Expand All @@ -53,3 +63,40 @@ def update_one(self, item_id: UUID4, data: SaveGroupRecipeAction):
@router.delete("/{item_id}", response_model=GroupRecipeActionOut)
def delete_one(self, item_id: UUID4):
return self.mixins.delete_one(item_id)

# ==================================================================================================================
# Actions

@router.post("/{item_id}/trigger/{recipe_slug}", status_code=202)
def trigger_action(self, item_id: UUID4, recipe_slug: str, bg_tasks: BackgroundTasks) -> None:
recipe_action = self.repos.group_recipe_actions.get_one(item_id)
if not recipe_action:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=ErrorResponse.respond(message="Not found."),
)

if recipe_action.action_type == GroupRecipeActionType.post.value:
task_action = requests.post
else:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse.respond(message=f'Cannot trigger action type "{recipe_action.action_type}".'),
)

recipe_service = RecipeService(self.repos, self.user, self.household, translator=self.translator)
try:
recipe = recipe_service.get_one(recipe_slug)
except NoEntryFound as e:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=ErrorResponse.respond(message="Not found."),
) from e

payload = GroupRecipeActionPayload(action=recipe_action, content=recipe)
bg_tasks.add_task(
task_action,
url=recipe_action.url,
json=jsonable_encoder(payload.model_dump()),
timeout=15,
)
2 changes: 2 additions & 0 deletions mealie/schema/household/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
CreateGroupRecipeAction,
GroupRecipeActionOut,
GroupRecipeActionPagination,
GroupRecipeActionPayload,
GroupRecipeActionType,
SaveGroupRecipeAction,
)
Expand Down Expand Up @@ -75,6 +76,7 @@
"CreateGroupRecipeAction",
"GroupRecipeActionOut",
"GroupRecipeActionPagination",
"GroupRecipeActionPayload",
"GroupRecipeActionType",
"SaveGroupRecipeAction",
"CreateWebhook",
Expand Down
13 changes: 13 additions & 0 deletions mealie/schema/household/group_recipe_action.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from enum import Enum
from typing import Any

from pydantic import UUID4, ConfigDict

from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase

# ==================================================================================================================
# CRUD


class GroupRecipeActionType(Enum):
link = "link"
Expand All @@ -31,3 +35,12 @@ class GroupRecipeActionOut(SaveGroupRecipeAction):

class GroupRecipeActionPagination(PaginationBase):
items: list[GroupRecipeActionOut]


# ==================================================================================================================
# Actions


class GroupRecipeActionPayload(MealieModel):
action: GroupRecipeActionOut
content: Any
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
from uuid import UUID, uuid4

import pytest
import requests
from fastapi.testclient import TestClient

from mealie.schema.household.group_recipe_action import (
CreateGroupRecipeAction,
GroupRecipeActionOut,
GroupRecipeActionType,
SaveGroupRecipeAction,
)
from mealie.schema.recipe.recipe import Recipe
from tests.utils import api_routes, assert_deserialize
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser


def new_link_action() -> CreateGroupRecipeAction:
@pytest.fixture(autouse=True)
def mock_requests_post(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: None)


def create_action(action_type: GroupRecipeActionType = GroupRecipeActionType.link) -> CreateGroupRecipeAction:
return CreateGroupRecipeAction(
action_type=GroupRecipeActionType.link,
action_type=action_type,
title=random_string(),
url=random_string(),
)


def save_action(
user: TestUser, action_type: GroupRecipeActionType = GroupRecipeActionType.link
) -> SaveGroupRecipeAction:
action = create_action(action_type)
return action.cast(SaveGroupRecipeAction, group_id=UUID(user.group_id), household_id=UUID(user.household_id))


def new_recipe(user: TestUser) -> Recipe:
return Recipe(
user_id=user.user_id,
group_id=UUID(user.group_id),
name=random_string(),
)


def test_group_recipe_actions_create_one(api_client: TestClient, unique_user: TestUser):
action_in = new_link_action()
action_in = create_action()
response = api_client.post(
api_routes.households_recipe_actions,
json=action_in.model_dump(),
Expand All @@ -42,7 +67,7 @@ def test_group_recipe_actions_get_all(api_client: TestClient, unique_user: TestU
for _ in range(random_int(3, 5)):
response = api_client.post(
api_routes.households_recipe_actions,
json=new_link_action().model_dump(),
json=create_action().model_dump(),
headers=unique_user.token,
)
data = assert_deserialize(response, 201)
Expand All @@ -59,7 +84,7 @@ def test_group_recipe_actions_get_all(api_client: TestClient, unique_user: TestU
def test_group_recipe_actions_get_one(
api_client: TestClient, unique_user: TestUser, g2_user: TestUser, is_own_group: bool
):
action_in = new_link_action()
action_in = create_action()
response = api_client.post(
api_routes.households_recipe_actions,
json=action_in.model_dump(),
Expand Down Expand Up @@ -87,7 +112,7 @@ def test_group_recipe_actions_get_one(


def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: TestUser):
action_in = new_link_action()
action_in = create_action()
response = api_client.post(
api_routes.households_recipe_actions,
json=action_in.model_dump(),
Expand All @@ -110,7 +135,7 @@ def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: Te


def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: TestUser):
action_in = new_link_action()
action_in = create_action()
response = api_client.post(
api_routes.households_recipe_actions,
json=action_in.model_dump(),
Expand All @@ -124,3 +149,46 @@ def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: Te

response = api_client.get(api_routes.households_recipe_actions_item_id(action_id), headers=unique_user.token)
assert response.status_code == 404


@pytest.mark.parametrize("missing_action", [True, False])
@pytest.mark.parametrize("missing_recipe", [True, False])
def test_group_recipe_actions_trigger_post(
api_client: TestClient, unique_user: TestUser, missing_action: bool, missing_recipe: bool
):
if missing_action:
action_id = uuid4()
else:
recipe_action = unique_user.repos.group_recipe_actions.create(
save_action(unique_user, GroupRecipeActionType.post)
)
action_id = recipe_action.id

if missing_recipe:
recipe_slug = random_string()
else:
recipe = unique_user.repos.recipes.create(new_recipe(unique_user))
recipe_slug = recipe.slug

response = api_client.post(
api_routes.households_recipe_actions_item_id_trigger_recipe_slug(action_id, recipe_slug),
headers=unique_user.token,
)

if missing_action or missing_recipe:
assert response.status_code == 404
else:
# we don't test if the request was actually made, just that the endpoint was hit and accepted
assert response.status_code == 202


def test_group_recipe_actions_trigger_invalid_type(api_client: TestClient, unique_user: TestUser):
recipe_action = unique_user.repos.group_recipe_actions.create(save_action(unique_user, GroupRecipeActionType.link))
recipe = unique_user.repos.recipes.create(new_recipe(unique_user))

response = api_client.post(
api_routes.households_recipe_actions_item_id_trigger_recipe_slug(recipe_action.id, recipe.id),
headers=unique_user.token,
)

assert response.status_code == 400
5 changes: 5 additions & 0 deletions tests/utils/api_routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,11 @@ def households_recipe_actions_item_id(item_id):
return f"{prefix}/households/recipe-actions/{item_id}"


def households_recipe_actions_item_id_trigger_recipe_slug(item_id, recipe_slug):
"""`/api/households/recipe-actions/{item_id}/trigger/{recipe_slug}`"""
return f"{prefix}/households/recipe-actions/{item_id}/trigger/{recipe_slug}"


def households_shopping_items_item_id(item_id):
"""`/api/households/shopping/items/{item_id}`"""
return f"{prefix}/households/shopping/items/{item_id}"
Expand Down
Loading