Initial commit

This commit is contained in:
2020-08-28 19:19:33 +02:00
commit 504df2b15d
19 changed files with 690 additions and 0 deletions

22
LICENSE.md Normal file
View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2020 David Álvarez González
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.

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# WebQuizEngine
Spring Boot web service to create and solve quizzes using a REST API.
## Register user
To register a new user send a JSON with `email` and `password` via `POST` request to `/api/register`:
``` json
{
"email": "test@gmail.com",
"password": "secret"
}
```
If the email is already taken by another user, the service will return the 400 (Bad request) status code.
The email also must have a valid format (with @ and .) and the password must have at least five characters.
## Create quiz
In order to create a quiz send a JSON via `POST` request to `api/quizzes` with this format:
``` json
{
"title": "The Ultimate Question",
"text": "What is the answer to the Ultimate Question of Life, the Universe and Everything?",
"options": ["Everything goes right","42","2+2=4","11011100"],
"answer": [1]
}
```
The answer equals [1] corresponds to the second item from the options array (multiple choice questions are supported)
If the number of options in the quiz is less than 2 the server responds with `400 (Bad request)` status code.
If everything is correct, the server response is a JSON with four fields: id, title, text and options:
```json
{
"id": 1,
"title": "The Ultimate Question",
"text": "What is the answer to the Ultimate Question of Life, the Universe and Everything?",
"options": ["Everything goes right","42","2+2=4","11011100"]
}
```
## Get quizzes
Send a `GET` request to `api/quizzes/{id}` to get quiz by its id.
Send a `GET` request to `api/quizzes` to get all quizzes.
Response contains a JSON with quizzes (inside content) and some additional metadata, i.e:
```json
{
"totalPages":1,
"totalElements":3,
"last":true,
"first":true,
"sort":{ },
"number":0,
"numberOfElements":3,
"size":10,
"empty":false,
"pageable": { },
"content":[
{"id":102,"title":"Test 1","text":"Text 1","options":["a","b","c"]},
{"id":103,"title":"Test 2","text":"Text 2","options":["a", "b", "c", "d"]},
{"id":202,"title":"The Java Logo","text":"What is depicted on the Java logo?",
"options":["Robot","Tea leaf","Cup of coffee","Bug"]}
]
}
```
The API support the navigation through pages by passing the page parameter: `/api/quizzes?page=1`
## Solve quiz
For solving quiz send a JSON via `POST` request to `api/quizzes/{id}/solve`:
``` json
{
"answer": [2]
}
```
Where answer is the indexes of the correct answers, and id is the question id.
It is also possible to send an empty array [] since some quizzes may not have correct options.
The service returns a JSON with two fields: `success` (`true` or `false`) and `feedback` (just a string).
There are three possible responses:
- If the passed answer is correct:
`{"success":true,"feedback":"Congratulations, you're right!"}`
- If the answer is incorrect:
`{"success":false,"feedback":"Wrong answer! Please, try again."}`
- If the specified quiz does not exist, the server returns the `404 (Not found)` status code.
## Get completed quizzes
Send a `GET` request to `/api/quizzes/completed` together with the user auth data to get completed quizzes by user.
It is allowed to solve a quiz multiple times and completions are sorted from the most recent to the oldest.
Response example:
```json
{
"totalPages":1,
"totalElements":5,
"last":true,
"first":true,
"empty":false,
"content":[
{"id":103,"completedAt":"2019-10-29T21:13:53.779542"},
{"id":102,"completedAt":"2019-10-29T21:13:52.324993"},
{"id":101,"completedAt":"2019-10-29T18:59:58.387267"},
{"id":101,"completedAt":"2019-10-29T18:59:55.303268"},
{"id":202,"completedAt":"2019-10-29T18:59:54.033801"}
]
}
```
## Delete quiz
A user can delete their quiz by sending the `DELETE` request to `/api/quizzes/{id}`
If the operation was successful, the service returns the `204 (No content)` status code without any content.
If the specified quiz does not exist, the server returns `404 (Not found)`. If the specified user is not the author of this quiz, the response is the `403 (Forbidden)` status code.

24
build.gradle Normal file
View File

@@ -0,0 +1,24 @@
plugins {
id 'org.springframework.boot' version '2.2.2.RELEASE'
id 'java'
}
apply plugin: 'io.spring.dependency-management'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
sourceSets.main.resources.srcDirs = ["src/main/resources"]
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
runtimeOnly 'com.h2database:h2'
compile("org.springframework.boot:spring-boot-starter-web")
}

1
settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'webquizengine'

View File

@@ -0,0 +1,13 @@
package org.hyperskill.webquiz.engine;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class WebQuizEngine {
public static void main(String[] args) {
SpringApplication.run(WebQuizEngine.class, args);
}
}

View File

@@ -0,0 +1,55 @@
package org.hyperskill.webquiz.engine.controller;
import org.hyperskill.webquiz.engine.model.Quiz;
import org.hyperskill.webquiz.engine.dto.AnswerDto;
import org.hyperskill.webquiz.engine.dto.FeedbackDto;
import org.hyperskill.webquiz.engine.model.QuizCompleted;
import org.hyperskill.webquiz.engine.service.QuizService;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/quizzes")
public class QuizController {
private final QuizService quizService;
public QuizController(QuizService quizService) {
this.quizService = quizService;
}
@PostMapping
public ResponseEntity<Quiz> createQuiz(@Valid @RequestBody Quiz quiz) {
return ResponseEntity.ok(quizService.createQuiz(quiz));
}
@GetMapping
public ResponseEntity<Page<Quiz>> getQuizzes(@RequestParam(defaultValue = "0", required = false) Integer page) {
return ResponseEntity.ok(quizService.getAllQuizzes(page));
}
@GetMapping("/{id}")
public ResponseEntity<Quiz> getQuizById(@PathVariable Long id) {
return ResponseEntity.ok(quizService.getQuiz(id));
}
@PostMapping("/{id}/solve")
public ResponseEntity<FeedbackDto> solveQuiz(@PathVariable(value="id") Long id, @RequestBody AnswerDto answer){
return ResponseEntity.ok(quizService.solveQuiz(id, answer));
}
@GetMapping(path = "/completed")
public ResponseEntity<Page<QuizCompleted>> getCompletedQuestionForUser(@RequestParam(defaultValue = "0", required = false) Integer page) {
return ResponseEntity.ok(quizService.getCompletedQuestionForUser(page));
}
@DeleteMapping("/{id}")
public ResponseEntity<String> deleteQuiz(@PathVariable Long id) {
quizService.deleteQuiz(id);
return new ResponseEntity<>("Quiz deleted", HttpStatus.NO_CONTENT);
}
}

View File

@@ -0,0 +1,26 @@
package org.hyperskill.webquiz.engine.controller;
import org.hyperskill.webquiz.engine.model.User;
import org.hyperskill.webquiz.engine.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/register")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<String> registerUser(@RequestBody @Valid User user) {
userService.registerUser(user);
return new ResponseEntity<>("Registration successful", HttpStatus.OK);
}
}

View File

@@ -0,0 +1,16 @@
package org.hyperskill.webquiz.engine.dto;
import java.util.List;
public class AnswerDto {
private List<Integer> answer;
public List<Integer> getAnswer() {
return answer;
}
public void setAnswer(List<Integer> answer) {
this.answer = answer;
}
}

View File

@@ -0,0 +1,28 @@
package org.hyperskill.webquiz.engine.dto;
public class FeedbackDto {
private boolean success;
private String feedback;
public FeedbackDto(boolean success, String feedback) {
this.success = success;
this.feedback = feedback;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getFeedback() {
return feedback;
}
public void setFeedback(String feedback) {
this.feedback = feedback;
}
}

View File

@@ -0,0 +1,83 @@
package org.hyperskill.webquiz.engine.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.persistence.*;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.*;
@Entity
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Quiz {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Long id;
@NotNull
@NotBlank(message = "Title can't be empty")
private String title;
@NotNull
@NotBlank(message = "Text can't be empty")
private String text;
@NotNull
@Size(min = 2, message = "At least two options needed")
@ElementCollection
private List<String> options;
@ElementCollection
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private List<Integer> answer;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@ManyToOne
private User user;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public List<String> getOptions() {
return options;
}
public void setOptions(List<String> options) {
this.options = options;
}
public List<Integer> getAnswer() {
return answer;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
}

View File

@@ -0,0 +1,62 @@
package org.hyperskill.webquiz.engine.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
@Entity
public class QuizCompleted {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@JsonIgnore
private Long id;
@NotNull
@JsonProperty("id")
private Long questionId;
@NotNull
private LocalDateTime completedAt;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_email", nullable = false)
private User user;
public QuizCompleted() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getQuestionId() {
return questionId;
}
public void setQuestionId(Long questionId) {
this.questionId = questionId;
}
public LocalDateTime getCompletedAt() {
return completedAt;
}
public void setCompletedAt(LocalDateTime completedAt) {
this.completedAt = completedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
}

View File

@@ -0,0 +1,38 @@
package org.hyperskill.webquiz.engine.model;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
@Entity
public class User {
@Id
@NotNull
@Email(regexp = ".+@.+\\..+", message = "Given string is not a valid email")
private String email;
@NotNull
@Size(min = 5, message = "Password must be at least 5 characters long")
private String password;
public User() {
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,10 @@
package org.hyperskill.webquiz.engine.repository;
import org.hyperskill.webquiz.engine.model.QuizCompleted;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface QuizCompletedRepository extends JpaRepository<QuizCompleted, Long> {
Page<QuizCompleted> findByUserEmail(String email, Pageable pageable);
}

View File

@@ -0,0 +1,8 @@
package org.hyperskill.webquiz.engine.repository;
import org.hyperskill.webquiz.engine.model.Quiz;
import org.springframework.data.jpa.repository.JpaRepository;
public interface QuizRepository extends JpaRepository<Quiz, Long> {
}

View File

@@ -0,0 +1,8 @@
package org.hyperskill.webquiz.engine.repository;
import org.hyperskill.webquiz.engine.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, String> {
User findByEmail(String email);
}

View File

@@ -0,0 +1,34 @@
package org.hyperskill.webquiz.engine.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/h2-console/**").permitAll()
.antMatchers(HttpMethod.POST, "/api/register").permitAll()
.antMatchers(HttpMethod.POST, "/actuator/shutdown").permitAll()
.anyRequest().authenticated().and()
.csrf().disable()
.headers().frameOptions().disable().and()
.httpBasic().and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,86 @@
package org.hyperskill.webquiz.engine.service;
import org.hyperskill.webquiz.engine.model.Quiz;
import org.hyperskill.webquiz.engine.dto.AnswerDto;
import org.hyperskill.webquiz.engine.dto.FeedbackDto;
import org.hyperskill.webquiz.engine.model.QuizCompleted;
import org.hyperskill.webquiz.engine.repository.QuizCompletedRepository;
import org.hyperskill.webquiz.engine.repository.QuizRepository;
import org.hyperskill.webquiz.engine.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.ResponseStatus;
import java.time.LocalDateTime;
import java.util.HashSet;
@Service
public class QuizService {
private final QuizRepository quizRepository;
private final UserRepository userRepository;
private final QuizCompletedRepository quizCompletedRepository;
public QuizService(QuizRepository quizRepository, UserRepository userRepository, QuizCompletedRepository quizCompletedRepository) {
this.quizRepository = quizRepository;
this.userRepository = userRepository;
this.quizCompletedRepository = quizCompletedRepository;
}
@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "Quiz not found!")
static class QuizNotFoundException extends RuntimeException {
}
@ResponseStatus(code = HttpStatus.FORBIDDEN, reason = "You are not the creator of the quiz!")
static class QuizWrongUserException extends RuntimeException {
}
public Quiz createQuiz(Quiz quiz) {
quiz.setUser(userRepository.findByEmail(getUserMail()));
return quizRepository.save(quiz);
}
public Quiz getQuiz(Long id) {
return quizRepository.findById(id).orElseThrow(QuizNotFoundException::new);
}
public Page<Quiz> getAllQuizzes(Integer page) {
return quizRepository.findAll(PageRequest.of(page, 10));
}
public FeedbackDto solveQuiz(Long id, AnswerDto answer) {
Quiz quiz = quizRepository.findById(id).orElseThrow(QuizNotFoundException::new);
if (new HashSet<>(quiz.getAnswer()).equals(new HashSet<>(answer.getAnswer()))) {
QuizCompleted quizCompleted = new QuizCompleted();
quizCompleted.setQuestionId(quiz.getId());
quizCompleted.setCompletedAt(LocalDateTime.now());
quizCompleted.setUser(userRepository.findByEmail(getUserMail()));
quizCompletedRepository.save(quizCompleted);
return new FeedbackDto(true, "Congratulations, you're right!");
} else {
return new FeedbackDto(false, "Wrong answer! Please, try again.");
}
}
public Page<QuizCompleted> getCompletedQuestionForUser(Integer page) {
return quizCompletedRepository.findByUserEmail(getUserMail(), PageRequest.of(page, 10, Sort.by(Sort.Direction.DESC, "completedAt")));
}
public void deleteQuiz(Long id) {
Quiz quiz = quizRepository.findById(id).orElseThrow(QuizNotFoundException::new);
if (quiz.getUser().getEmail().equalsIgnoreCase(getUserMail())) {
quizRepository.deleteById(id);
} else {
throw new QuizWrongUserException();
}
}
private String getUserMail() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}

View File

@@ -0,0 +1,47 @@
package org.hyperskill.webquiz.engine.service;
import org.hyperskill.webquiz.engine.model.User;
import org.hyperskill.webquiz.engine.repository.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ResponseStatus;
import java.util.List;
@Component
public class UserService implements UserDetailsService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "Email already taken!")
static class EmailTakenException extends RuntimeException {
}
public void registerUser(User user) {
if (userRepository.existsById(user.getEmail())) {
throw new EmailTakenException();
}
user.setPassword(passwordEncoder.encode(user.getPassword()));
userRepository.save(user);
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email);
if (!userRepository.existsById(user.getEmail())) {
throw new UsernameNotFoundException(email + " not found");
}
return new org.springframework.security.core.userdetails.
User(user.getEmail(), user.getPassword(), List.of());
}
}

View File

@@ -0,0 +1,14 @@
server.port=8889
management.endpoints.web.exposure.include=*
management.endpoint.shutdown.enabled=true
spring.datasource.url=jdbc:h2:file:../quizdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true
spring.h2.console.settings.trace=false
spring.h2.console.settings.web-allow-others=false