From bf66869ebf5ef45ef84c98c2fc0b3d7a11a05c0e Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Tue, 29 Oct 2024 21:21:14 +0100 Subject: [PATCH] [Added] Basis for Backend --- backend/.devcontainer/Dockerfile | 15 --- backend/.devcontainer/devcontainer.json | 18 ++- backend/Dockerfile | 26 ++++ backend/pom.xml | 11 ++ .../com/ninjaws/quiz/Config/AppConfig.java | 14 ++ .../quiz/Controllers/QuizController.java | 37 +++++ .../Exception/GlobalExceptionHandler.java | 20 +++ .../com/ninjaws/quiz/Models/ApiResponse.java | 21 +++ .../com/ninjaws/quiz/Models/Question.java | 59 ++++++++ .../java/com/ninjaws/quiz/Models/Request.java | 14 ++ .../java/com/ninjaws/quiz/Models/Session.java | 18 +++ .../com/ninjaws/quiz/QuizApplication.java | 2 + .../com/ninjaws/quiz/Services/ApiService.java | 23 ++++ .../ninjaws/quiz/Services/QuizService.java | 126 ++++++++++++++++++ .../src/main/resources/application.properties | 1 + backend/src/main/resources/static/index.html | 1 + docker-compose.yml | 2 + 17 files changed, 388 insertions(+), 20 deletions(-) delete mode 100644 backend/.devcontainer/Dockerfile create mode 100644 backend/Dockerfile create mode 100644 backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/Question.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/Request.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Models/Session.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java create mode 100644 backend/src/main/resources/static/index.html diff --git a/backend/.devcontainer/Dockerfile b/backend/.devcontainer/Dockerfile deleted file mode 100644 index deef895..0000000 --- a/backend/.devcontainer/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# Use an official OpenJDK as a parent image -FROM openjdk:17-jdk-slim - -# Set the working directory -WORKDIR /app - -# Install Maven -RUN apt-get update && apt-get install -y \ - maven=3.3.9-4 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN mvn clean package - -CMD ["tail","-f","/dev/null"] \ No newline at end of file diff --git a/backend/.devcontainer/devcontainer.json b/backend/.devcontainer/devcontainer.json index 3075d71..aead60f 100644 --- a/backend/.devcontainer/devcontainer.json +++ b/backend/.devcontainer/devcontainer.json @@ -2,13 +2,19 @@ "name": "Quiz Backend", "build": { - "context": ".", - "dockerfile": "Dockerfile", + "context": "..", + "dockerfile": "../Dockerfile", "args": { - "WORKDIR": "/app" - } + "WORKDIR": "/app", + // "UID": "${localEnv:USER_UID}", + // "GID": "${localEnv:USER_GID}" + }, + "target": "development" }, - "runArgs": ["--name=quiz-backend-dev-cont"], + "runArgs": [ + "--name=quiz-backend-dev-cont", + "--net=host" // Allows it to be opened on the host network + ], "workspaceFolder": "/app", "mounts": [ "source=.,target=/app,type=bind" @@ -17,6 +23,8 @@ "vscode": { "settings": {}, "extensions": [ + "vscjava.vscode-java-pack", + "Oracle.oracle-java" ] } } diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fc70432 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM openjdk:17-jdk-slim AS base +WORKDIR /app +RUN apt-get update && apt install -y \ + maven \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +#---Development +FROM base AS development +# Switching user so it doesn't use root +# If root adds files, it takes ownership, making them unremovable on the host except by using sudo +# node tranfers ownership to the host user +ENV UID=1000 +ENV GID=1000 +ENV USER=dev +RUN groupadd -g ${GID} ${USER} \ + && useradd -m -u ${UID} -g ${USER} ${USER} +USER ${USER} +EXPOSE 8080 +CMD ["tail","-f","/dev/null"] + +#---Building + + + +#---Production \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index 80df2a1..47ec3a2 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -55,6 +55,17 @@ spring-boot-starter-test test + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + + org.projectlombok + lombok + 1.18.34 + provided + diff --git a/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java b/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java new file mode 100644 index 0000000..a426b14 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Config/AppConfig.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.Config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class AppConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java new file mode 100644 index 0000000..6ab69c3 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Controllers/QuizController.java @@ -0,0 +1,37 @@ +package com.ninjaws.quiz.Controllers; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ninjaws.quiz.Models.Session; +import com.ninjaws.quiz.Services.QuizService; + +@RestController +@RequestMapping("/quiz") +public class QuizController { + + @Autowired + private QuizService quizService; + + @GetMapping("/questions") + public String startSession() { + // String sessionId = generateSessionId(); + String sessionId = quizService.createSession(); + return sessionId; + } + + @GetMapping("/questions/{sessionId}") + public Optional checkStatus(@PathVariable String sessionId) { + return quizService.checkSession(sessionId); + } + + // @PostMapping("/checkanswers") + // public Result checkAnswers(@RequestBody String answers) { + // return new Result(); + // } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..07d7a06 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Exception/GlobalExceptionHandler.java @@ -0,0 +1,20 @@ +package com.ninjaws.quiz.Exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +/** + * Determines behaviour when certain types of Exceptions are encountered + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(e.getMessage()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java b/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java new file mode 100644 index 0000000..0d1da5c --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/ApiResponse.java @@ -0,0 +1,21 @@ +package com.ninjaws.quiz.Models; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + + +/** + * The response sent by the external API + */ +@Getter +@Setter +public class ApiResponse { + @JsonProperty("response_code") + private int responseCode; + @JsonProperty("results") + private List results; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Question.java b/backend/src/main/java/com/ninjaws/quiz/Models/Question.java new file mode 100644 index 0000000..4efb9a9 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/Question.java @@ -0,0 +1,59 @@ +package com.ninjaws.quiz.Models; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; + +/** + * Holds the question, answers, and correct answer + * TODO: Should not send correct answer to the user + */ +// @Setter() +@Getter() +public class Question { + private String type; + private String difficulty; + private String category; + private String question; + + // @JsonIgnore + private String correctAnswer; + + // @JsonIgnore + private List incorrectAnswers; + + @JsonProperty("answers") + public List getAnswers() { + List possibleAnswers = new ArrayList<>(incorrectAnswers); + possibleAnswers.add(correctAnswer); + Collections.shuffle(possibleAnswers); + return possibleAnswers; + } + + @JsonProperty("correct_answer") + public void setCorrectAnswer(String correctAnswer) { + this.correctAnswer = correctAnswer; + } + + @JsonProperty("incorrect_answers") + public void setIncorrectAnswer(List incorrectAnswers) { + this.incorrectAnswers = incorrectAnswers; + } + + @JsonProperty("correct_answer") + @JsonIgnore + public String getCorrectAnswer() { + return correctAnswer; + } + + @JsonProperty("incorrect_answers") + @JsonIgnore + public List getIncorrectAnswers() { + return incorrectAnswers; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Request.java b/backend/src/main/java/com/ninjaws/quiz/Models/Request.java new file mode 100644 index 0000000..15205ef --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/Request.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.Models; + +import lombok.Getter; + +@Getter +public class Request { + private final String sessionId; + private final String requestUrl; + + public Request(String sessionId, String requestUrl) { + this.sessionId = sessionId; + this.requestUrl = requestUrl; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Models/Session.java b/backend/src/main/java/com/ninjaws/quiz/Models/Session.java new file mode 100644 index 0000000..f0f8ab5 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Models/Session.java @@ -0,0 +1,18 @@ +package com.ninjaws.quiz.Models; + +import java.util.List; +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Session { + private final String id; + private List questions; + + public Session() { + this.id = UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java b/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java index 722a567..809b4e1 100644 --- a/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java +++ b/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java @@ -2,8 +2,10 @@ package com.ninjaws.quiz; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class QuizApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java new file mode 100644 index 0000000..189f4b2 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Services/ApiService.java @@ -0,0 +1,23 @@ +package com.ninjaws.quiz.Services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class ApiService { + + private final RestTemplate restTemplate; + + @Autowired + public ApiService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public String requestQuestions(String url) { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String jsonData = response.getBody(); + return jsonData; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java new file mode 100644 index 0000000..53924fc --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/Services/QuizService.java @@ -0,0 +1,126 @@ +package com.ninjaws.quiz.Services; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.Models.ApiResponse; +import com.ninjaws.quiz.Models.Question; +import com.ninjaws.quiz.Models.Request; +import com.ninjaws.quiz.Models.Session; + + +@Service +public class QuizService { + + @Autowired + private ApiService apiService; + @Autowired + private ObjectMapper objectMapper; + + /** + * Stores all active sessions + * Gets added to when a user requests data + * Gets removed from when a user requests their score (by sending their answers) + * TODO: Replace with something that automatically removes items after a certain amount of time (Caffein?) + */ + private final Map sessions = new ConcurrentHashMap<>(); + + /** + * Stores all user requests for data + * Executes them in order, one every 5 seconds (the limit of the external API) + * Removes them from the Queue when the request was succesful + */ + private final BlockingQueue requests = new LinkedBlockingQueue<>(); + + /** + * Actions: + * - Creates a session with sessionId + * - Adds session to the cache + * - Creates request and adds it to the queue + * @return The sessionId + */ + public String createSession() { + Session session = new Session(); + sessions.put(session.getId(), session); // TODO: Either remove id from session, or maybe Caffein does allow them together + try { + requests.put(new Request(session.getId(), "https://opentdb.com/api.php?amount=10")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return session.getId(); + } + + /** + * @param sessionId + * Actions: + * - Looks up the session in the cache + * - Checks if the data from the request is stored + * @return The data if it's available, otherwise a statuscode (?) + */ + public Optional checkSession(String sessionId) throws IllegalArgumentException { + Session session = sessions.get(sessionId); + + if(session == null) { + throw new IllegalArgumentException("Invalid session ID: " + sessionId); + } + + if (session.getQuestions() == null) { + return Optional.empty(); + } + System.out.print(session.getQuestions().get(0).getIncorrectAnswers().get(0)); + return Optional.of(session); + } + + /** + * Every 5 seconds: + * - Take the top request from the queue (removes it) + * - Perform the request + * - Wait for the data + * - Find the matching session from the cache + * - Add the data to the session in the cache + */ + @Scheduled(fixedDelay = 5000) + protected void processQueue() { + Request request = requests.poll(); + if (request != null) { + String json = apiService.requestQuestions(request.getRequestUrl()); + try { + List questions = extractQuestions(json); + Session session = sessions.get(request.getSessionId()); + session.setQuestions(questions); + sessions.put(request.getSessionId(), session); + } catch (IOException e) { + //TODO: Log this + } + } + } + + private List extractQuestions(String json) throws IOException { + return objectMapper.readValue(json, ApiResponse.class).getResults(); + } + + /** + * Receives: sessionId and answers + * Actions: + * - retrieves session from cache + * - Deletes session from cache + * - Matches answers answers to correctAnswers + * - Calculates score + * Returns: calculated score + * */ + public void calculateScore() { + + } + +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b3b3a2f..8d23e20 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=quiz +server.port=9090 \ No newline at end of file diff --git a/backend/src/main/resources/static/index.html b/backend/src/main/resources/static/index.html new file mode 100644 index 0000000..4b255a9 --- /dev/null +++ b/backend/src/main/resources/static/index.html @@ -0,0 +1 @@ +

Quiz backend

\ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5765b4f..9e0016c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,5 +4,7 @@ services: context: ./frontend dockerfile: Dockerfile target: production + image: quiz-frontend-prod + container_name: quiz-frontend-prod-cont ports: - "4200:80" \ No newline at end of file