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,15 @@
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) {
SpringApplication.run(QuizApplication.class, args);
}
}
@@ -0,0 +1,40 @@
package com.ninjaws.quiz.components;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import com.ninjaws.quiz.services.ApiService;
import com.ninjaws.quiz.services.StateManagementService;
@Component
public class StartupRunner implements CommandLineRunner {
@Autowired
private StateManagementService stateManagementService;
@Autowired
private ApiService apiService;
@Override
public void run(String... args) {
/**
* On startup:
* - Set ProcessingState to Startup
* - Store the categories
* - Get a sessiontoken
*
* * Inside the SchedulerService
* - Start filling the question table
*
* * Inside the Response Handling Service
* - No more questions? Stop
* - Set ProcessingState to Normal
*/
stateManagementService.setCurrentProcessingState(StateManagementService.ProcessingState.STARTUP);
apiService.getCategories();
apiService.getSessionToken();
}
}
@@ -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();
}
}
@@ -0,0 +1,20 @@
package com.ninjaws.quiz.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // Allow all endpoints
.allowedOrigins("http://localhost:4200", "https://quiz.ianvink.nl") // Allow specific origin
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // Allow specific methods
.allowedHeaders("*") // Allow all headers
.allowCredentials(true); // Allow credentials
}
}
@@ -0,0 +1,82 @@
package com.ninjaws.quiz.controllers;
import java.util.List;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ninjaws.quiz.entities.Category;
import com.ninjaws.quiz.models.Answers;
import com.ninjaws.quiz.models.QuizSettings;
import com.ninjaws.quiz.models.Session;
import com.ninjaws.quiz.services.DataService;
import com.ninjaws.quiz.services.QuizService;
@RestController
@RequestMapping("/quiz")
public class QuizController {
@Autowired
private QuizService quizService;
@Autowired
private DataService dataService;
/**
* Start a session
* @param amount The amount of questsions
* @param category The desired category (or all)
* @param difficulty The desired difficulty (or all)
* @param type The desired type (or both)
* @return The sessionId, or statusCode 5 when the queue is full
*/
@GetMapping("/questions")
public String startSession(
@RequestParam(required = true) Integer amount,
@RequestParam(required = false) Integer category,
@RequestParam(required = false) String difficulty,
@RequestParam(required = false) String type
) {
QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type);
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;
}
/**
* Checks if the questions have been retrieved
* @param sessionId The session that holds the questions
* @return The Session, or nothing if they are not ready yet
*/
@GetMapping("/status/{sessionId}")
public Optional<Session> checkStatus(@PathVariable String sessionId) {
return quizService.checkSession(sessionId);
}
/**
* Receive the chosen answers, calculate the score, and return it
* @param answers The chosen answers
* @return The score
*/
@PostMapping("/score")
public String checkAnswers(@RequestBody Answers answers) {
String jsonString = String.format("{\"score\":\"%s\"}", quizService.getScore(answers));
return jsonString;
}
@GetMapping("/categories")
public List<Category> getCategories(){
return dataService.getCategories();
}
}
@@ -0,0 +1,34 @@
package com.ninjaws.quiz.entities;
import java.util.List;
import java.util.UUID;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
@MappedSuperclass
@Getter
@Setter
public class BaseQuestionEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne
@JoinColumn(name = "category", nullable = false)
private Category category;
private String difficulty;
private String type;
private String question;
private String correctAnswer;
// @ElementCollection // This stores it in a separate table, it's just a set of strings so not required right now
private List<String> incorrectAnswers;
}
@@ -0,0 +1,25 @@
package com.ninjaws.quiz.entities;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "category")
@Getter
@Setter
public class Category {
@Id
private Integer id;
private String name;
public Category() {}
public Category(int id, String name) {
this.id = id;
this.name = name;
}
}
@@ -0,0 +1,10 @@
package com.ninjaws.quiz.entities;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "question_collecting")
public class QuestionCollectingEntity extends BaseQuestionEntity{
}
@@ -0,0 +1,10 @@
package com.ninjaws.quiz.entities;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "question")
public class QuestionEntity extends BaseQuestionEntity {
}
@@ -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<String> handleIllegalArgumentException(IllegalArgumentException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(e.getMessage());
}
}
@@ -0,0 +1,19 @@
package com.ninjaws.quiz.models;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Answers {
private String sessionId;
private List<String> answers;
public Answers() {}
public Answers(String sessionId) {
this.sessionId = sessionId;
}
}
@@ -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<QuestionDTO> results;
}
@@ -0,0 +1,14 @@
package com.ninjaws.quiz.models;
import java.util.List;
import com.ninjaws.quiz.entities.Category;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CategoryResponse {
List<Category> trivia_categories;
}
@@ -0,0 +1,55 @@
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;
import lombok.Setter;
@Getter
@Setter
public class QuestionDTO {
private String category;
private String difficulty;
private String type;
private String question;
//* Hidden from the user */
private String correctAnswer;
private List<String> incorrectAnswers;
@JsonProperty("answers")
public List<String> getAnswers() {
List<String> 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<String> incorrectAnswers) {
this.incorrectAnswers = incorrectAnswers;
}
@JsonProperty("correct_answer")
@JsonIgnore
public String getCorrectAnswer() {
return correctAnswer;
}
@JsonProperty("incorrect_answers")
@JsonIgnore
public List<String> getIncorrectAnswers() {
return incorrectAnswers;
}
}
@@ -0,0 +1,62 @@
package com.ninjaws.quiz.models;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class QuizSettings {
@Min(1)
@Max(50)
private int amount = 10;
private Integer category;
private String difficulty;
private String type;
public QuizSettings(){}
/** Inverse of toApiQuery */
public QuizSettings(int amount, Integer category, String difficulty, String type) {
this.amount = amount;
this.category = category;
this.difficulty = difficulty;
this.type = type;
// this.category = category == null ? Category.ANY : Category.values()[category];
// this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase());
// this.type = type == null ? Type.ANY : type.equals("boolean") ? Type.BOOLEAN : Type.MULTIPLE;
}
/**
* Ensures the value is between 1 and 50
* @Valid doesn't seem to be enforcing it, so this ensures the value is legal
*/
public void setAmount(@Valid int amount) {
this.amount = Math.max(1, Math.min(amount, 50));
}
/**
* Converts the class to a format that can be provided as arguments for the api request
* @return The resulting request arguments as String
*/
public String toApiQuery() {
StringBuilder query = new StringBuilder("?amount=" + amount);
if (category != null) {
query.append("&category=").append(category);
}
if (difficulty != null ) {
query.append("&difficulty=").append(difficulty);
}
if (type != null ) {
query.append("&type=").append(type);
}
return query.toString();
}
}
@@ -0,0 +1,14 @@
package com.ninjaws.quiz.models;
import lombok.Getter;
@Getter
public class Request {
private final String sessionId;
private final QuizSettings quizSettings;
public Request(String sessionId, QuizSettings quizSettings) {
this.sessionId = sessionId;
this.quizSettings = quizSettings;
}
}
@@ -0,0 +1,19 @@
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 int statusCode = 0;
private List<QuestionDTO> questions;
public Session() {
this.id = UUID.randomUUID().toString();
}
}
@@ -0,0 +1,15 @@
package com.ninjaws.quiz.models;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SessionTokenDTO {
@JsonProperty("response_code")
private int responseCode;
@JsonProperty("token")
private String token;
}
@@ -0,0 +1,11 @@
package com.ninjaws.quiz.repositories;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.ninjaws.quiz.entities.Category;
public interface CategoryRepository extends JpaRepository<Category, Long>{
Optional<Category> findByName(String name);
}
@@ -0,0 +1,14 @@
package com.ninjaws.quiz.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import com.ninjaws.quiz.entities.QuestionCollectingEntity;
/**
* This table is used for collecting questions from the external API
* When it finishes, the data from this repository will replace the data in the QuestionRepository, to ensure it stays fresh
* This should be scheduled once every day
*/
public interface QuestionCollectingRepository extends JpaRepository<QuestionCollectingEntity, Long>{
}
@@ -0,0 +1,39 @@
package com.ninjaws.quiz.repositories;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.ninjaws.quiz.entities.QuestionEntity;
import jakarta.transaction.Transactional;
public interface QuestionRepository extends JpaRepository<QuestionEntity, Long>{//, JpaSpecificationExecutor<Question> {
// @Query(value = "SELECT * FROM question AS q " +
// "WHERE (?1 IS NULL OR q.category = ?1) " +
// "AND (?2 IS NULL OR q.difficulty = ?2) " +
// "AND (?3 IS NULL OR q.type = ?3) " +
// "ORDER BY RAND() LIMIT ?4", nativeQuery = true)
@Query(value = "SELECT * FROM question AS q " +
"WHERE (:categoryId IS NULL OR q.category = :categoryId) " +
"AND (:difficulty IS NULL OR q.difficulty = :difficulty) " +
"AND (:type IS NULL OR q.type = :type) " +
"ORDER BY RAND() LIMIT :limit;", nativeQuery = true)
List<QuestionEntity> lookupQuestions(
@Param("categoryId") Integer categoryId,
@Param("difficulty") String difficulty,
@Param("type") String type,
@Param("limit") int limit);
/**
* Moves all data from one table to another
*/
@Modifying
@Transactional
@Query(value="INSERT INTO question SELECT * FROM question_collecting;", nativeQuery=true)
void moveCollection();
}
@@ -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;
}
}