From 675bdc7f8e33b7b1fe96aab78d8c6eb510bee457 Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Fri, 1 Nov 2024 17:02:24 +0100 Subject: [PATCH] [Updated] Finished Frontend, Added statushandling to backend and frontend [Todo: session token handling at lvl 2] --- .../quiz/controllers/QuizController.java | 9 +- .../com/ninjaws/quiz/models/QuizSettings.java | 5 -- .../com/ninjaws/quiz/services/ApiService.java | 43 ++++----- .../ninjaws/quiz/services/CleanerService.java | 14 ++- .../ninjaws/quiz/services/QueueService.java | 10 ++- .../ninjaws/quiz/services/QuizService.java | 22 +++-- .../services/ResponseHandlingService.java | 83 ++++++++++++++++++ .../quiz/services/SchedulerService.java | 38 ++------ frontend/public/question.ico | Bin 0 -> 10462 bytes frontend/src/app/Quiz/models/session.ts | 7 ++ .../src/app/Quiz/services/cleaner.service.ts | 33 +++++++ .../src/app/Quiz/services/quiz.service.ts | 39 ++++---- .../Quiz/services/response-handler.service.ts | 36 ++++++++ .../src/app/Quiz/setup/setup.component.ts | 6 +- frontend/src/app/app.component.html | 2 - frontend/src/index.html | 2 +- 16 files changed, 247 insertions(+), 102 deletions(-) create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java create mode 100644 frontend/public/question.ico create mode 100644 frontend/src/app/Quiz/models/session.ts create mode 100644 frontend/src/app/Quiz/services/cleaner.service.ts create mode 100644 frontend/src/app/Quiz/services/response-handler.service.ts diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java index 758af5c..69b89b8 100644 --- a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -31,8 +31,13 @@ public class QuizController { @RequestParam(required = false) String type ) { QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type); - String jsonString = String.format("{\"sessionId\":\"%s\"}", quizService.createSession(quizSettings)); - return jsonString; + Optional result = quizService.createSession(quizSettings); + if(result.isPresent()) { + String jsonString = String.format("{\"statusCode\":0,\"sessionId\":\"%s\"}", result.get()); + return jsonString; + } + String jsonString = String.format("{\"statusCode\":5}"); + return jsonString; } @GetMapping("/status/{sessionId}") diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java index a0321e5..9ed2ff4 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -59,11 +59,6 @@ public class QuizSettings { /** Inverse of toApiQuery */ public QuizSettings(int amount, Integer category, String difficulty, String type) { - System.out.println(amount); - System.out.print(category); - System.out.print(difficulty); - System.out.print(type); - this.amount = amount; this.category = category == null ? Category.ANY : Category.values()[category]; this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase()); diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java index aa096bb..b91bf88 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -1,21 +1,32 @@ package com.ninjaws.quiz.services; +import java.io.IOException; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; +import com.ninjaws.quiz.models.ApiResponse; import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Request; import jakarta.validation.Valid; @Service public class ApiService { + @Autowired + private CleanerService cleanerService; + @Autowired + private ResponseHandlingService responseHandler; @Autowired private RestTemplate restTemplate; - private final String apiUrl = "https://opentdb.com/api.php"; + private static final System.Logger logger = System.getLogger(ApiService.class.getName()); + + private final String apiUrl = "https://opentdb.com/api.php"; public ApiService() { } @@ -24,25 +35,17 @@ public class ApiService { return apiUrl + quizSettings.toApiQuery(); } - public String requestQuestions(@Valid QuizSettings quizSettings) { - String url = buildRequestString(quizSettings); - ResponseEntity response = restTemplate.getForEntity(url, String.class); - String jsonData = response.getBody(); - - // try { - // int responseCode = getResponseCode(jsonData); - // /** When the code has expired or you've ran out of questions, update the token and try again */ - // if(responseCode == 3 || responseCode == 4) { - // requestSessionToken(); - // requestQuestions(quizSettings); - // } - // } catch (Exception e) { - // // Failed to get a responseCode, meaning the request failed completely (they always send a response_code) - // } - return jsonData; - } - - public void handleStatusCode(int statusCode) { + public void handleRequest(@Valid Request request) { + String url = buildRequestString(request.getQuizSettings()); + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + ApiResponse apiResponse = cleanerService.extractApiResponse(json); + responseHandler.handleResponse(request.getSessionId(), apiResponse); + + } catch (RestClientException | IOException | IllegalStateException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java index 0e5544c..5385f67 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java @@ -1,16 +1,22 @@ package com.ninjaws.quiz.services; +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.ninjaws.quiz.models.QuizSettings; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.models.ApiResponse; @Service public class CleanerService { + @Autowired + private ObjectMapper objectMapper; + public CleanerService() {} - public QuizSettings jsonToQuizSettings(String json){ - QuizSettings quizSettings = new QuizSettings(); - return quizSettings; + public ApiResponse extractApiResponse(String json) throws IOException { + return objectMapper.readValue(json, ApiResponse.class); } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java index 9bda6b1..4568b69 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java @@ -15,10 +15,14 @@ public class QueueService { * 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<>(); + private final BlockingQueue requests = new LinkedBlockingQueue<>(5); - public void addToQueue(Request request) { - requests.add(request); + /** + * Offer automatically doesn't add it to the queue if it is full, so no Exceptions thrown + * @return Whether it's possible to add the request to the queue + */ + public boolean addToQueue(Request request) { + return requests.offer(request); } public Request popFromQueue() { diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index 5d3975f..0b22c30 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -27,12 +27,17 @@ public class QuizService { * @param quizSettings The values set by the user, determining the kind of quiz they want * @return The sessionId */ - public String createSession(QuizSettings quizSettings) { + public Optional createSession(QuizSettings quizSettings) { Session session = new Session(); cacheService.addToCache(session.getId(), session); - queueService.addToQueue(new Request(session.getId(), quizSettings)); + boolean addedToQueue = queueService.addToQueue(new Request(session.getId(), quizSettings)); + /** In this order, because doing the queue before the cache could lead to race conditions (request completion immediately calls cache, but then the item doesn't exist) */ + if(!addedToQueue) { + cacheService.removeFromCache(session.getId()); + return Optional.empty(); + } - return session.getId(); + return Optional.of(session.getId()); } /** @@ -49,9 +54,16 @@ public class QuizService { throw new IllegalArgumentException("Invalid session ID: " + sessionId); } + /** Still waiting for a response */ if (session.getQuestions() == null) { return Optional.empty(); } + + /** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */ + if(session.getStatusCode() != 0) { + cacheService.removeFromCache(session.getId()); + } + return Optional.of(session); } @@ -76,10 +88,6 @@ public class QuizService { for(int i = 0; i < session.getQuestions().size(); i++) { String correctAnswer = session.getQuestions().get(i).getCorrectAnswer(); String userAnswer = answers.getAnswers().get(i); - // for(String answer : session.getQuestions().get(i).getAnswers()) { - // System.out.print(answer); - // } - if (correctAnswer.equalsIgnoreCase(userAnswer)) { amountCorrect++; } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java new file mode 100644 index 0000000..e642039 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -0,0 +1,83 @@ +package com.ninjaws.quiz.services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.Session; + +@Service +public class ResponseHandlingService { + + @Autowired + private CacheService cacheService; + + /** + * If there is no sessionId, then this is a request from the scheduler + * purely to stock in the database No Cache call is required + */ + public void handleResponse(String sessionId, ApiResponse response) throws IllegalStateException { + + Session session = cacheService.getFromCache(sessionId); + if (session == null) { + // Critical system failure: Should never occur + throw new IllegalStateException("Session should be defined!"); + } + + switch (response.getResponseCode()) { + case 0: + // Nothing + updateSessionInCache(session, response); + break; + case 1: + // Not enough questions to satisfy the request. + updateSessionInCache(session, response); + break; + case 2: + // Invalid parameters + updateSessionInCache(session, response); + break; + case 3: + // If it expired. Shouldn't happen since the backup database is full within 40 minutes, and the code lasts for 6 hours + // Return a statuscode 1 to signal an issue, just in case a user hits this situation (should not happen) + session.setStatusCode(1); + updateSessionInCache(session, response); + case 4: + // It returns nothing, the previous batch was the last one + // Return a statuscode 1 to the user + session.setStatusCode(1); + updateSessionInCache(session, response); + break; + case 5: + // Should be prevented by the scheduler + default: + break; + } + } + + /** + * If the request was made by the scheduler during queue-downtime, there is no session + */ + public void switchInternal(ApiResponse response) { + switch (response.getResponseCode()) { + case 0: + // TODO: Store in DB + break; + case 4: + // Signal DataService that we have extracted all the data. Stop gathering, switch to caching-mode + // TODO: Signal DB that it's full, start db-swapping process + break; + case 5: + // Should be prevented by the scheduler + default: + break; + } + } + + private void updateSessionInCache(Session session, ApiResponse response) { + session.setStatusCode(response.getResponseCode()); + session.setQuestions(response.getResults()); + cacheService.addToCache(session.getId(), session); + } + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java index 4813f68..a57f3c9 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java @@ -1,17 +1,10 @@ package com.ninjaws.quiz.services; -import java.io.IOException; -import java.util.List; - 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 SchedulerService { @@ -20,40 +13,21 @@ public class SchedulerService { private QueueService queueService; @Autowired private ApiService apiService; - @Autowired - private CacheService cacheService; - @Autowired - private ObjectMapper objectMapper; - - private static final System.Logger logger = System.getLogger(SchedulerService.class.getName()); public SchedulerService() {} /** * 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 + * - Check if there is a request in queue + * - Yes: Send it to the apiService + * - No: Have the apiService do bulk gathering for the DB */ @Scheduled(fixedDelay = 5000) protected void processQueue() { Request request = queueService.popFromQueue(); if (request != null) { - String json = apiService.requestQuestions(request.getQuizSettings()); - try { - List questions = extractQuestions(json); - Session session = cacheService.getFromCache(request.getSessionId()); - session.setQuestions(questions); - cacheService.addToCache(request.getSessionId(), session); - } catch (IOException | NullPointerException e) { - logger.log(System.Logger.Level.ERROR, "Critical failure", e); - } + apiService.handleRequest(request); } - } - - private List extractQuestions(String json) throws IOException { - return objectMapper.readValue(json, ApiResponse.class).getResults(); - } + } + } diff --git a/frontend/public/question.ico b/frontend/public/question.ico new file mode 100644 index 0000000000000000000000000000000000000000..a27a1978a9c14373712b03787ac656357c601168 GIT binary patch literal 10462 zcmdU#O^6&t6vyAV>iB`Khmecth>D7!VnB?dY%YRV6+DUu4I00q7a-*%u6NK6_YrL;A_zhiy@bN`{JG^`qPreUN?cLkB@E`D3>(f@VZ;6- zDb51d6=>49>agLy0){q<>MHH1VZUnJU;zrlzz%X%^0vRU85(Um4tZ$tGYn=F^!29{>z1O`7%?#WDn4Z0{1mz3!A1W7em8YP)b#_?xa+G1}xuClO-pELM&g<8RQ=O z#kX>BaBhv|Wu*DwJG;eqUA7wTyE!>xmT~uEdGnz8_@g?0{A!(HZLNNN=(|f=o6B=) z>%O@A*~obrgF$@wE)1M!TFdss*sDJu`p!=yGKjw1EF+Dj;V~6|#*vx#$t7kG{-G=e z#*)}#`47sew451pOzC?0zV8?_%%DQweop4OQr1;D*N$$#dwt)+lD>D+n4u^R&iOpgah+xTm%}$kzVn4) zmJ4Z4a*X>eXKTay9_rqs@Lo9GYnl0N37l^t-a_Nwp8Ai6nE(3vMM^|ht?-5gh};rC$OK03sw zU&WS4w6}-!(OHHvh~Atm2c;iNo^u0z!h49`p6~H);tuR{Iaiq(#6}JlTl%n6iyvMC zpZtD)MS=aE-k$H#cClZ3tv)w{*vZIpv3uQ)mh1P!em4t`)z4N}Kec7ycg{b9?Wf1Kwz+N=)npLG<*ApT@z(PR4;{Nke54Z1I!L0B**c5}>f_%_II z{TXz+Z;G8s$O2d<&LD9z$TBqsgDg{LFu+n8hA~)5$1nm*X&G{`l%7FY()Y?$ z+eEW0H;Jk&H;86fHu3-PSb7z{kg{f)bO4ed=RE2V>T4dMI}~_??oi+nxSoN*BroO?PT`g~NL*AJr~l#xU5bmuRpKmhPxA0Dcu%Bx literal 0 HcmV?d00001 diff --git a/frontend/src/app/Quiz/models/session.ts b/frontend/src/app/Quiz/models/session.ts new file mode 100644 index 0000000..64801c1 --- /dev/null +++ b/frontend/src/app/Quiz/models/session.ts @@ -0,0 +1,7 @@ +import { Question } from "./question"; + +export interface Session { + id: string; + statusCode: number; + questions: Question[]; +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/services/cleaner.service.ts b/frontend/src/app/Quiz/services/cleaner.service.ts new file mode 100644 index 0000000..8dbf81b --- /dev/null +++ b/frontend/src/app/Quiz/services/cleaner.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { Question } from '../models/question'; + +@Injectable({ + providedIn: 'root' +}) +export class CleanerService { + + constructor() { } + + /** + * Sets decoded strings for the questions and the answers, so the special characters will be shown instead of their codes + */ + public cleanQuestionStrings(questions: Question[]): Question[] { + for(let i = 0; i < questions.length; i++) { + questions[i].question_decoded = this.decodeString(questions[i].question); + questions[i].answers_decoded = []; + for(let j = 0; j < questions[i].answers.length; j++){ + questions[i].answers_decoded!.push(this.decodeString(questions[i].answers[j])); + } + } + return questions; + } + + /** Puts special characters back into the string, like ' and " */ + private decodeString(text: string) { + const txt = document.createElement("textarea"); + txt.innerHTML = text; + const decodedValue = txt.value; + txt.remove(); // Clean up the temporary element + return decodedValue; + } +} diff --git a/frontend/src/app/Quiz/services/quiz.service.ts b/frontend/src/app/Quiz/services/quiz.service.ts index ca5e2b8..67d1da2 100644 --- a/frontend/src/app/Quiz/services/quiz.service.ts +++ b/frontend/src/app/Quiz/services/quiz.service.ts @@ -16,6 +16,8 @@ import { import { Question } from '../models/question'; import { Answers } from '../models/answers'; import { StorageService } from './storage.service'; +import { CleanerService } from './cleaner.service'; +import { ResponseHandlerService } from './response-handler.service'; @Injectable({ providedIn: 'root', @@ -23,13 +25,22 @@ import { StorageService } from './storage.service'; export class QuizService { private apiUrl = 'http://localhost:9090/quiz'; - constructor(private http: HttpClient, private storageService: StorageService) {} + constructor( + private http: HttpClient, + private storageService: StorageService, + private cleanerService: CleanerService, + private reponseHandler: ResponseHandlerService) {} - public async startSession(quizParams: QuizParams) { + public async startSession(quizParams: QuizParams): Promise { const sessionIdObj = await firstValueFrom(this.http.get(this.apiUrl + '/questions' + this.convertParamsToUrl(quizParams))); + /** No need to poll if the session is not created. Likely means the queue is full */ + if(sessionIdObj.statusCode !== 0) { + return this.reponseHandler.handleResponse(sessionIdObj.statusCode); + } this.storageService.setSession(sessionIdObj.sessionId); - const questionsObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); - return this.cleanQuestionStrings(questionsObj.questions) as Question[]; + const sessionObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); + + return this.reponseHandler.handleResponse(sessionIdObj.statusCode, sessionObj); } public pollQuestions(sessionId: string) { @@ -42,26 +53,6 @@ export class QuizService { ); } - private cleanQuestionStrings(questions: Question[]): Question[] { - for(let i = 0; i < questions.length; i++) { - questions[i].question_decoded = this.decodeString(questions[i].question); - questions[i].answers_decoded = []; - for(let j = 0; j < questions[i].answers.length; j++){ - questions[i].answers_decoded!.push(this.decodeString(questions[i].answers[j])); - } - } - return questions; - } - - /** Puts special characters back into the string, like ' and " */ - private decodeString(text: string) { - const txt = document.createElement("textarea"); - txt.innerHTML = text; - const decodedValue = txt.value; - txt.remove(); // Clean up the temporary element - return decodedValue; - } - public async getScore(answers: string[]) { const answersJson: Answers = { sessionId: this.storageService.getSession()!, diff --git a/frontend/src/app/Quiz/services/response-handler.service.ts b/frontend/src/app/Quiz/services/response-handler.service.ts new file mode 100644 index 0000000..2c5493b --- /dev/null +++ b/frontend/src/app/Quiz/services/response-handler.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { Session } from '../models/session'; +import { CleanerService } from './cleaner.service'; +import { Question } from '../models/question'; + +@Injectable({ + providedIn: 'root', +}) +export class ResponseHandlerService { + constructor(private cleanerService: CleanerService) {} + + /** + * Session is optional, except for the success state (0) + */ + public handleResponse(statusCode: number, session?: Session): Question[] | null { + switch (statusCode) { + case 0: + // Success + session!.questions = this.cleanerService.cleanQuestionStrings(session!.questions); + return session!.questions; + case 1: + // No Results (requests too much data for this combination of parameters) + alert('Not enough data for this request, please try a different combination'); + return null; + case 2: + // Invalid parameter (should be handled by the backend) + alert('Invalid input'); + return null; + case 5: + // Queue is full, try again later + alert('The server is experiencing a lot of traffic, please try again later'); + return null; + } + return null; + } +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/setup/setup.component.ts b/frontend/src/app/Quiz/setup/setup.component.ts index fc928ac..34c9056 100644 --- a/frontend/src/app/Quiz/setup/setup.component.ts +++ b/frontend/src/app/Quiz/setup/setup.component.ts @@ -43,8 +43,10 @@ export class SetupComponent { }; this.spinnerService.setActive(true); - this.quizService.startSession(params).then((questions: Question[]) => { - this.gotoQuiz(questions); + this.quizService.startSession(params).then((questions: Question[] | null) => { + if(questions !== null) { + this.gotoQuiz(questions); + } }) .catch((e) => {alert("Something went wrong, please try again");}) .finally(() => {this.spinnerService.setActive(false);}); diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 5c1583f..363b1a0 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1,4 +1,2 @@ - - \ No newline at end of file diff --git a/frontend/src/index.html b/frontend/src/index.html index d51364b..195d95b 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -5,7 +5,7 @@ Quiz - +