Added Project
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Ninjaws
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Quiz
|
||||||
|
App that allows the user to set up a quiz, and answer the questions
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
### Install Docker
|
||||||
|
- On Windows and Mac
|
||||||
|
```
|
||||||
|
Install Docker Desktop
|
||||||
|
Windows: https://docs.docker.com/desktop/install/windows-install/
|
||||||
|
Mac: https://docs.docker.com/desktop/install/mac-install/
|
||||||
|
```
|
||||||
|
- On Linux
|
||||||
|
```bash
|
||||||
|
# For Debian-based systems
|
||||||
|
sudo apt install -y docker.io
|
||||||
|
sudo apt install docker-compose
|
||||||
|
|
||||||
|
#For Arch-based systems
|
||||||
|
sudo pacman -S docker.io
|
||||||
|
sudo pacman -S docker-compose
|
||||||
|
|
||||||
|
# Making Docker run now and on startup
|
||||||
|
sudo systemctl start docker
|
||||||
|
sudo systemctl enable docker
|
||||||
|
|
||||||
|
# Using the CLI without sudo
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
- Navigate to the root of the project
|
||||||
|
- Open a terminal
|
||||||
|
- Start the containers:
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
- Visit localhost:4200 to play the quiz!
|
||||||
|
|
||||||
|
|
||||||
|
## Benchmarking
|
||||||
|
- Go to the /scripts folder and run the LoadTestMultiple.sh script
|
||||||
|
- Inside you can change how many sessions should be created (set to 6 by default)
|
||||||
|
- In the /scripts/benchmarks folder you can find some benchmarks I've done
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- If the data is not present, it will try to query it from the external API, which takes significantly longer (only 1 request per 5 seconds)
|
||||||
|
- When running it locally, give the backend a few minutes to gather data in bulk (should be completely done in ~7 minutes)
|
||||||
|
- It refreshes this data every night in the background, so it stays up to date
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Reflection
|
||||||
|
|
||||||
|
## Issues during testing (October 30th)
|
||||||
|
### Scheduler
|
||||||
|
I don't know if it was the scheduler specifically, but whenever I try to test it, both the cache and the queue seem to become empty after the first function is completed.
|
||||||
|
I don't know how to solve this yet
|
||||||
|
|
||||||
|
|
||||||
|
## Caching solution (October 30th)
|
||||||
|
### Lack of data
|
||||||
|
Not being able to gather blocks of 50 is a big issue. The idea of reusing blocks becomes less viable if I can't just get a big chunk every time for others to use.
|
||||||
|
I can't force a big chunk, because it might just return a status 1, and then the user gets nothing.
|
||||||
|
|
||||||
|
Perhaps I can separate the questions myself and make a collection, that i can build the requests from. However, for every quest tracking if someone has already seen is a huge pain.
|
||||||
|
|
||||||
|
|
||||||
|
## Possible solution (October 31st)
|
||||||
|
Cache all data, 50 at a time
|
||||||
|
Store it in a database
|
||||||
|
When its done, let the db make blocks for quick cache access
|
||||||
|
|
||||||
|
Then randomize when someone asks for an item from the cache
|
||||||
|
Or return a failure if there are not enough items (a la the api itself)
|
||||||
|
|
||||||
|
Every night, create a new db in the background and start filling it (should take ~34 minutes for all 20k items the external API has)
|
||||||
|
When it's full, replace the old database with the new one, and delete the old one.
|
||||||
|
Then start replacing the cache items one at a time.
|
||||||
|
|
||||||
|
Users shouldn't feel this at all, because the active sessions are stored in their own cache items
|
||||||
|
|
||||||
|
# Progress so far (November 3rd)
|
||||||
|
There only seem to be ~4100 items, making the collecting phase only last around 7 minutes.
|
||||||
|
|
||||||
|
Swapping dbs is replaced with swapping tables, seems to work just as fine, yet much simpler to set up
|
||||||
|
|
||||||
|
TODO: Add a caching state, and start filling up each unique combination with the items from the db
|
||||||
|
This should lead to performance gains
|
||||||
|
|
||||||
|
## Possible future alternative
|
||||||
|
In the current state (when the cache is done), the backend might hold data 3 times (Cache, active DB table, and background DB table during the night)
|
||||||
|
|
||||||
|
### Purely Cached solution
|
||||||
|
- Every night
|
||||||
|
- Check for each category+difficulty combination how many questions there are (API has an endpoint for this)
|
||||||
|
- Track which one you are on, and how many you have
|
||||||
|
- Take blocks of 50, until the remaining items are less than 50, then get that exact amount
|
||||||
|
- When they are gathered, either
|
||||||
|
1. Divide them between 3 cache items: cat:dif:any/multiple/boolean
|
||||||
|
- (Manually filter on the type, since the API sends questions with either option together), or
|
||||||
|
2. In one like cat:dif:any and then filter it by hand when a user asks for it
|
||||||
|
- (This will replace the collection put there the night before)
|
||||||
|
- Then move to the next one
|
||||||
|
- Stop when the last combination has been collected
|
||||||
|
|
||||||
|
#### When a user requests data:
|
||||||
|
- Get the items from the cache
|
||||||
|
- Less items than requested: Inform the user to try a different combination
|
||||||
|
- More items than requested: Randomize and select a subset
|
||||||
|
|
||||||
|
### Tradeoffs
|
||||||
|
- Advantage: Less total data held on the server
|
||||||
|
|
||||||
|
- Downside: Slower gathering, since there are 25+3+4=300 unique combinations (each of which will end with a request of less than 50 items), it will require a decent number requests more than the bulk collecting that the DB does.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "Quiz Backend",
|
||||||
|
|
||||||
|
"build": {
|
||||||
|
"context": "..",
|
||||||
|
"dockerfile": "../Dockerfile",
|
||||||
|
"args": {
|
||||||
|
"WORKDIR": "/app"
|
||||||
|
// "UID": "${localEnv:USER_UID}",
|
||||||
|
// "GID": "${localEnv:USER_GID}"
|
||||||
|
},
|
||||||
|
"target": "development"
|
||||||
|
},
|
||||||
|
"runArgs": [
|
||||||
|
"--name=quiz-backend-dev-cont",
|
||||||
|
"--net=host" // Allows it to be opened on the host network
|
||||||
|
],
|
||||||
|
"workspaceFolder": "/app",
|
||||||
|
"mounts": [
|
||||||
|
"source=.,target=/app,type=bind"
|
||||||
|
],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {},
|
||||||
|
"extensions": [
|
||||||
|
"vscjava.vscode-java-pack",
|
||||||
|
"Oracle.oracle-java",
|
||||||
|
"scala-lang.scala"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/mvnw text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
HELP.md
|
||||||
|
target/
|
||||||
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
|
!**/src/main/**/target/
|
||||||
|
!**/src/test/**/target/
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
build/
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
wrapperVersion=3.3.2
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
FROM openjdk:17-jdk-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt install -y \
|
||||||
|
maven \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
#---Development
|
||||||
|
FROM base AS development
|
||||||
|
# Switching user so it doesn't use root
|
||||||
|
# If root adds files, it takes ownership, making them unremovable on the host except by using sudo
|
||||||
|
# node tranfers ownership to the host user
|
||||||
|
ENV UID=1000
|
||||||
|
ENV GID=1000
|
||||||
|
ENV USER=dev
|
||||||
|
RUN groupadd -g ${GID} ${USER} \
|
||||||
|
&& useradd -m -u ${UID} -g ${USER} ${USER}
|
||||||
|
USER ${USER}
|
||||||
|
EXPOSE 9090
|
||||||
|
CMD ["tail","-f","/dev/null"]
|
||||||
|
|
||||||
|
#---Building
|
||||||
|
FROM base AS build
|
||||||
|
COPY . .
|
||||||
|
RUN mvn clean package
|
||||||
|
# Add -DskipTests for slightly faster build, but no testing
|
||||||
|
|
||||||
|
#---Production
|
||||||
|
# Pulled again to get rid of maven, to decrease the size
|
||||||
|
FROM openjdk:17-jdk-slim AS production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/target/*.jar app.jar
|
||||||
|
EXPOSE 9090
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.2
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
Vendored
+149
@@ -0,0 +1,149 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.2
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
|
||||||
|
}
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.3.5</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>com.ninjaws</groupId>
|
||||||
|
<artifactId>quiz</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>quiz</name>
|
||||||
|
<description>Quiz App</description>
|
||||||
|
<url/>
|
||||||
|
<licenses>
|
||||||
|
<license/>
|
||||||
|
</licenses>
|
||||||
|
<developers>
|
||||||
|
<developer/>
|
||||||
|
</developers>
|
||||||
|
<scm>
|
||||||
|
<connection/>
|
||||||
|
<developerConnection/>
|
||||||
|
<tag/>
|
||||||
|
<url/>
|
||||||
|
</scm>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<lombok.version>1.18.34</lombok.version>
|
||||||
|
<caffeine.version>3.1.8</caffeine.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
<version>${caffeine.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-text</artifactId>
|
||||||
|
<version>1.10.0</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
spring.application.name=quiz
|
||||||
|
server.address=0.0.0.0
|
||||||
|
server.port=9090
|
||||||
|
|
||||||
|
# H2 Database configuration
|
||||||
|
spring.h2.console.enabled=true
|
||||||
|
# spring.h2.console.path=/console
|
||||||
|
|
||||||
|
|
||||||
|
spring.datasource.url=jdbc:h2:mem:testdb
|
||||||
|
spring.datasource.driverClassName=org.h2.Driver
|
||||||
|
spring.datasource.username=sa
|
||||||
|
spring.datasource.password=
|
||||||
|
|
||||||
|
spring.jpa.hibernate.ddl-auto=create-drop
|
||||||
|
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||||
|
|
||||||
|
|
||||||
|
# # Primary database configuration
|
||||||
|
# spring.datasource.primary.url=jdbc:h2:mem:testdb
|
||||||
|
# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||||
|
# spring.datasource.primary.driver-class-name=org.h2.Driver
|
||||||
|
# spring.datasource.primary.username=sa
|
||||||
|
# spring.datasource.primary.password=
|
||||||
|
# spring.jpa.primary.hibernate.ddl-auto=create-drop
|
||||||
|
# spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect
|
||||||
|
|
||||||
|
# # Secondary database configuration
|
||||||
|
# spring.datasource.secondary.url=jdbc:h2:mem:secondarydb
|
||||||
|
# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||||
|
# spring.datasource.secondary.driver-class-name=org.h2.Driver
|
||||||
|
# spring.datasource.secondary.username=sa
|
||||||
|
# spring.datasource.secondary.password=
|
||||||
|
# spring.jpa.secondary.hibernate.ddl-auto=create-drop
|
||||||
|
# spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<h1>Quiz backend</h1>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ninjaws.quiz;
|
||||||
|
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
class QuizApplicationTests {
|
||||||
|
|
||||||
|
// @Test
|
||||||
|
// void contextLoads() {
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ninjaws.quiz.scala;
|
||||||
|
import io.gatling.core.Predef._
|
||||||
|
import io.gatling.http.Predef._
|
||||||
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
|
/** Does not work, use the LoadTestMultiple.sh in the /scripts folder in the root instead */
|
||||||
|
class QuestionStatusSimulation extends Simulation {
|
||||||
|
val httpProtocol = http.baseUrl("http://localhost:9090").acceptHeader("application/json")
|
||||||
|
|
||||||
|
val requestQuestions = exec(http("Request Questions").get("/questions/bla").check(status.is(200)).check(jsonPath("$.id").saveAs("sessionId")))
|
||||||
|
|
||||||
|
val checkStatus = exec(http("Check Status").get("/status/${sessionId}").check(status.is(200),jsonPath("$.status").notNull))
|
||||||
|
|
||||||
|
val scn = scenario("Check Questions Status Completion").exec(requestQuestions).pause(1).asLongAsDuring(session => session("completed").asOption[Boolean].getOrElse(false) == false, 30.seconds) {
|
||||||
|
exec(checkStatus).pause(1).exec{ session => val status = session("status").asOption[String]
|
||||||
|
session.set("completed", status.isDefined && status.get != "null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUp(
|
||||||
|
scn.inject(atOnceUsers(10))
|
||||||
|
).protocols(httpProtocol);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.ninjaws.quiz.services;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import com.ninjaws.quiz.models.Session;
|
||||||
|
|
||||||
|
public class CacheServiceTest {
|
||||||
|
|
||||||
|
private CacheService cacheService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
cacheService = new CacheService();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAddToCache() {
|
||||||
|
String key = "session1";
|
||||||
|
Session session = new Session();
|
||||||
|
|
||||||
|
cacheService.addToCache(key, session);
|
||||||
|
|
||||||
|
assertNotNull(cacheService.getFromCache(key));
|
||||||
|
assertEquals(session, cacheService.getFromCache(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetFromCache() {
|
||||||
|
String key = "session1";
|
||||||
|
Session session = new Session();
|
||||||
|
|
||||||
|
cacheService.addToCache(key, session);
|
||||||
|
|
||||||
|
Session retrievedSession = cacheService.getFromCache(key);
|
||||||
|
assertNotNull(retrievedSession);
|
||||||
|
assertEquals(session, retrievedSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRemoveFromCache() {
|
||||||
|
String key = "session1";
|
||||||
|
Session session = new Session();
|
||||||
|
|
||||||
|
cacheService.addToCache(key, session);
|
||||||
|
cacheService.removeFromCache(key);
|
||||||
|
|
||||||
|
assertNull(cacheService.getFromCache(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetFromCacheWithNonExistentKey() {
|
||||||
|
assertNull(cacheService.getFromCache("nonExistentKey"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.ninjaws.quiz.services;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import com.ninjaws.quiz.models.QuizSettings;
|
||||||
|
import com.ninjaws.quiz.models.Request;
|
||||||
|
|
||||||
|
public class QueueServiceTest {
|
||||||
|
|
||||||
|
private QueueService queueService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
queueService = new QueueService();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAddToQueue() {
|
||||||
|
QuizSettings quizSettings = new QuizSettings();
|
||||||
|
Request request = new Request("session1", quizSettings);
|
||||||
|
|
||||||
|
boolean added = queueService.addToQueue(request);
|
||||||
|
assertTrue(added);
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
added = queueService.addToQueue(new Request("session" + (i + 2), new QuizSettings()));
|
||||||
|
assertTrue(added);
|
||||||
|
}
|
||||||
|
|
||||||
|
added = queueService.addToQueue(new Request("session6", new QuizSettings()));
|
||||||
|
assertFalse(added);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPopFromQueue() {
|
||||||
|
QuizSettings quizSettings1 = new QuizSettings();
|
||||||
|
QuizSettings quizSettings2 = new QuizSettings();
|
||||||
|
|
||||||
|
Request request1 = new Request("session1", quizSettings1);
|
||||||
|
Request request2 = new Request("session2", quizSettings2);
|
||||||
|
|
||||||
|
queueService.addToQueue(request1);
|
||||||
|
queueService.addToQueue(request2);
|
||||||
|
|
||||||
|
Request poppedRequest = queueService.popFromQueue();
|
||||||
|
assertEquals(request1, poppedRequest);
|
||||||
|
|
||||||
|
poppedRequest = queueService.popFromQueue();
|
||||||
|
assertEquals(request2, poppedRequest);
|
||||||
|
|
||||||
|
assertNull(queueService.popFromQueue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPopFromEmptyQueue() {
|
||||||
|
assertNull(queueService.popFromQueue());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: production
|
||||||
|
image: quiz-frontend-prod
|
||||||
|
container_name: quiz-frontend-prod-cont
|
||||||
|
ports:
|
||||||
|
- "4200:80"
|
||||||
|
networks:
|
||||||
|
- quiz-network
|
||||||
|
restart: always
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: production
|
||||||
|
image: quiz-backend-prod
|
||||||
|
container_name: quiz-backend-prod-cont
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
networks:
|
||||||
|
- quiz-network
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
networks:
|
||||||
|
quiz-network: # Define the network
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "Quiz Frontend",
|
||||||
|
|
||||||
|
"build": {
|
||||||
|
"context": "..",
|
||||||
|
"dockerfile": "../Dockerfile",
|
||||||
|
"args": {
|
||||||
|
// "WORKDIR": "/app"
|
||||||
|
},
|
||||||
|
"target": "development"
|
||||||
|
},
|
||||||
|
"runArgs": [
|
||||||
|
"--name=quiz-frontend-dev-cont",
|
||||||
|
"--net=host" // Allows it to be opened on the host network
|
||||||
|
],
|
||||||
|
"workspaceFolder": "/app",
|
||||||
|
"mounts": [
|
||||||
|
"source=.,target=/app,type=bind"
|
||||||
|
],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {},
|
||||||
|
"extensions": [
|
||||||
|
"christian-kohler.npm-intellisense",
|
||||||
|
"Angular.ng-template",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"Zignd.html-css-class-completion",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"ecmel.vscode-html-css",
|
||||||
|
"pranaygp.vscode-css-peek"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
.vscode/
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*
|
||||||
|
npm-debug.log
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
FROM node:22-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NG_CLI_ANALYTICS=ci
|
||||||
|
RUN npm install -g @angular/cli
|
||||||
|
|
||||||
|
#--- Development
|
||||||
|
FROM base AS development
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y git && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
# Switching user so it doesn't use root
|
||||||
|
# If root adds files, it takes ownership, making them unremovable on the host except by using sudo
|
||||||
|
# node tranfers ownership to the host user
|
||||||
|
USER node
|
||||||
|
EXPOSE 4200
|
||||||
|
CMD ["tail","-f","/dev/null"]
|
||||||
|
|
||||||
|
#--- Building
|
||||||
|
FROM base AS build
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
# Copied here, since the the Development setup uses a bind volume instead
|
||||||
|
COPY . .
|
||||||
|
RUN ng build --configuration production
|
||||||
|
|
||||||
|
#--- Production
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
COPY --from=build /app/dist/quiz/browser /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"quiz": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/quiz",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "2kB",
|
||||||
|
"maximumError": "4kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "quiz:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "quiz:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name quiz.ianvink.nl;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_read_timeout 360s;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# add_header 'Access-Control-Allow-Origin' '*'; # Adjust this to your frontend domain
|
||||||
|
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; # Include POST
|
||||||
|
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization'; # Include any other headers
|
||||||
|
|
||||||
|
proxy_pass http://backend:9090/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# server {
|
||||||
|
# listen 80;
|
||||||
|
# server_name quiz.ianvink.nl;
|
||||||
|
|
||||||
|
# location /.well-known/acme-challenge/ {
|
||||||
|
# root /usr/share/nginx/html; # Adjust this if needed
|
||||||
|
# }
|
||||||
|
|
||||||
|
# location / {
|
||||||
|
# return 301 https://$host$request_uri;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl;
|
||||||
|
# server_name quiz.example.com;
|
||||||
|
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/quiz.example.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/quiz.example.com/privkey.pem;
|
||||||
|
|
||||||
|
# location / {
|
||||||
|
# root /usr/share/nginx/html;
|
||||||
|
# index index.html;
|
||||||
|
# try_files $uri $uri/ /index.html;
|
||||||
|
# }
|
||||||
|
|
||||||
|
# location /api/ {
|
||||||
|
# proxy_pass http://backend:9090/; # Adjust this if needed
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
Generated
+14549
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "quiz",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^18.2.0",
|
||||||
|
"@angular/common": "^18.2.0",
|
||||||
|
"@angular/compiler": "^18.2.0",
|
||||||
|
"@angular/core": "^18.2.0",
|
||||||
|
"@angular/forms": "^18.2.0",
|
||||||
|
"@angular/platform-browser": "^18.2.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^18.2.0",
|
||||||
|
"@angular/router": "^18.2.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^18.2.10",
|
||||||
|
"@angular/cli": "^18.2.10",
|
||||||
|
"@angular/compiler-cli": "^18.2.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jasmine-core": "~5.2.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"typescript": "~5.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Answers {
|
||||||
|
sessionId: string;
|
||||||
|
answers: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface Question {
|
||||||
|
question: string;
|
||||||
|
answers: string[];
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
difficulty: string;
|
||||||
|
|
||||||
|
/** To make special characters visible */
|
||||||
|
question_decoded?: string;
|
||||||
|
answers_decoded?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Category } from "./category";
|
||||||
|
|
||||||
|
export interface QuizParams {
|
||||||
|
category: number;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
difficulty: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Score {
|
||||||
|
amount_questions: number;
|
||||||
|
amount_correct: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Question } from "./question";
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
statusCode: number;
|
||||||
|
questions: Question[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { SetupComponent } from './setup/setup.component';
|
||||||
|
import { QuizComponent } from './quiz/quiz.component';
|
||||||
|
import { ScoreComponent } from './score/score.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: '', component: SetupComponent },
|
||||||
|
{ path: 'quiz', component: QuizComponent },
|
||||||
|
{ path: 'score', component: ScoreComponent },
|
||||||
|
{ path: '**', redirectTo: '' }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class QuizRoutingModule { }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
import { QuizRoutingModule } from './quiz-routing.module';
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
QuizRoutingModule
|
||||||
|
],
|
||||||
|
providers: []
|
||||||
|
})
|
||||||
|
export class QuizModule { }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-100">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md w-80 overflow-y-scroll max-h-3/4 no-scrollbar">
|
||||||
|
<div *ngFor="let question of questions; let i = index" class="mb-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-2">{{ question.question_decoded }}</h2>
|
||||||
|
<div *ngFor="let answer of question.answers_decoded; let j = index">
|
||||||
|
<label class="block mb-1">
|
||||||
|
<input type="radio" name="question{{ i }}" [value]="answer" (change)="selectAnswer(i, question.answers[j])"
|
||||||
|
[checked]="selectedAnswers[i] === answer" class="mr-2" />
|
||||||
|
{{ answer }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button (click)="getScore()" [disabled]="!allQuestionsAnswered()"
|
||||||
|
class="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition disabled:opacity-50 ">
|
||||||
|
Get Score
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Score } from '../models/score';
|
||||||
|
import { Question } from '../models/question';
|
||||||
|
import { QuizService } from '../services/quiz.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-quiz',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './quiz.component.html',
|
||||||
|
styleUrl: './quiz.component.scss'
|
||||||
|
})
|
||||||
|
export class QuizComponent {
|
||||||
|
questions: Question[] = [];
|
||||||
|
selectedAnswers: { [key: number]: string } = {};
|
||||||
|
|
||||||
|
constructor(private router: Router, private quizService: QuizService) {
|
||||||
|
const navigation = this.router.getCurrentNavigation();
|
||||||
|
if (navigation?.extras?.state) {
|
||||||
|
this.questions = navigation.extras.state['questions'];
|
||||||
|
}else {
|
||||||
|
// You didn't come here through the proper channels, byebye
|
||||||
|
this.router.navigate(['']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAnswer(questionIndex: number, answer: string) {
|
||||||
|
this.selectedAnswers[questionIndex] = answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
allQuestionsAnswered(): boolean {
|
||||||
|
return Object.keys(this.selectedAnswers).length === this.questions.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getScore() {
|
||||||
|
const answers: string[] = []
|
||||||
|
for(let i = 0; i < Object.keys(this.selectedAnswers).length; i++) {
|
||||||
|
answers.push(this.selectedAnswers[i]);
|
||||||
|
}
|
||||||
|
this.quizService.getScore(answers)
|
||||||
|
.then((result:number) => {
|
||||||
|
const score: Score = {
|
||||||
|
amount_correct:result,
|
||||||
|
amount_questions: Object.keys(this.selectedAnswers).length
|
||||||
|
}
|
||||||
|
this.router.navigate(['/score'], { state: { score: score },skipLocationChange:true });
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert("Something went wrong");
|
||||||
|
});
|
||||||
|
|
||||||
|
// const sampleScore: Score = {
|
||||||
|
// amount_questions: 10,
|
||||||
|
// amount_correct: 7
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="flex flex-col items-center justify-center h-screen bg-gray-100">
|
||||||
|
<h1 class="text-4xl font-bold mb-4">Your Score</h1>
|
||||||
|
<p class="text-2xl">
|
||||||
|
{{ score.amount_correct }} / {{ score.amount_questions }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
(click)="playAgain()"
|
||||||
|
class="mt-6 bg-blue-500 text-white p-2 rounded hover:bg-blue-600">
|
||||||
|
Play Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Score } from '../models/score';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-score',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './score.component.html',
|
||||||
|
styleUrl: './score.component.scss'
|
||||||
|
})
|
||||||
|
export class ScoreComponent {
|
||||||
|
|
||||||
|
score !: Score;
|
||||||
|
|
||||||
|
constructor(private router: Router) {
|
||||||
|
const navigation = this.router.getCurrentNavigation();
|
||||||
|
if (navigation?.extras?.state) {
|
||||||
|
this.score = navigation.extras.state['score'];
|
||||||
|
}else {
|
||||||
|
// You didn't come here through the proper channels, byebye
|
||||||
|
this.router.navigate(['']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAgain() {
|
||||||
|
this.router.navigate(['']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { QuizParams } from '../models/quiz-params';
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
catchError,
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
interval,
|
||||||
|
lastValueFrom,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
take,
|
||||||
|
takeWhile,
|
||||||
|
} from 'rxjs';
|
||||||
|
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';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Category } from '../models/category';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class QuizService {
|
||||||
|
private apiUrl = environment.apiUrl + '/quiz';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
private storageService: StorageService,
|
||||||
|
private cleanerService: CleanerService,
|
||||||
|
private reponseHandler: ResponseHandlerService) {}
|
||||||
|
|
||||||
|
public async startSession(quizParams: QuizParams): Promise<Question[] | null> {
|
||||||
|
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);
|
||||||
|
const sessionObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId));
|
||||||
|
return this.reponseHandler.handleResponse(sessionObj.statusCode, sessionObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
public pollQuestions(sessionId: string) {
|
||||||
|
return interval(2000).pipe(
|
||||||
|
startWith(0),
|
||||||
|
switchMap(() => this.http.get<any>(this.apiUrl + `/status/${sessionId}`)),
|
||||||
|
// takeWhile((response) => response === null),
|
||||||
|
filter((response) => response !== null),
|
||||||
|
take(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getScore(answers: string[]) {
|
||||||
|
const answersJson: Answers = {
|
||||||
|
sessionId: this.storageService.getSession()!,
|
||||||
|
answers: answers
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new HttpHeaders({'Content-Type': 'application/json'});
|
||||||
|
const scoreObj = await firstValueFrom(this.http.post<any>(this.apiUrl+'/score', answersJson, { headers }));
|
||||||
|
return scoreObj.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertParamsToUrl(quizParams: QuizParams) {
|
||||||
|
let url = `?amount=${quizParams.amount}`;
|
||||||
|
const catIndex = quizParams.category;
|
||||||
|
url += catIndex !== 0 ? `&category=${catIndex}` : '';
|
||||||
|
|
||||||
|
const diff = quizParams.difficulty.toLowerCase();
|
||||||
|
url += diff !== 'any difficulty' ? `&difficulty=${diff}` : '';
|
||||||
|
|
||||||
|
const typeIndex = this.getTypes().indexOf(quizParams.type);
|
||||||
|
url +=
|
||||||
|
typeIndex !== 0
|
||||||
|
? typeIndex === 1
|
||||||
|
? '&type=multiple'
|
||||||
|
: '&type=boolean'
|
||||||
|
: '';
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCategories(): Promise<Category[]> {
|
||||||
|
let categories: Category[] = await firstValueFrom(this.http.get<any>(this.apiUrl + '/categories'));
|
||||||
|
categories.push({id:0,name:"Any Category"});
|
||||||
|
categories.sort((a,b) => a.id - b.id);
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDifficulties(): string[] {
|
||||||
|
return ['Any Difficulty', 'Easy', 'Medium', 'Hard'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTypes(): string[] {
|
||||||
|
return ['Any Type', 'Multiple choice', 'True/False'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class StorageService {
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSession(sessionId: string): void {
|
||||||
|
localStorage.setItem("sessionId", sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSession(): string | null {
|
||||||
|
return localStorage.getItem("sessionId");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-100">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md w-80">
|
||||||
|
<h1 class="text-2xl font-bold mb-6 text-center bg-red">Quiz App</h1>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="category" class="block mb-1">Category</label>
|
||||||
|
<select id="category" class="w-full p-2 border rounded" [(ngModel)]="category">
|
||||||
|
<option *ngFor="let category of categories" [value]="category.id">{{ category.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="difficulty" class="block mb-1">Difficulty</label>
|
||||||
|
<select id="difficulty" class="w-full p-2 border rounded" [(ngModel)]="difficulty">
|
||||||
|
<option *ngFor="let difficulty of quizService.getDifficulties()" class="focus:outline-none" [value]="difficulty">{{ difficulty }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="type" class="block mb-1">Type</label>
|
||||||
|
<select id="type" class="w-full p-2 border rounded" [(ngModel)]="type">
|
||||||
|
<option *ngFor="let type of quizService.getTypes()" [value]="type">{{ type }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="amount" class="block mb-1">Amount</label>
|
||||||
|
<input type="number" id="amount" [(ngModel)]="amount" min="1" max="50" class="w-full p-2 border rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition" (click)="startQuiz()">
|
||||||
|
Start Quiz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { QuizService } from '../services/quiz.service';
|
||||||
|
import { QuizParams } from '../models/quiz-params';
|
||||||
|
import { SpinnerService } from '../../Spinner/services/spinner.service';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Question } from '../models/question';
|
||||||
|
import { Category } from '../models/category';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-setup',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './setup.component.html',
|
||||||
|
styleUrl: './setup.component.scss',
|
||||||
|
})
|
||||||
|
export class SetupComponent {
|
||||||
|
|
||||||
|
categories!: Category[];
|
||||||
|
category!: number;
|
||||||
|
difficulty: string = "";
|
||||||
|
type: string = "";
|
||||||
|
amount: number = 10;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private router: Router,
|
||||||
|
public quizService: QuizService,
|
||||||
|
private spinnerService: SpinnerService
|
||||||
|
) {
|
||||||
|
quizService.getCategories().then((categories: Category[]) => {
|
||||||
|
/** Setting default values */
|
||||||
|
this.categories = categories;
|
||||||
|
this.category = this.categories.find(item => item.id == 0)!.id;
|
||||||
|
this.difficulty = this.quizService.getDifficulties()[0];
|
||||||
|
this.type = this.quizService.getTypes()[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startQuiz() {
|
||||||
|
const params: QuizParams = {
|
||||||
|
category: this.category,
|
||||||
|
type: this.type,
|
||||||
|
amount: this.amount,
|
||||||
|
difficulty: this.difficulty,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.spinnerService.setActive(true);
|
||||||
|
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);});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
gotoQuiz(questions: Question[]) {
|
||||||
|
this.router.navigate(['/quiz'], {
|
||||||
|
state: { questions: questions },
|
||||||
|
skipLocationChange: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class SpinnerService {
|
||||||
|
|
||||||
|
private isActive = false;
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
toggleSpinner() {
|
||||||
|
this.isActive = !this.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActive(active: boolean) {
|
||||||
|
new Promise(
|
||||||
|
(resolve) => setTimeout(resolve, 0)).then(
|
||||||
|
_ =>this.isActive = active
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSpinnerActive() {
|
||||||
|
return this.isActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: []
|
||||||
|
})
|
||||||
|
export class SpinnerModule { }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="spinner" *ngIf="spinnerService.isSpinnerActive()">
|
||||||
|
<div class="spinner-content">
|
||||||
|
<div class="lds-spinner"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
.lds-spinner {
|
||||||
|
color: green;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
.lds-spinner div {
|
||||||
|
transform-origin: 40px 40px;
|
||||||
|
animation: lds-spinner 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
.lds-spinner div:after {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 37px;
|
||||||
|
width: 6px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 20%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(1) {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
animation-delay: -1.1s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(2) {
|
||||||
|
transform: rotate(30deg);
|
||||||
|
animation-delay: -1s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(3) {
|
||||||
|
transform: rotate(60deg);
|
||||||
|
animation-delay: -0.9s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(4) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
animation-delay: -0.8s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(5) {
|
||||||
|
transform: rotate(120deg);
|
||||||
|
animation-delay: -0.7s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(6) {
|
||||||
|
transform: rotate(150deg);
|
||||||
|
animation-delay: -0.6s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(7) {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
animation-delay: -0.5s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(8) {
|
||||||
|
transform: rotate(210deg);
|
||||||
|
animation-delay: -0.4s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(9) {
|
||||||
|
transform: rotate(240deg);
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(10) {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
animation-delay: -0.2s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(11) {
|
||||||
|
transform: rotate(300deg);
|
||||||
|
animation-delay: -0.1s;
|
||||||
|
}
|
||||||
|
.lds-spinner div:nth-child(12) {
|
||||||
|
transform: rotate(330deg);
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
@keyframes lds-spinner {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
position: fixed; /* Stay in place */
|
||||||
|
z-index: var(--z-spinner);
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
height: 100%; /* Full height */
|
||||||
|
overflow: auto; /* Enable scroll if needed */
|
||||||
|
background-color: rgb(0,0,0); /* Fallback color */
|
||||||
|
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-content {
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
display:flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { animate, style, transition, trigger } from '@angular/animations';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { SpinnerService } from '../services/spinner.service';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-spinner',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './spinner.component.html',
|
||||||
|
styleUrl: './spinner.component.scss',
|
||||||
|
styles: [],
|
||||||
|
animations: [
|
||||||
|
trigger('fadeInOut', [
|
||||||
|
transition(':enter', [style({ opacity:0}), animate("300ms ease-in-out")]),
|
||||||
|
transition(':leave', [animate("300ms ease-in-out", style({ opacity: 0}))]),
|
||||||
|
])]
|
||||||
|
})
|
||||||
|
export class SpinnerComponent {
|
||||||
|
constructor(public spinnerService: SpinnerService) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
|
<app-spinner></app-spinner>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AppComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should have the 'quiz' title`, () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app.title).toEqual('quiz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, quiz');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { SpinnerComponent } from './Spinner/spinner/spinner.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, SpinnerComponent],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss'
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
title = 'quiz';
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient()]
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: '', loadChildren: () => import('./Quiz/quiz.module').then(m => m.QuizModule) }, // Lazy load QuizModule
|
||||||
|
// { path: '**', redirectTo: '/quiz' } // Handle unmatched routes
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
/** Connecting through the docker compose network is not working for now, so I'll have to use the localhost */
|
||||||
|
// apiUrl: 'http://backend:9090',
|
||||||
|
apiUrl: '/api' //'https://api.nosotros.ianvink.nl',
|
||||||
|
// apiUrl: 'http://localhost:9090',
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:9090'
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Quiz</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="question.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
@import "tailwindcss/base";
|
||||||
|
@import "tailwindcss/components";
|
||||||
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-root {
|
||||||
|
flex: 1; /* Makes the AppComponent fill the available space */
|
||||||
|
// display:flex;
|
||||||
|
// justify-content: center;
|
||||||
|
// align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// --z-base: 0;
|
||||||
|
// --z-dropdown: 100;
|
||||||
|
// --z-modal: 200;
|
||||||
|
// --z-tooltip: 300;
|
||||||
|
--z-spinner: 1800;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{html,ts}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user