diff --git a/README.md b/README.md index 207b9da..5c69052 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ # DevOps W07 In-Class Exercise Template -This repository contains a full-stack application with a SvelteKit client and a Spring Boot server. It demonstrates modern web application architecture and DevOps practices. +This repository contains a full-stack canteen application with a SvelteKit client, Spring Boot server, and LLM recommendation service. It demonstrates modern web application architecture and DevOps practices. ## Project Overview This project includes: -- **Client**: SvelteKit with TypeScript, TailwindCSS, and reusable UI components. -- **Server**: Spring Boot Java application with RESTful APIs. -- **DevOps**: Dockerized services, CI/CD pipelines, and production-ready deployment configurations. +- **Client**: SvelteKit with TypeScript, TailwindCSS, and reusable UI components for browsing canteen meals. +- **Server**: Spring Boot Java application with RESTful APIs, gRPC communication, and PostgreSQL integration. +- **LLM Service**: Python FastAPI service for generating meal recommendations using AI. +- **Database**: PostgreSQL for storing user preferences and application data. +- **DevOps**: Dockerized services, CI/CD pipelines, Helm charts, and production-ready deployment configurations. ## Prerequisites - Node.js (v22 or later) - Java JDK 21+ +- Python 3.x - Gradle - Docker and Docker Compose - Git +- Kubernetes and Helm (for Kubernetes deployment) ## Setup Instructions @@ -48,8 +52,25 @@ cd w07-solution ./gradlew build ``` +### LLM Service Setup + +1. Navigate to the `llm` directory: + ```bash + cd llm + ``` +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + ## Running the Application +### Start the Database + +```bash +docker compose up database -d +``` + ### Start the Client ```bash @@ -66,6 +87,14 @@ cd server ``` The server API will be available at [http://localhost:8080](http://localhost:8080). +### Start the LLM Service + +```bash +cd llm +python main.py +``` +The LLM service will be available at [http://localhost:5000](http://localhost:5000). + ## Development Workflow ### Client Development @@ -73,14 +102,24 @@ The server API will be available at [http://localhost:8080](http://localhost:808 - Built with SvelteKit and TypeScript for a modern, reactive UI. - TailwindCSS for styling. - Components and routes are organized in the `src` directory. +- Features meal browsing, favoriting, and user preferences. ### Server Development - Built with Spring Boot for scalable and maintainable server services. +- Includes gRPC communication with the LLM service. +- PostgreSQL integration for user preferences storage. +- RESTful APIs for canteen data and user management. - Gradle is used for dependency management and building. - Source code is in the `src/main/java` directory. - Tests are in the `src/test/java` directory. +### LLM Service Development + +- Built with FastAPI for AI-powered meal recommendations. +- Integrates with external LLM APIs for generating personalized suggestions. +- Source code is in the `llm` directory. + ## Building for Production ### Client Build @@ -103,13 +142,31 @@ The project includes Docker configurations for containerized deployment. ### Build and Run with Docker Compose -1. Build and start the services: +1. Build and start all services: ```bash docker compose up --build ``` 2. Access the application: - Client: [http://localhost:3000](http://localhost:3000) - Server: [http://localhost:8080](http://localhost:8080) + - LLM Service: [http://localhost:5000](http://localhost:5000) + - Database: PostgreSQL on port 5432 + +## Kubernetes Deployment + +The project includes Helm charts for Kubernetes deployment. + +### Deploy with Helm + +1. Update the `tumid` value in [`helm/canteen-app/values.yaml`](helm/canteen-app/values.yaml): + ```yaml + tumid: your-tum-id + ``` + +2. Install the Helm chart: + ```bash + helm install canteen ./helm/canteen-app + ``` ## CI/CD Pipeline @@ -122,18 +179,31 @@ The project includes GitHub Actions workflows for: ``` ├── client/ # SvelteKit client │ ├── src/ # Source code -│ ├── public/ # Static assets +│ ├── static/ # Static assets │ └── package.json # Client dependencies │ ├── server/ # Spring Boot server -│ ├── src/ # Source code +│ ├── src/ # Source code including gRPC services │ ├── build.gradle # Gradle build file │ └── Dockerfile # Server Dockerfile │ +├── llm/ # Python LLM service +│ ├── main.py # FastAPI application +│ ├── requirements.txt # Python dependencies +│ └── Dockerfile # LLM service Dockerfile +│ +├── helm/ # Kubernetes Helm charts +│ └── canteen-app/ # Main application chart +│ +├── docs/ # API documentation (Bruno collection) ├── compose.yml # Docker Compose for local development └── .github/workflows/ # CI/CD workflows ``` +## API Documentation + +API documentation is available in the [`docs/CanteenApp Bruno`](docs/CanteenApp%20Bruno) directory as a Bruno collection for testing endpoints. + ## License This project is licensed under the MIT License. \ No newline at end of file diff --git a/client/src/lib/env.ts b/client/src/lib/env.ts new file mode 100644 index 0000000..cda89cd --- /dev/null +++ b/client/src/lib/env.ts @@ -0,0 +1,3 @@ +import { env } from '$env/dynamic/public'; + +export let BaseURL = env.PUBLIC_API_URL || "http://localhost:8080/api"; \ No newline at end of file diff --git a/client/src/lib/index.ts b/client/src/lib/index.ts index 856f2b6..40800d8 100644 --- a/client/src/lib/index.ts +++ b/client/src/lib/index.ts @@ -1 +1,2 @@ // place files you want to import through the `$lib` alias in this folder. +export { getCookie, setCookie } from './utils'; diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index aef99a5..49ea78a 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2,4 +2,10 @@ export type Meal = { name: string; dish_type: string; labels: string[]; + favorite: boolean; }; + +export type UserPreferences = { + username: string; + favoriteMeals: string[]; +}; \ No newline at end of file diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts new file mode 100644 index 0000000..cb3ba37 --- /dev/null +++ b/client/src/lib/utils.ts @@ -0,0 +1,13 @@ +export function getCookie(name: string): string | null { + if (typeof document === 'undefined') return null; + return document.cookie + .split('; ') + .find(row => row.startsWith(name + '=')) + ?.split('=')[1] || null; +} + +export function setCookie(name: string, value: string, days: number) { + if (typeof document === 'undefined') return; + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; +} diff --git a/client/src/routes/+page.svelte b/client/src/routes/+page.svelte index 35ca410..e248842 100644 --- a/client/src/routes/+page.svelte +++ b/client/src/routes/+page.svelte @@ -1,8 +1,6 @@
@@ -90,7 +25,7 @@ {:else}
{#each meals as meal} - + {/each}
{/if} diff --git a/client/src/routes/+page.ts b/client/src/routes/+page.ts index 06493b6..56bb597 100644 --- a/client/src/routes/+page.ts +++ b/client/src/routes/+page.ts @@ -1,14 +1,40 @@ import type { PageLoad } from './$types'; -import { env } from '$env/dynamic/public'; +import { BaseURL} from '$lib/env'; +import type { Meal, UserPreferences } from '$lib/types'; +import { getCookie, setCookie } from '$lib'; export const ssr = false; -let baseUrl = env.PUBLIC_API_URL || "http://localhost:8080/api"; - +//get the username from the cookie +let username: string | null = null; export const load: PageLoad = async ({ fetch }) => { - const res = await fetch(`${baseUrl}/mensa-garching/today`); - const meals = await res.json(); + username = getCookie('username'); - return { meals }; + // Prompt for username if not found in cookie + if (!username) { + username = prompt('Please enter your username:'); + if (username) { + setCookie('username', username, 30); // Store for 30 days + } else { + // Handle case where user cancels or enters empty string + username = 'anonymous'; + } + } + + const res = await fetch(`${BaseURL}/mensa-garching/today`); + const meals: Meal[] = await res.json(); + + const res2 = await fetch(`${BaseURL}/preferences/${username}`); + const preferences: UserPreferences = await res2.json(); + + console.log('Meals:', meals); + console.log('Preferences:', preferences); + + // Set the boolean favorite property for each meal + meals.forEach((meal: any) => { + meal.favorite = preferences.favoriteMeals.includes(meal.name); + }); + + return { meals}; }; \ No newline at end of file diff --git a/client/src/routes/FoodCard.svelte b/client/src/routes/FoodCard.svelte index dd20dd8..c829411 100644 --- a/client/src/routes/FoodCard.svelte +++ b/client/src/routes/FoodCard.svelte @@ -1,55 +1,61 @@
-

{meal.name}

-

{meal.dish_type}

+
+
+

{meal.name}

+

{meal.dish_type}

+
+ + +
{#if meal.labels.includes("VEGETARIAN")} @@ -100,21 +106,4 @@ {/if}
- - -
- - \ No newline at end of file + \ No newline at end of file diff --git a/docs/CanteenApp Bruno/Create preference.bru b/docs/CanteenApp Bruno/Create preference.bru new file mode 100644 index 0000000..2af0241 --- /dev/null +++ b/docs/CanteenApp Bruno/Create preference.bru @@ -0,0 +1,15 @@ +meta { + name: Create preference + type: http + seq: 3 +} + +post { + url: http://localhost:8080/api/preferences/test?meal=Pommes + body: none + auth: inherit +} + +params:query { + meal: Pommes +} diff --git a/docs/CanteenApp Bruno/Get preference.bru b/docs/CanteenApp Bruno/Get preference.bru new file mode 100644 index 0000000..53bc0b3 --- /dev/null +++ b/docs/CanteenApp Bruno/Get preference.bru @@ -0,0 +1,11 @@ +meta { + name: Get preference + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/preferences/test + body: none + auth: inherit +} diff --git a/docs/CanteenApp Bruno/Get today's meals.bru b/docs/CanteenApp Bruno/Get today's meals.bru new file mode 100644 index 0000000..edb4350 --- /dev/null +++ b/docs/CanteenApp Bruno/Get today's meals.bru @@ -0,0 +1,11 @@ +meta { + name: Get today's meals + type: http + seq: 1 +} + +get { + url: http://localhost:8080/api/mensa-garching/today + body: none + auth: inherit +} diff --git a/docs/CanteenApp Bruno/bruno.json b/docs/CanteenApp Bruno/bruno.json new file mode 100644 index 0000000..c90ed8c --- /dev/null +++ b/docs/CanteenApp Bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "CanteenApp Bruno", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/server/src/main/java/de/tum/aet/devops25/w07/UserPreferenceRepository.java b/server/src/main/java/de/tum/aet/devops25/w07/UserPreferenceRepository.java index 0bca9bb..6a225a9 100644 --- a/server/src/main/java/de/tum/aet/devops25/w07/UserPreferenceRepository.java +++ b/server/src/main/java/de/tum/aet/devops25/w07/UserPreferenceRepository.java @@ -1,10 +1,8 @@ package de.tum.aet.devops25.w07; +import de.tum.aet.devops25.w07.model.UserPreferences; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; -import java.util.Optional; - /* Required for interacting with the database. A Spring Data JPA interface that provides ready-to-use database operations. diff --git a/server/src/main/java/de/tum/aet/devops25/w07/controller/CanteenController.java b/server/src/main/java/de/tum/aet/devops25/w07/controller/CanteenController.java index 73b57fd..c4be1bd 100644 --- a/server/src/main/java/de/tum/aet/devops25/w07/controller/CanteenController.java +++ b/server/src/main/java/de/tum/aet/devops25/w07/controller/CanteenController.java @@ -23,7 +23,7 @@ public CanteenController(CanteenService canteenService) { * @param canteenName the ID of the canteen (e.g., "mensa-garching") * @return list of dishes available today at the specified canteen */ - @GetMapping("/api/{canteenName}/today") + @GetMapping("/{canteenName}/today") public ResponseEntity> getTodayMeals(@PathVariable("canteenName") String canteenName) { List todayMeals = canteenService.getTodayMeals(canteenName); diff --git a/server/src/main/java/de/tum/aet/devops25/w07/controller/MealRecommendationController.java b/server/src/main/java/de/tum/aet/devops25/w07/controller/MealRecommendationController.java index 67ce148..045205a 100644 --- a/server/src/main/java/de/tum/aet/devops25/w07/controller/MealRecommendationController.java +++ b/server/src/main/java/de/tum/aet/devops25/w07/controller/MealRecommendationController.java @@ -1,6 +1,6 @@ package de.tum.aet.devops25.w07.controller; -import de.tum.aet.devops25.w07.UserPreferences; +import de.tum.aet.devops25.w07.model.UserPreferences; import de.tum.aet.devops25.w07.model.Dish; import de.tum.aet.devops25.w07.service.CanteenService; import de.tum.aet.devops25.w07.service.LLMRecommendationService; diff --git a/server/src/main/java/de/tum/aet/devops25/w07/UserPreferenceController.java b/server/src/main/java/de/tum/aet/devops25/w07/controller/UserPreferenceController.java similarity index 57% rename from server/src/main/java/de/tum/aet/devops25/w07/UserPreferenceController.java rename to server/src/main/java/de/tum/aet/devops25/w07/controller/UserPreferenceController.java index bf4e724..b90000c 100644 --- a/server/src/main/java/de/tum/aet/devops25/w07/UserPreferenceController.java +++ b/server/src/main/java/de/tum/aet/devops25/w07/controller/UserPreferenceController.java @@ -1,10 +1,9 @@ -package de.tum.aet.devops25.w07; +package de.tum.aet.devops25.w07.controller; +import de.tum.aet.devops25.w07.model.UserPreferences; import de.tum.aet.devops25.w07.service.UserPreferenceService; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/preferences") public class UserPreferenceController { @@ -21,14 +20,13 @@ public UserPreferences getPreferences(@PathVariable String name) { return userPreferenceService.getPreferences(name); } - @PostMapping - public UserPreferences addPreference(@RequestBody UserPreferences preference) { - return userPreferenceService.addPreferences(preference); + @PostMapping("/{name}") + public UserPreferences addPreference(@PathVariable String name, @RequestParam String meal) { + return userPreferenceService.addPreferences(name, meal); } - @PostMapping("/saveFavorites") - public UserPreferences saveFavorites(@RequestBody UserPreferences preference) { - return userPreferenceService.saveFavorites(preference); // Save favorites through the service + @DeleteMapping("/{name}") + public UserPreferences removePreference(@PathVariable String name, @RequestParam String meal) { + return userPreferenceService.removePreference(name, meal); } - } diff --git a/server/src/main/java/de/tum/aet/devops25/w07/UserPreferences.java b/server/src/main/java/de/tum/aet/devops25/w07/model/UserPreferences.java similarity index 58% rename from server/src/main/java/de/tum/aet/devops25/w07/UserPreferences.java rename to server/src/main/java/de/tum/aet/devops25/w07/model/UserPreferences.java index f4182a8..5f993d0 100644 --- a/server/src/main/java/de/tum/aet/devops25/w07/UserPreferences.java +++ b/server/src/main/java/de/tum/aet/devops25/w07/model/UserPreferences.java @@ -1,4 +1,4 @@ -package de.tum.aet.devops25.w07; +package de.tum.aet.devops25.w07.model; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; @@ -7,34 +7,34 @@ import java.util.List; -/* - Needed to define the database structure - JPA Entity that maps to a table in PostgreSQL. Each instance represents a user's preference (like favorite meal) - */ - @Entity @Table(name = "user_preferences") public class UserPreferences { - @Id - private String name; //using name as primary student identifier + private String name; + @ElementCollection - private List favoriteMeals; // List of favorite meals + private List favoriteMeals; + + // Default constructor required by JPA + public UserPreferences() {} + + // Constructor for convenience + public UserPreferences(String name, List favoriteMeals) { + this.name = name; + this.favoriteMeals = favoriteMeals; + } + // Getters and setters - public String getName() { return name; } - + public void setName(String name) { this.name = name; } - + public List getFavoriteMeals() { return favoriteMeals; } - - public void setFavoriteMeals(List favoriteMeals) { - this.favoriteMeals = favoriteMeals; - } } diff --git a/server/src/main/java/de/tum/aet/devops25/w07/service/UserPreferenceService.java b/server/src/main/java/de/tum/aet/devops25/w07/service/UserPreferenceService.java index 4bac8d9..6093b95 100644 --- a/server/src/main/java/de/tum/aet/devops25/w07/service/UserPreferenceService.java +++ b/server/src/main/java/de/tum/aet/devops25/w07/service/UserPreferenceService.java @@ -1,9 +1,11 @@ package de.tum.aet.devops25.w07.service; import de.tum.aet.devops25.w07.UserPreferenceRepository; -import de.tum.aet.devops25.w07.UserPreferences; +import de.tum.aet.devops25.w07.model.UserPreferences; import org.springframework.stereotype.Service; +import java.util.List; + @Service public class UserPreferenceService { @@ -14,14 +16,52 @@ public UserPreferenceService(UserPreferenceRepository repository) { } public UserPreferences getPreferences(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Name cannot be null or empty"); + } return repository.findById(name).orElse(null); } - public UserPreferences addPreferences(UserPreferences userPreference) { - return repository.save(userPreference); + + public UserPreferences addPreferences(String name, String meal) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Name cannot be null or empty"); + } + if (meal == null || meal.trim().isEmpty()) { + throw new IllegalArgumentException("Meal cannot be null or empty"); + } + + return repository.findById(name) + .map(userPreferences -> { + // Avoid duplicate meals + if (!userPreferences.getFavoriteMeals().contains(meal)) { + userPreferences.getFavoriteMeals().add(meal); + return repository.save(userPreferences); + } + return userPreferences; // Return existing without saving if duplicate + }) + .orElseGet(() -> { + UserPreferences newPreferences = new UserPreferences(name, List.of(meal)); + return repository.save(newPreferences); + }); } - public UserPreferences saveFavorites(UserPreferences preference) { - return repository.save(preference); // Save the updated favorites list + public UserPreferences removePreference(String name, String meal) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Name cannot be null or empty"); + } + if (meal == null || meal.trim().isEmpty()) { + throw new IllegalArgumentException("Meal cannot be null or empty"); + } + + return repository.findById(name) + .map(userPreferences -> { + if (userPreferences.getFavoriteMeals().contains(meal)) { + userPreferences.getFavoriteMeals().remove(meal); + return repository.save(userPreferences); + } + return userPreferences; // Return existing if meal not found + }) + .orElse(null); // Return null if user preferences not found } } diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties index 2839837..f7a1e63 100644 --- a/server/src/main/resources/application.properties +++ b/server/src/main/resources/application.properties @@ -1,5 +1,6 @@ spring.application.name=in-class-exercise server.port=8080 +server.servlet.context-path=/api spring.jackson.date-format=yyyy-MM-dd spring.jackson.serialization.write-dates-as-timestamps=false diff --git a/server/src/test/java/de/tum/aet/devops25/w07/CanteenControllerTest.java b/server/src/test/java/de/tum/aet/devops25/w07/CanteenControllerTest.java index 9cf811a..2d050bc 100644 --- a/server/src/test/java/de/tum/aet/devops25/w07/CanteenControllerTest.java +++ b/server/src/test/java/de/tum/aet/devops25/w07/CanteenControllerTest.java @@ -64,7 +64,7 @@ public void testGetTodayMeals_ReturnsNoContent_WhenNoMealsAvailable() throws Exc when(canteenService.getTodayMeals("mensa-garching")).thenReturn(List.of()); // Act & Assert - getList("/api/{canteenName}/today", HttpStatus.NO_CONTENT, Dish.class, "mensa-garching"); + getList("/{canteenName}/today", HttpStatus.NO_CONTENT, Dish.class, "mensa-garching"); } @Test @@ -79,7 +79,7 @@ public void testGetTodayMeals_ReturnsOkWithMeals() throws Exception { when(canteenService.getTodayMeals("mensa-garching")).thenReturn(expectedDishes); // Act - List actualTodayDishes = getList("/api/{canteenName}/today", HttpStatus.OK, Dish.class, "mensa-garching"); + List actualTodayDishes = getList("/{canteenName}/today", HttpStatus.OK, Dish.class, "mensa-garching"); // Assert assertThat(actualTodayDishes).hasSize(2);