Merge branch 'refactor/server-refactor' into 'main'

refactor/server

Closes #44

See merge request cse1105/2025-2026/teams/csep-team-76!41
This commit is contained in:
Oskar Rasieński 2026-01-08 02:38:00 +01:00
commit 6010208e33
10 changed files with 249 additions and 130 deletions

View file

@ -72,6 +72,26 @@ public class Ingredient {
this.id = id; this.id = id;
} }
public double getProteinPer100g() {
return proteinPer100g;
}
public double getFatPer100g() {
return fatPer100g;
}
public double getCarbsPer100g() {
return carbsPer100g;
}
public long getId() {
return id;
}
public String getName() {
return name;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false; if (o == null || getClass() != o.getClass()) return false;

View file

@ -1,17 +1,16 @@
package server; package server.api;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@Controller @RestController
@RequestMapping("/") @RequestMapping("/")
public class SomeController { public class HealthCheckController {
@GetMapping("/") @GetMapping("/")
@ResponseBody @ResponseBody
public String index() { public String index() {
return "Hello world!"; return "Server is online!";
} }
} }

View file

@ -5,8 +5,6 @@ import commons.ws.Topics;
import commons.ws.messages.CreateIngredientMessage; import commons.ws.messages.CreateIngredientMessage;
import commons.ws.messages.DeleteIngredientMessage; import commons.ws.messages.DeleteIngredientMessage;
import commons.ws.messages.UpdateIngredientMessage; import commons.ws.messages.UpdateIngredientMessage;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@ -19,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import server.database.IngredientRepository; import server.database.IngredientRepository;
import server.service.IngredientService;
import java.util.Optional; import java.util.Optional;
import java.util.List; import java.util.List;
@ -42,12 +41,12 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class IngredientController { public class IngredientController {
private final IngredientRepository ingredientRepository; private final IngredientService ingredientService;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
public IngredientController(IngredientRepository ingredientRepository, public IngredientController(IngredientService ingredientService,
SimpMessagingTemplate messagingTemplate) { SimpMessagingTemplate messagingTemplate) {
this.ingredientRepository = ingredientRepository; this.ingredientService = ingredientService;
this.messagingTemplate = messagingTemplate; this.messagingTemplate = messagingTemplate;
} }
@ -75,15 +74,9 @@ public class IngredientController {
@RequestParam Optional<Integer> page, @RequestParam Optional<Integer> page,
@RequestParam Optional<Integer> limit @RequestParam Optional<Integer> limit
) { ) {
List<Ingredient> ingredients = limit return limit
.map(l -> { .map(integer -> ResponseEntity.ok(ingredientService.findAll(page.orElse(0), integer)))
return ingredientRepository.findAllByOrderByNameAsc( .orElseGet(() -> ResponseEntity.ok(ingredientService.findAll()));
PageRequest.of(page.orElse(0), l)
).toList();
})
.orElseGet(ingredientRepository::findAllByOrderByNameAsc);
return ResponseEntity.ok(ingredients);
} }
/** /**
@ -102,7 +95,7 @@ public class IngredientController {
*/ */
@GetMapping("/ingredients/{id}") @GetMapping("/ingredients/{id}")
public ResponseEntity<Ingredient> getIngredientById(@PathVariable Long id) { public ResponseEntity<Ingredient> getIngredientById(@PathVariable Long id) {
return ingredientRepository.findById(id) return ingredientService.findById(id)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build()); .orElseGet(() -> ResponseEntity.notFound().build());
} }
@ -130,15 +123,13 @@ public class IngredientController {
@PathVariable Long id, @PathVariable Long id,
@RequestBody Ingredient updated @RequestBody Ingredient updated
) { ) {
if (!ingredientRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
updated.setId(id); updated.setId(id);
Ingredient savedIngredient = ingredientRepository.save(updated); return ingredientService.update(id, updated)
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new CreateIngredientMessage(savedIngredient)); .map(saved -> {
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new CreateIngredientMessage(saved));
return ResponseEntity.ok(savedIngredient); return ResponseEntity.ok(saved);
})
.orElseGet(() -> ResponseEntity.notFound().build());
} }
/** /**
@ -164,17 +155,12 @@ public class IngredientController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
Ingredient example = new Ingredient(); return ingredientService.create(ingredient)
example.name = ingredient.name; .map(saved -> {
if (ingredientRepository.existsById(ingredient.id) || ingredientRepository.exists(Example.of(example))) {
return ResponseEntity.badRequest().build();
}
Ingredient saved = ingredientRepository.save(ingredient);
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new UpdateIngredientMessage(saved)); messagingTemplate.convertAndSend(Topics.INGREDIENTS, new UpdateIngredientMessage(saved));
return ResponseEntity.ok(saved); return ResponseEntity.ok(saved);
})
.orElseGet(() -> ResponseEntity.badRequest().build());
} }
/** /**
@ -193,13 +179,11 @@ public class IngredientController {
*/ */
@DeleteMapping("/ingredients/{id}") @DeleteMapping("/ingredients/{id}")
public ResponseEntity<Boolean> deleteIngredient(@PathVariable Long id) { public ResponseEntity<Boolean> deleteIngredient(@PathVariable Long id) {
if (!ingredientRepository.existsById(id)) { if (!ingredientService.delete(id)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
ingredientRepository.deleteById(id);
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new DeleteIngredientMessage(id)); messagingTemplate.convertAndSend(Topics.INGREDIENTS, new DeleteIngredientMessage(id));
return ResponseEntity.ok(true); return ResponseEntity.ok(true);
} }
} }

View file

@ -7,8 +7,6 @@ import commons.ws.Topics;
import commons.ws.messages.CreateRecipeMessage; import commons.ws.messages.CreateRecipeMessage;
import commons.ws.messages.DeleteRecipeMessage; import commons.ws.messages.DeleteRecipeMessage;
import commons.ws.messages.UpdateRecipeMessage; import commons.ws.messages.UpdateRecipeMessage;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
@ -22,9 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import server.database.IngredientRepository; import server.service.RecipeService;
import server.database.RecipeIngredientRepository;
import server.database.RecipeRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -32,19 +28,12 @@ import java.util.Optional;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class RecipeController { public class RecipeController {
private final RecipeRepository recipeRepository; // JPA repository used in this controller
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
private final RecipeIngredientRepository recipeIngredientRepository; private final RecipeService recipeService;
private final IngredientRepository ingredientRepository;
public RecipeController(RecipeRepository recipeRepository, public RecipeController(RecipeService recipeService, SimpMessagingTemplate messagingTemplate) {
SimpMessagingTemplate messagingTemplate, this.recipeService = recipeService;
IngredientRepository ingredientRepository,
RecipeIngredientRepository recipeIngredientRepository) {
this.recipeRepository = recipeRepository;
this.messagingTemplate = messagingTemplate; this.messagingTemplate = messagingTemplate;
this.recipeIngredientRepository = recipeIngredientRepository;
this.ingredientRepository = ingredientRepository;
} }
/** /**
@ -57,10 +46,9 @@ public class RecipeController {
*/ */
@GetMapping("/recipe/{id}") @GetMapping("/recipe/{id}")
public ResponseEntity<Recipe> getRecipe(@PathVariable Long id) { public ResponseEntity<Recipe> getRecipe(@PathVariable Long id) {
if (!recipeRepository.existsById(id)) { return recipeService.findById(id)
return ResponseEntity.notFound().build(); .map(ResponseEntity::ok)
} .orElseGet(() -> ResponseEntity.notFound().build());
return ResponseEntity.ok(recipeRepository.findById(id).get());
} }
/** /**
@ -72,28 +60,12 @@ public class RecipeController {
*/ */
@GetMapping("/recipes") @GetMapping("/recipes")
public ResponseEntity<List<Recipe>> getRecipes(@RequestParam Optional<Integer> limit) { public ResponseEntity<List<Recipe>> getRecipes(@RequestParam Optional<Integer> limit) {
if (limit.isPresent()) {
return ResponseEntity.ok( return ResponseEntity.ok(
recipeRepository.findAll( // Choose the right overload. One has a limit, other doesn't.
PageRequest.of(0, limit.get()) limit.map(recipeService::findAll).orElseGet(recipeService::findAll)
).toList()); );
} }
return ResponseEntity.ok(recipeRepository.findAll());
}
private Recipe saveRecipeAndDependencies(Recipe recipe) {
recipe.getIngredients()
.forEach(recipeIngredient ->
recipeIngredient.setIngredient(
ingredientRepository
.findByName(recipeIngredient.getIngredient().name)
.orElseGet(() -> ingredientRepository.save(recipeIngredient.getIngredient())
))
);
recipeIngredientRepository.saveAll(recipe.getIngredients());
Recipe saved = recipeRepository.save(recipe);
return saved;
}
/** /**
* Mapping for <code>POST /recipe/{id}</code>. * Mapping for <code>POST /recipe/{id}</code>.
* Also creates the ingredient elements if they do not exist. * Also creates the ingredient elements if they do not exist.
@ -104,13 +76,12 @@ public class RecipeController {
*/ */
@PostMapping("/recipe/{id}") @PostMapping("/recipe/{id}")
public ResponseEntity<Recipe> updateRecipe(@PathVariable Long id, @RequestBody Recipe recipe) { public ResponseEntity<Recipe> updateRecipe(@PathVariable Long id, @RequestBody Recipe recipe) {
if (!recipeRepository.existsById(id)) { return recipeService.update(id, recipe)
return ResponseEntity.badRequest().build(); .map(saved -> {
} messagingTemplate.convertAndSend(Topics.RECIPES, new UpdateRecipeMessage(saved)); // Send to WS.
Recipe saved = saveRecipeAndDependencies(recipe);
messagingTemplate.convertAndSend(Topics.RECIPES, new UpdateRecipeMessage(saved));
return ResponseEntity.ok(saved); return ResponseEntity.ok(saved);
})
.orElseGet(() -> ResponseEntity.notFound().build()); // Recipe with that id not found.
} }
/** /**
@ -125,22 +96,12 @@ public class RecipeController {
*/ */
@PutMapping("/recipe/new") @PutMapping("/recipe/new")
public ResponseEntity<Recipe> createRecipe(@RequestBody Recipe recipe) { public ResponseEntity<Recipe> createRecipe(@RequestBody Recipe recipe) {
return recipeService.create(recipe)
// We initialize a new example recipe with the name of input recipe .map(saved -> {
// This is the only attribute we are concerned about making sure it's unique messagingTemplate.convertAndSend(Topics.RECIPES, new CreateRecipeMessage(saved)); // Send to WS.
Recipe example = new Recipe();
example.setName(recipe.getName());
/* Here we use very funny JPA magic repository.exists(Example<Recipe>)
We check if any recipe in the repository has the same name as the input
*/
if (recipeRepository.exists(Example.of(example))) {
return ResponseEntity.badRequest().build();
}
Recipe saved = saveRecipeAndDependencies(recipe);
messagingTemplate.convertAndSend(Topics.RECIPES, new CreateRecipeMessage(saved));
return ResponseEntity.ok(saved); return ResponseEntity.ok(saved);
})
.orElseGet(() -> ResponseEntity.badRequest().build()); // That recipe already exists.
} }
/** /**
@ -153,13 +114,10 @@ public class RecipeController {
*/ */
@DeleteMapping("/recipe/{id}") @DeleteMapping("/recipe/{id}")
public ResponseEntity<Boolean> deleteRecipe(@PathVariable Long id) { public ResponseEntity<Boolean> deleteRecipe(@PathVariable Long id) {
if (!recipeRepository.existsById(id)) { if (!recipeService.delete(id)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
recipeRepository.deleteById(id); messagingTemplate.convertAndSend(Topics.RECIPES, new DeleteRecipeMessage(id)); // Send to WS.
messagingTemplate.convertAndSend(Topics.RECIPES, new DeleteRecipeMessage(id));
return ResponseEntity.ok(true); return ResponseEntity.ok(true);
} }
} }

View file

@ -12,5 +12,7 @@ public interface IngredientRepository extends JpaRepository<Ingredient, Long> {
List<Ingredient> findAllByOrderByNameAsc(); List<Ingredient> findAllByOrderByNameAsc();
Page<Ingredient> findAllByOrderByNameAsc(Pageable pageable); Page<Ingredient> findAllByOrderByNameAsc(Pageable pageable);
Optional<Ingredient> findByName(String name); Optional<Ingredient> findByName(String name);
boolean existsByName(String name);
} }

View file

@ -19,4 +19,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
import commons.Recipe; import commons.Recipe;
public interface RecipeRepository extends JpaRepository<Recipe, Long> {} public interface RecipeRepository extends JpaRepository<Recipe, Long> {
boolean existsByName(String name);
}

View file

@ -0,0 +1,67 @@
package server.service;
import commons.Ingredient;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import server.database.IngredientRepository;
import java.util.List;
import java.util.Optional;
@Service
public class IngredientService {
IngredientRepository ingredientRepository;
public IngredientService(IngredientRepository ingredientRepository) {
this.ingredientRepository = ingredientRepository;
}
public Optional<Ingredient> findById(Long id) {
return ingredientRepository.findById(id);
}
public List<Ingredient> findAll() {
return ingredientRepository.findAllByOrderByNameAsc();
}
public List<Ingredient> findAll(int page, int limit) {
return ingredientRepository.findAllByOrderByNameAsc(PageRequest.of(page, limit)).toList();
}
/**
* Creates a new ingredient. Returns empty if the recipe with the same name or id already exists.
* @param ingredient Ingredient to be saved in the db.
* @return The created ingredient (the ingredient arg with a new assigned id) or empty if it already exists in db.
*/
public Optional<Ingredient> create(Ingredient ingredient) {
if (ingredientRepository.existsByName(ingredient.getName()) ||
ingredientRepository.existsById(ingredient.getId())) {
return Optional.empty();
}
return Optional.of(ingredientRepository.save(ingredient));
}
/**
* Updates an ingredient. The ingredient with the provided id will be replaced (in db) with the provided ingredient.
* @param id id of the ingredient to update.
* @param ingredient Ingredient to be saved in the db.
* @return The created ingredient (the ingredient arg with a new assigned id.)
*/
public Optional<Ingredient> update(Long id, Ingredient ingredient) {
assert id.equals(ingredient.getId()) :
"The id of the updated ingredient doesn't match the provided ingredient's id.";
if (!ingredientRepository.existsById(id)) {
return Optional.empty();
}
return Optional.of(ingredientRepository.save(ingredient));
}
public boolean delete(Long id) {
if (!ingredientRepository.existsById(id)) return false;
ingredientRepository.deleteById(id);
return true;
}
}

View file

@ -0,0 +1,89 @@
package server.service;
import commons.Recipe;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import server.database.IngredientRepository;
import server.database.RecipeIngredientRepository;
import server.database.RecipeRepository;
import java.util.List;
import java.util.Optional;
@Service
public class RecipeService {
RecipeRepository recipeRepository;
IngredientRepository ingredientRepository;
RecipeIngredientRepository recipeIngredientRepository;
public RecipeService(RecipeRepository recipeRepository,
IngredientRepository ingredientRepository,
RecipeIngredientRepository recipeIngredientRepository) {
this.recipeRepository = recipeRepository;
this.ingredientRepository = ingredientRepository;
this.recipeIngredientRepository = recipeIngredientRepository;
}
public Optional<Recipe> findById(Long id) {
return recipeRepository.findById(id);
}
public List<Recipe> findAll() {
return recipeRepository.findAll();
}
public List<Recipe> findAll(int limit) {
return recipeRepository.findAll(PageRequest.of(0, limit)).toList();
}
/**
* Creates a new recipe. Returns empty if the recipe with the same name already exists.
* @param recipe Recipe to be saved in the db.
* @return The created recipe (the recipe arg with a new assigned id) or empty if it already exists in db.
*/
public Optional<Recipe> create(Recipe recipe) {
if (recipeRepository.existsByName(recipe.getName())) {
return Optional.empty();
}
return Optional.of(saveWithDependencies(recipe));
}
/**
* Updates a recipe. The recipe with the provided id will be replaced with the provided recipe.
* Automatically updates ingredients and any dependencies of recipe.
* @param id id of the recipe to update.
* @param recipe Recipe to be saved in the db.
* @return The created recipe (the recipe arg with a new assigned id.)
*/
public Optional<Recipe> update(Long id, Recipe recipe) {
assert id.equals(recipe.getId()) : "The id of the updated recipe doesn't match the provided recipes id.";
if (!recipeRepository.existsById(id)) {
return Optional.empty();
}
return Optional.of(saveWithDependencies(recipe));
}
public boolean delete(Long id) {
// TODO: Propagate deletion to ingredients.
if (!recipeRepository.existsById(id)) return false;
recipeRepository.deleteById(id);
return true;
}
private Recipe saveWithDependencies(Recipe recipe) {
// TODO: try to automate this with JFX somehow.
recipe.getIngredients()
.forEach(recipeIngredient ->
recipeIngredient.setIngredient(
ingredientRepository
.findByName(recipeIngredient.getIngredient().name)
.orElseGet(() -> ingredientRepository.save(recipeIngredient.getIngredient())
))
);
recipeIngredientRepository.saveAll(recipe.getIngredients());
return recipeRepository.save(recipe);
}
}

View file

@ -11,6 +11,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import server.database.IngredientRepository; import server.database.IngredientRepository;
import server.WebSocketConfig; import server.WebSocketConfig;
import server.service.IngredientService;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@ -20,10 +21,11 @@ import java.util.stream.Stream;
@DataJpaTest @DataJpaTest
@ActiveProfiles("mock-data-test") @ActiveProfiles("mock-data-test")
@Import(WebSocketConfig.class) @Import({WebSocketConfig.class, IngredientService.class})
public class IngredientControllerTest { public class IngredientControllerTest {
private final SimpMessagingTemplate template; private final SimpMessagingTemplate template;
private final IngredientRepository ingredientRepository; private final IngredientRepository ingredientRepository;
private final IngredientService ingredientService;
private IngredientController controller; private IngredientController controller;
private static final double PROTEIN_BASE = 1.0; private static final double PROTEIN_BASE = 1.0;
@ -42,8 +44,10 @@ public class IngredientControllerTest {
@Autowired @Autowired
public IngredientControllerTest(IngredientRepository ingredientRepository, public IngredientControllerTest(IngredientRepository ingredientRepository,
IngredientService ingredientService,
SimpMessagingTemplate template) { SimpMessagingTemplate template) {
this.ingredientRepository = ingredientRepository; this.ingredientRepository = ingredientRepository;
this.ingredientService = ingredientService;
this.template = template; this.template = template;
} }
@ -59,7 +63,7 @@ public class IngredientControllerTest {
@BeforeEach @BeforeEach
public void setup() { public void setup() {
controller = new IngredientController(ingredientRepository, template); controller = new IngredientController(ingredientService, template);
this.createInitialIngredients(); this.createInitialIngredients();
} }

View file

@ -13,9 +13,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import server.WebSocketConfig; import server.WebSocketConfig;
import server.database.IngredientRepository;
import server.database.RecipeIngredientRepository;
import server.database.RecipeRepository; import server.database.RecipeRepository;
import server.service.RecipeService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -38,30 +37,27 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
// This is required to enable WebSocket messaging in tests // This is required to enable WebSocket messaging in tests
// //
// Without this line, Spring screams about missing SimpMessagingTemplate bean // Without this line, Spring screams about missing SimpMessagingTemplate bean
@Import(WebSocketConfig.class) @Import({WebSocketConfig.class, RecipeService.class})
public class RecipeControllerTest { public class RecipeControllerTest {
private final RecipeService recipeService;
private final RecipeRepository recipeRepository;
private final SimpMessagingTemplate template; private final SimpMessagingTemplate template;
private RecipeController controller; private RecipeController controller;
private List<Recipe> recipes; private List<Recipe> recipes;
private final RecipeRepository recipeRepository;
private List<Long> recipeIds; private List<Long> recipeIds;
public static final int NUM_RECIPES = 10; public static final int NUM_RECIPES = 10;
private final IngredientRepository ingredientRepository;
private final RecipeIngredientRepository recipeIngredientRepository;
// Injects a test repository into the test class // Injects a test repository into the test class
@Autowired @Autowired
public RecipeControllerTest( public RecipeControllerTest(
RecipeService recipeService,
RecipeRepository recipeRepository, RecipeRepository recipeRepository,
SimpMessagingTemplate template, SimpMessagingTemplate template
IngredientRepository ingredientRepository,
RecipeIngredientRepository recipeIngredientRepository
) { ) {
this.recipeService = recipeService;
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.template = template; this.template = template;
this.ingredientRepository = ingredientRepository;
this.recipeIngredientRepository = recipeIngredientRepository;
} }
@BeforeEach @BeforeEach
@ -71,10 +67,8 @@ public class RecipeControllerTest {
.mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of())) .mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of()))
.toList(); .toList();
controller = new RecipeController( controller = new RecipeController(
recipeRepository, recipeService,
template, template
ingredientRepository,
recipeIngredientRepository
); );
Set<String> tags = info.getTags(); Set<String> tags = info.getTags();
List<Long> ids = new ArrayList<>(); List<Long> ids = new ArrayList<>();