[Updated] Finished Frontend, Added statushandling to backend and frontend [Todo: session token handling at lvl 2]

This commit is contained in:
Ninjaws
2024-11-01 17:02:24 +01:00
parent 3188f6ce20
commit 675bdc7f8e
16 changed files with 247 additions and 102 deletions
@@ -31,7 +31,12 @@ public class QuizController {
@RequestParam(required = false) String type @RequestParam(required = false) String type
) { ) {
QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type); QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type);
String jsonString = String.format("{\"sessionId\":\"%s\"}", quizService.createSession(quizSettings)); Optional<String> 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; return jsonString;
} }
@@ -59,11 +59,6 @@ public class QuizSettings {
/** Inverse of toApiQuery */ /** Inverse of toApiQuery */
public QuizSettings(int amount, Integer category, String difficulty, String type) { 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.amount = amount;
this.category = category == null ? Category.ANY : Category.values()[category]; this.category = category == null ? Category.ANY : Category.values()[category];
this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase()); this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase());
@@ -1,20 +1,31 @@
package com.ninjaws.quiz.services; package com.ninjaws.quiz.services;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import com.ninjaws.quiz.models.ApiResponse;
import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.QuizSettings;
import com.ninjaws.quiz.models.Request;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@Service @Service
public class ApiService { public class ApiService {
@Autowired
private CleanerService cleanerService;
@Autowired
private ResponseHandlingService responseHandler;
@Autowired @Autowired
private RestTemplate restTemplate; private RestTemplate restTemplate;
private static final System.Logger logger = System.getLogger(ApiService.class.getName());
private final String apiUrl = "https://opentdb.com/api.php"; private final String apiUrl = "https://opentdb.com/api.php";
public ApiService() { public ApiService() {
@@ -24,25 +35,17 @@ public class ApiService {
return apiUrl + quizSettings.toApiQuery(); return apiUrl + quizSettings.toApiQuery();
} }
public String requestQuestions(@Valid QuizSettings quizSettings) { public void handleRequest(@Valid Request request) {
String url = buildRequestString(quizSettings); String url = buildRequestString(request.getQuizSettings());
try {
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class); ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String jsonData = response.getBody(); String json = response.getBody();
ApiResponse apiResponse = cleanerService.extractApiResponse(json);
// try { responseHandler.handleResponse(request.getSessionId(), apiResponse);
// 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) {
} catch (RestClientException | IOException | IllegalStateException e) {
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
}
} }
} }
@@ -1,16 +1,22 @@
package com.ninjaws.quiz.services; package com.ninjaws.quiz.services;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.ninjaws.quiz.models.QuizSettings; import com.fasterxml.jackson.databind.ObjectMapper;
import com.ninjaws.quiz.models.ApiResponse;
@Service @Service
public class CleanerService { public class CleanerService {
@Autowired
private ObjectMapper objectMapper;
public CleanerService() {} public CleanerService() {}
public QuizSettings jsonToQuizSettings(String json){ public ApiResponse extractApiResponse(String json) throws IOException {
QuizSettings quizSettings = new QuizSettings(); return objectMapper.readValue(json, ApiResponse.class);
return quizSettings;
} }
} }
@@ -15,10 +15,14 @@ public class QueueService {
* Executes them in order, one every 5 seconds (the limit of the external API) * Executes them in order, one every 5 seconds (the limit of the external API)
* Removes them from the Queue when the request was succesful * Removes them from the Queue when the request was succesful
*/ */
private final BlockingQueue<Request> requests = new LinkedBlockingQueue<>(); private final BlockingQueue<Request> 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() { public Request popFromQueue() {
@@ -27,12 +27,17 @@ public class QuizService {
* @param quizSettings The values set by the user, determining the kind of quiz they want * @param quizSettings The values set by the user, determining the kind of quiz they want
* @return The sessionId * @return The sessionId
*/ */
public String createSession(QuizSettings quizSettings) { public Optional<String> createSession(QuizSettings quizSettings) {
Session session = new Session(); Session session = new Session();
cacheService.addToCache(session.getId(), 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); throw new IllegalArgumentException("Invalid session ID: " + sessionId);
} }
/** Still waiting for a response */
if (session.getQuestions() == null) { if (session.getQuestions() == null) {
return Optional.empty(); 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); return Optional.of(session);
} }
@@ -76,10 +88,6 @@ public class QuizService {
for(int i = 0; i < session.getQuestions().size(); i++) { for(int i = 0; i < session.getQuestions().size(); i++) {
String correctAnswer = session.getQuestions().get(i).getCorrectAnswer(); String correctAnswer = session.getQuestions().get(i).getCorrectAnswer();
String userAnswer = answers.getAnswers().get(i); String userAnswer = answers.getAnswers().get(i);
// for(String answer : session.getQuestions().get(i).getAnswers()) {
// System.out.print(answer);
// }
if (correctAnswer.equalsIgnoreCase(userAnswer)) { if (correctAnswer.equalsIgnoreCase(userAnswer)) {
amountCorrect++; amountCorrect++;
} }
@@ -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);
}
}
@@ -1,17 +1,10 @@
package com.ninjaws.quiz.services; package com.ninjaws.quiz.services;
import java.io.IOException;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; 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.Request;
import com.ninjaws.quiz.models.Session;
@Service @Service
public class SchedulerService { public class SchedulerService {
@@ -20,40 +13,21 @@ public class SchedulerService {
private QueueService queueService; private QueueService queueService;
@Autowired @Autowired
private ApiService apiService; private ApiService apiService;
@Autowired
private CacheService cacheService;
@Autowired
private ObjectMapper objectMapper;
private static final System.Logger logger = System.getLogger(SchedulerService.class.getName());
public SchedulerService() {} public SchedulerService() {}
/** /**
* Every 5 seconds: * Every 5 seconds:
* - Take the top request from the queue (removes it) * - Check if there is a request in queue
* - Perform the request * - Yes: Send it to the apiService
* - Wait for the data * - No: Have the apiService do bulk gathering for the DB
* - Find the matching session from the cache
* - Add the data to the session in the cache
*/ */
@Scheduled(fixedDelay = 5000) @Scheduled(fixedDelay = 5000)
protected void processQueue() { protected void processQueue() {
Request request = queueService.popFromQueue(); Request request = queueService.popFromQueue();
if (request != null) { if (request != null) {
String json = apiService.requestQuestions(request.getQuizSettings()); apiService.handleRequest(request);
try {
List<Question> 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);
}
} }
} }
private List<Question> extractQuestions(String json) throws IOException {
return objectMapper.readValue(json, ApiResponse.class).getResults();
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+7
View File
@@ -0,0 +1,7 @@
import { Question } from "./question";
export interface Session {
id: string;
statusCode: number;
questions: Question[];
}
@@ -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;
}
}
+15 -24
View File
@@ -16,6 +16,8 @@ import {
import { Question } from '../models/question'; import { Question } from '../models/question';
import { Answers } from '../models/answers'; import { Answers } from '../models/answers';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
import { CleanerService } from './cleaner.service';
import { ResponseHandlerService } from './response-handler.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -23,13 +25,22 @@ import { StorageService } from './storage.service';
export class QuizService { export class QuizService {
private apiUrl = 'http://localhost:9090/quiz'; 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<Question[] | null> {
const sessionIdObj = await firstValueFrom(this.http.get<any>(this.apiUrl + '/questions' + this.convertParamsToUrl(quizParams))); const sessionIdObj = await firstValueFrom(this.http.get<any>(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); this.storageService.setSession(sessionIdObj.sessionId);
const questionsObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); const sessionObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId));
return this.cleanQuestionStrings(questionsObj.questions) as Question[];
return this.reponseHandler.handleResponse(sessionIdObj.statusCode, sessionObj);
} }
public pollQuestions(sessionId: string) { 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[]) { public async getScore(answers: string[]) {
const answersJson: Answers = { const answersJson: Answers = {
sessionId: this.storageService.getSession()!, sessionId: this.storageService.getSession()!,
@@ -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;
}
}
@@ -43,8 +43,10 @@ export class SetupComponent {
}; };
this.spinnerService.setActive(true); this.spinnerService.setActive(true);
this.quizService.startSession(params).then((questions: Question[]) => { this.quizService.startSession(params).then((questions: Question[] | null) => {
if(questions !== null) {
this.gotoQuiz(questions); this.gotoQuiz(questions);
}
}) })
.catch((e) => {alert("Something went wrong, please try again");}) .catch((e) => {alert("Something went wrong, please try again");})
.finally(() => {this.spinnerService.setActive(false);}); .finally(() => {this.spinnerService.setActive(false);});
-2
View File
@@ -1,4 +1,2 @@
<!-- <div class="h-screen"> -->
<router-outlet></router-outlet> <router-outlet></router-outlet>
<!-- </div> -->
<app-spinner></app-spinner> <app-spinner></app-spinner>
+1 -1
View File
@@ -5,7 +5,7 @@
<title>Quiz</title> <title>Quiz</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="question.ico">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>