Added Project

This commit is contained in:
Ninjaws
2024-11-06 22:13:00 +01:00
commit 10c4aa8831
107 changed files with 18082 additions and 0 deletions
@@ -0,0 +1,110 @@
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.CategoryResponse;
import com.ninjaws.quiz.models.QuizSettings;
import com.ninjaws.quiz.models.Request;
import com.ninjaws.quiz.models.SessionTokenDTO;
import jakarta.validation.Valid;
@Service
public class ApiService {
@Autowired
private CleanerService cleanerService;
@Autowired
private ResponseHandlingService responseHandler;
@Autowired
private RestTemplate restTemplate;
private static final System.Logger logger = System.getLogger(ApiService.class.getName());
private final String apiUrl = "https://opentdb.com";
private String sessionToken;
public ApiService() {
}
private String buildRequestString(QuizSettings quizSettings) {
return apiUrl + "/api.php" + quizSettings.toApiQuery();
}
public void handleRequest(@Valid Request request) {
String url = buildRequestString(request.getQuizSettings());
try {
ResponseEntity<String> 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);
}
}
/**
* Collect a chuck of 50 items
* Store them in the DataService
*/
public void collectBulk() {
String tokenStr = sessionToken == null ? "" : "&token="+sessionToken;
String url = apiUrl + "/api.php?amount=50" + tokenStr;
try {
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String json = response.getBody();
ApiResponse apiResponse = cleanerService.extractApiResponse(json);
responseHandler.handleResponse(apiResponse);
/**
* A bit dirty, but otherwise it causes circular dependencies
* Makes sure the categories are refreshed after finishing to replace the questions, to make sure the ids match
* And resets the sessionToken, it will be remade during the next REFRESH
*/
if(apiResponse.getResponseCode() == 4) {
getCategories();
sessionToken = null;
}
} catch (RestClientException | IOException e) {
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
}
}
public void getSessionToken() {
String url = apiUrl + "/api_token.php?command=request";
try {
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String json = response.getBody();
SessionTokenDTO tokenDto = cleanerService.extractSessionToken(json);
sessionToken = tokenDto.getToken();
} catch (RestClientException | IOException e) {
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
}
}
public void getCategories() {
String url = apiUrl + "/api_category.php";
try {
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String json = response.getBody();
CategoryResponse categoryResponse = cleanerService.extractCategoryResponse(json);
responseHandler.handleCategoryResponse(categoryResponse);
} catch (RestClientException | IOException e) {
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
}
}
}
@@ -0,0 +1,38 @@
package com.ninjaws.quiz.services;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.ninjaws.quiz.models.Session;
@Service
public class CacheService {
/**
* 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)
*/
private final Cache<String,Session> sessions;
public CacheService() {
sessions = (Caffeine.newBuilder()
.expireAfterWrite(1,TimeUnit.HOURS)
.build());
}
public void addToCache(String key, Session session) {
sessions.put(key, session);
}
public Session getFromCache(String sessionId) {
return sessions.getIfPresent(sessionId);
}
public void removeFromCache(String sessionId) {
sessions.invalidate(sessionId);
}
}
@@ -0,0 +1,123 @@
package com.ninjaws.quiz.services;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.lang3.StringEscapeUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ninjaws.quiz.entities.Category;
import com.ninjaws.quiz.entities.QuestionCollectingEntity;
import com.ninjaws.quiz.entities.QuestionEntity;
import com.ninjaws.quiz.models.ApiResponse;
import com.ninjaws.quiz.models.CategoryResponse;
import com.ninjaws.quiz.models.QuestionDTO;
import com.ninjaws.quiz.models.SessionTokenDTO;
import com.ninjaws.quiz.repositories.CategoryRepository;
/**
* Deals with conversions from one form of data to another
* Json to Object
* Entity to DTO
* etc
*/
@Service
public class CleanerService {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private CategoryRepository categoryRepository;
public CleanerService() {
}
public ApiResponse extractApiResponse(String json) throws IOException {
return objectMapper.readValue(json, ApiResponse.class);
}
public CategoryResponse extractCategoryResponse(String json) throws IOException {
return objectMapper.readValue(json, CategoryResponse.class);
}
public List<QuestionDTO> questionListEntitytoDTO(List<QuestionEntity> entities) {
List<QuestionDTO> dtos = new ArrayList<>();
for (int i = 0; i < entities.size(); i++) {
QuestionEntity entity = entities.get(i);
dtos.add(questionEntityToDTO(entity));
}
return dtos;
}
public SessionTokenDTO extractSessionToken(String json) throws IOException {
return objectMapper.readValue(json, SessionTokenDTO.class);
}
public QuestionDTO questionEntityToDTO(QuestionEntity entity) {
QuestionDTO dto = new QuestionDTO();
dto.setQuestion(entity.getQuestion());
dto.setCorrectAnswer(entity.getCorrectAnswer());
dto.setIncorrectAnswer(entity.getIncorrectAnswers());
dto.setCategory(entity.getCategory().getName());
dto.setType(entity.getType());
dto.setDifficulty(entity.getDifficulty());
return dto;
}
public List<QuestionEntity> questionListDTOtoEntity(List<QuestionDTO> dtos) {
List<QuestionEntity> entities = new ArrayList<>();
for (int i = 0; i < dtos.size(); i++) {
QuestionDTO dto = dtos.get(i);
entities.add(questionDTOtoEntity(dto));
}
return entities;
}
public QuestionEntity questionDTOtoEntity(QuestionDTO dto) {
QuestionEntity entity = new QuestionEntity();
Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!"));
entity.setCategory(category);
entity.setDifficulty(dto.getDifficulty());
entity.setType(dto.getType());
entity.setQuestion(dto.getQuestion());
entity.setCorrectAnswer(dto.getCorrectAnswer());
entity.setIncorrectAnswers(dto.getIncorrectAnswers());
return entity;
}
// TODO: Find a simpler way to convert from one table to another if the data is identical so this nonsense isn't necessary
public List<QuestionCollectingEntity> questionListDTOtoCollectingEntity(List<QuestionDTO> dtos) {
List<QuestionCollectingEntity> entities = new ArrayList<>();
for (int i = 0; i < dtos.size(); i++) {
QuestionDTO dto = dtos.get(i);
entities.add(questionDTOtoCollectingEntity(dto));
}
return entities;
}
public QuestionCollectingEntity questionDTOtoCollectingEntity(QuestionDTO dto) {
QuestionCollectingEntity entity = new QuestionCollectingEntity();
Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!"));
entity.setCategory(category);
entity.setDifficulty(dto.getDifficulty());
entity.setType(dto.getType());
entity.setQuestion(dto.getQuestion());
entity.setCorrectAnswer(dto.getCorrectAnswer());
entity.setIncorrectAnswers(dto.getIncorrectAnswers());
return entity;
}
/**
* For some reason, the categories come with special characters,
* But the categories from the questions dont
* So this has to be manually fixed if they are to be stored in the db (otherwise namematching doesnt work)
*/
private String decodeString(String text){
return StringEscapeUtils.unescapeHtml4(text);
}
}
@@ -0,0 +1,70 @@
package com.ninjaws.quiz.services;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ninjaws.quiz.entities.Category;
import com.ninjaws.quiz.entities.QuestionCollectingEntity;
import com.ninjaws.quiz.entities.QuestionEntity;
import com.ninjaws.quiz.models.QuizSettings;
import com.ninjaws.quiz.repositories.CategoryRepository;
import com.ninjaws.quiz.repositories.QuestionCollectingRepository;
import com.ninjaws.quiz.repositories.QuestionRepository;
import jakarta.transaction.Transactional;
@Service
public class DataService {
@Autowired
private QuestionRepository questionRepository;
@Autowired
private QuestionCollectingRepository collectingRepository;
@Autowired
private CategoryRepository categoryRepository;
public List<QuestionEntity> getAllQuestions() {
return questionRepository.findAll();
}
public QuestionEntity saveQuestion(QuestionEntity question) {
return questionRepository.save(question);
}
public List<QuestionEntity> saveQuestions(List<QuestionEntity> questions) {
return questionRepository.saveAll(questions);
}
/**
* Tries to get questions that match the specified QuizSettings
*
* @return The questions if the query was possible, and an empty array if not
*/
public List<QuestionEntity> lookupQuestions(QuizSettings quizSettings) {
return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount());
}
public List<Category> saveCategories(List<Category> categories) {
return categoryRepository.saveAll(categories);
}
public List<Category> getCategories(){
return categoryRepository.findAll();
}
/**
* Moving the collected questions to the questions list
*
*/
@Transactional
public void migrateQuestions() {
questionRepository.deleteAll();
questionRepository.moveCollection();
collectingRepository.deleteAll();
}
public List<QuestionCollectingEntity> collectQuestions(List<QuestionCollectingEntity> questions) {
return collectingRepository.saveAll(questions);
}
}
@@ -0,0 +1,31 @@
package com.ninjaws.quiz.services;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import org.springframework.stereotype.Service;
import com.ninjaws.quiz.models.Request;
@Service
public class QueueService {
/**
* 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<Request> requests = new LinkedBlockingQueue<>(5);
/**
* 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() {
return requests.poll();
}
}
@@ -0,0 +1,123 @@
package com.ninjaws.quiz.services;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ninjaws.quiz.entities.QuestionEntity;
import com.ninjaws.quiz.models.Answers;
import com.ninjaws.quiz.models.QuizSettings;
import com.ninjaws.quiz.models.Request;
import com.ninjaws.quiz.models.Session;
@Service
public class QuizService {
@Autowired
private CacheService cacheService;
@Autowired
private QueueService queueService;
@Autowired
private DataService dataService;
@Autowired
private CleanerService cleanerService;
@Autowired
private StateManagementService stateManager;
/**
* Actions:
* - Creates a session with sessionId
* - Adds session to the cache
* - Creates a request and adds it to the queue
* @param quizSettings The values set by the user, determining the kind of quiz they want
* @return The sessionId
*/
public Optional<String> createSession(QuizSettings quizSettings) {
Session session = new Session();
/** First, we check if there are already enough items in our db */
List<QuestionEntity> questionsRetrieved = dataService.lookupQuestions(quizSettings);
/** If the items are not matching the required amount, and the db is not currently being filled up, then they simply don't exist */
if (questionsRetrieved.size() < quizSettings.getAmount() && stateManager.getCurrentProcessingState() != StateManagementService.ProcessingState.STARTUP) {
session.setStatusCode(1);
cacheService.addToCache(session.getId(), session);
return Optional.of(session.getId());
}
/** All items are collected in from the database, it doesn't need to be added to the queue */
if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) {
session.setQuestions(cleanerService.questionListEntitytoDTO(questionsRetrieved));
cacheService.addToCache(session.getId(), session);
return Optional.of(session.getId());
}
cacheService.addToCache(session.getId(), session);
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 Optional.of(session.getId());
}
/**
* Actions:
* - Looks up the session in the cache
* - Checks if the data from the request is stored
* @param sessionId
* @return The data if it's available, Optional.empty() if not, and an Exception if the session is invalid
*/
public Optional<Session> checkSession(String sessionId) throws IllegalArgumentException {
Session session = cacheService.getFromCache(sessionId);
if(session == null) {
throw new IllegalArgumentException("Invalid session ID: " + sessionId);
}
/** 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);
}
/** Still waiting for a response */
if (session.getQuestions() == null) {
return Optional.empty();
}
return Optional.of(session);
}
/**
* Receives: sessionId and answers
*
* Actions:
* - Retrieves session from cache
* - Deletes session from cache
* - Matches answers answers to correctAnswers
* - Calculates score
* Returns: calculated score
* */
public int getScore(Answers answers) {
Session session = cacheService.getFromCache(answers.getSessionId());
cacheService.removeFromCache(session.getId());
return calculateScore(session, answers);
}
private int calculateScore(Session session, Answers answers) {
int amountCorrect = 0;
for(int i = 0; i < session.getQuestions().size(); i++) {
String correctAnswer = session.getQuestions().get(i).getCorrectAnswer();
String userAnswer = answers.getAnswers().get(i);
if (correctAnswer.equalsIgnoreCase(userAnswer)) {
amountCorrect++;
}
}
return amountCorrect;
}
}
@@ -0,0 +1,110 @@
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.CategoryResponse;
import com.ninjaws.quiz.models.Session;
@Service
public class ResponseHandlingService {
@Autowired
private StateManagementService stateManager;
@Autowired
private CacheService cacheService;
@Autowired
private DataService dataService;
@Autowired
private CleanerService cleanerService;
/**
* 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:
// Success
updateSessionInCache(session, response);
/** Managing the token gets difficult since there is a timelimit, so simply dont add these, let the bulk collector do it */
// dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults()));
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 handleResponse(ApiResponse response) {
switch (response.getResponseCode()) {
case 0:
if(stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP) {
/** Initial saving during the startup */
dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults()));
} else if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) {
/** Background collecting during the night */
dataService.collectQuestions(cleanerService.questionListDTOtoCollectingEntity(response.getResults()));
}
break;
case 4:
// Signal DataService that we have extracted all the data. Stop gathering,
// TODO: Switch to caching-mode
/**
* If it was in the REFRESH state, move the data from collecting to the used questions
* Otherwise simply switch to the NORMAL state
*/
if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) {
dataService.migrateQuestions();
}
stateManager.setCurrentProcessingState(StateManagementService.ProcessingState.NORMAL);
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);
}
public void handleCategoryResponse(CategoryResponse categoryResponse) {
dataService.saveCategories(categoryResponse.getTrivia_categories());
}
}
@@ -0,0 +1,49 @@
package com.ninjaws.quiz.services;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import com.ninjaws.quiz.models.Request;
@Service
public class SchedulerService {
@Autowired
private QueueService queueService;
@Autowired
private ApiService apiService;
@Autowired
private StateManagementService stateService;
public SchedulerService() {}
/**
* Every 5 seconds:
* - 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) {
apiService.handleRequest(request);
} else {
if (stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP || stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) {
apiService.collectBulk();
}
}
}
/**
* Starts the renewing of the questions, to ensure they stay fresh
*/
@Scheduled(cron = "0 0 1 * * ?")
protected void StartDbRefresh() {
apiService.getSessionToken();
stateService.setCurrentProcessingState(StateManagementService.ProcessingState.REFRESH);
}
}
@@ -0,0 +1,29 @@
package com.ninjaws.quiz.services;
import org.springframework.stereotype.Service;
@Service
public class StateManagementService {
/**
* STARTUP: When starting the app
* REFRESH: Scheduled at night, so the database stays fresh
* NORMAL: No special activities
*/
public enum ProcessingState {
STARTUP,
REFRESH,
NORMAL
}
private ProcessingState currentProcessingState;
public ProcessingState getCurrentProcessingState() {
return currentProcessingState;
}
public void setCurrentProcessingState(ProcessingState currentProcessingState) {
this.currentProcessingState = currentProcessingState;
}
}