Skip to content

Commit

Permalink
feat: Use Backend for Recipe Post Actions (mealie-recipes#4163)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-genson authored and boc-the-git committed Sep 28, 2024
1 parent 4d64e7b commit 92e48ab
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 25 deletions.
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) {
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

0 comments on commit 92e48ab

Please sign in to comment.