Ajout du support des images dans l'API REST (incompatible avec la version précédente)

This commit is contained in:
FyloZ 2021-02-06 22:24:43 -05:00
parent bb8c0cb4c5
commit c38552d703
5 changed files with 266 additions and 34 deletions

View File

@ -10,11 +10,11 @@ import dev.fyloz.trial.colorrecipesexplorer.service.EmployeeUserDetailsServiceIm
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import org.slf4j.Logger
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Lazy
import org.springframework.core.env.Environment
import org.springframework.http.HttpMethod
import org.springframework.security.authentication.AuthenticationManager
@ -57,13 +57,12 @@ import javax.servlet.http.HttpServletResponse
class WebSecurityConfig(
val restAuthenticationEntryPoint: RestAuthenticationEntryPoint,
val securityConfigurationProperties: SecurityConfigurationProperties,
@Lazy val userDetailsService: EmployeeUserDetailsServiceImpl,
@Lazy val employeeService: EmployeeServiceImpl,
val environment: Environment,
val logger: Logger
) : WebSecurityConfigurerAdapter() {
@Autowired
private lateinit var userDetailsService: EmployeeUserDetailsServiceImpl
@Autowired
private lateinit var employeeService: EmployeeServiceImpl
var debugMode = false
override fun configure(authBuilder: AuthenticationManagerBuilder) {
authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder())
@ -98,7 +97,7 @@ class WebSecurityConfig(
}
@PostConstruct
fun createSystemUsers() {
fun initWebSecurity() {
fun createUser(
credentials: SecurityConfigurationProperties.SystemUserCredentials?,
firstName: String,
@ -124,6 +123,8 @@ class WebSecurityConfig(
}
createUser(securityConfigurationProperties.root, "Root", "User", listOf(EmployeePermission.ADMIN))
debugMode = "debug" in environment.activeProfiles
if (debugMode) logger.warn("Debug mode is enabled, security will be disabled!")
}
override fun configure(http: HttpSecurity) {
@ -145,13 +146,6 @@ class WebSecurityConfig(
.headers().frameOptions().disable()
.and()
.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/").permitAll()
.antMatchers("/api/login").permitAll()
.antMatchers("/api/employee/logout").permitAll()
.antMatchers(HttpMethod.GET, "/api/employee/current").authenticated()
.generateAuthorizations()
.and()
.addFilter(
JwtAuthenticationFilter(
authenticationManager(),
@ -167,6 +161,18 @@ class WebSecurityConfig(
)
)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
if (!debugMode) {
http.authorizeRequests()
.antMatchers(HttpMethod.GET, "/").permitAll()
.antMatchers("/api/login").permitAll()
.antMatchers("/api/employee/logout").permitAll()
.antMatchers(HttpMethod.GET, "/api/employee/current").authenticated()
.generateAuthorizations()
} else {
http.authorizeRequests()
.antMatchers("**").permitAll()
}
}
}
@ -179,20 +185,6 @@ class RestAuthenticationEntryPoint : AuthenticationEntryPoint {
) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
}
class CorsFilter : Filter {
override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
response as HttpServletResponse
response.setHeader("Access-Control-Allow-Origin", "http://localhost:4200")
response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "*")
response.setHeader("Access-Control-Allow-Credentials", true.toString())
response.setHeader("Access-Control-Max-Age", 180.toString())
chain.doFilter(request, response)
}
}
const val authorizationCookieName = "Authorization"
const val defaultGroupCookieName = "Default-Group"
val blacklistedJwtTokens = mutableListOf<String>()
@ -207,7 +199,6 @@ class JwtAuthenticationFilter(
init {
setFilterProcessesUrl("/api/login")
debugMode = "debug" in environment.activeProfiles
if (debugMode) logger.warn("Debug mode is enabled, cookies will not be secured!")
}
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {

View File

@ -9,7 +9,6 @@ import javax.persistence.*
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
import javax.validation.constraints.Size
private const val RECIPE_ID_NULL_MESSAGE = "Un identifiant est requis"
private const val RECIPE_NAME_NULL_MESSAGE = "Un nom est requis"

View File

@ -2,12 +2,14 @@ package dev.fyloz.trial.colorrecipesexplorer.rest
import dev.fyloz.trial.colorrecipesexplorer.model.*
import dev.fyloz.trial.colorrecipesexplorer.service.MixService
import dev.fyloz.trial.colorrecipesexplorer.service.RecipeImageService
import dev.fyloz.trial.colorrecipesexplorer.service.RecipeService
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.net.URI
import javax.validation.Valid
@ -21,13 +23,43 @@ class RecipeController(recipeService: RecipeService) :
recipeService,
RECIPE_CONTROLLER_PATH
) {
@PutMapping("public")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto): ResponseEntity<Void> {
service.updatePublicData(publicDataDto)
return ResponseEntity.noContent().build()
}
}
@RestController
@RequestMapping(RECIPE_CONTROLLER_PATH)
class RecipeImageController(val recipeImageService: RecipeImageService) {
@GetMapping("{recipeId}/image")
@ResponseStatus(HttpStatus.OK)
fun getAllIdsForRecipe(@PathVariable recipeId: Long): ResponseEntity<Collection<Long>> =
ResponseEntity.ok(recipeImageService.getAllIdsForRecipe(recipeId))
@GetMapping("{recipeId}/image/{id}", produces = [MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE])
@ResponseStatus(HttpStatus.OK)
fun getById(@PathVariable recipeId: Long, @PathVariable id: Long): ResponseEntity<ByteArray> =
ResponseEntity.ok(recipeImageService.getByIdForRecipe(id, recipeId))
@PostMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@ResponseStatus(HttpStatus.CREATED)
fun save(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity<Void> {
val id = recipeImageService.save(image, recipeId)
return ResponseEntity.created(URI.create("$RECIPE_CONTROLLER_PATH/$recipeId/image/$id")).build()
}
@DeleteMapping("{recipeId}/image/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(@PathVariable recipeId: Long, @PathVariable id: Long): ResponseEntity<Void> {
recipeImageService.delete(id, recipeId)
return ResponseEntity.noContent().build()
}
}
@RestController
@RequestMapping(MIX_CONTROLLER_PATH)
class MixController(val mixService: MixService) :

View File

@ -1,10 +1,15 @@
package dev.fyloz.trial.colorrecipesexplorer.service
import dev.fyloz.trial.colorrecipesexplorer.exception.model.EntityNotFoundRestException
import dev.fyloz.trial.colorrecipesexplorer.model.*
import dev.fyloz.trial.colorrecipesexplorer.model.validation.isNotNullAndNotBlank
import dev.fyloz.trial.colorrecipesexplorer.model.validation.or
import dev.fyloz.trial.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.trial.colorrecipesexplorer.service.files.FilesService
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.nio.file.NoSuchFileException
import kotlin.contracts.ExperimentalContracts
interface RecipeService : ExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeRepository> {
@ -94,3 +99,63 @@ class RecipeServiceImpl(
override fun removeMix(mix: Mix): Recipe =
update(mix.recipe.apply { mixes.remove(mix) })
}
const val RECIPE_IMAGES_DIRECTORY = "images/recipe"
interface RecipeImageService {
fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray
/** Gets the identifier of every images associated to the recipe with the given [recipeId]. */
fun getAllIdsForRecipe(recipeId: Long): Collection<Long>
/** Saves the given [image] and associate it to the recipe with the given [recipeId]. Returns the identifier of the saved image. */
fun save(image: MultipartFile, recipeId: Long): Long
/** Deletes the image with the given [recipeId] and [id]. */
fun delete(id: Long, recipeId: Long)
}
@Service
class RecipeImageServiceImpl(val recipeService: RecipeService, val filesService: FilesService) : RecipeImageService {
override fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray =
try {
filesService.readAsBytes(getPath(id, recipeId))
} catch (ex: NoSuchFileException) {
throw EntityNotFoundRestException("$recipeId/$id")
}
override fun getAllIdsForRecipe(recipeId: Long): Collection<Long> {
val recipe = recipeService.getById(recipeId)
val recipeDirectory = getRecipeDirectory(recipe.id!!)
if (!recipeDirectory.exists() || !recipeDirectory.isDirectory) {
return listOf()
}
return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory is a directory and exists before
.filterNotNull()
.map { it.name.toLong() }
}
override fun save(image: MultipartFile, recipeId: Long): Long {
/** Gets the next id available for a new image for the recipe with the given [recipeId]. */
fun getNextAvailableId(): Long =
with(getAllIdsForRecipe(recipeId)) {
if (isEmpty())
0
else
maxOrNull()!! + 1L // maxOrNull() cannot return null because existingIds cannot be empty at this point
}
val nextAvailableId = getNextAvailableId()
filesService.write(image, getPath(nextAvailableId, recipeId))
return nextAvailableId
}
override fun delete(id: Long, recipeId: Long) =
filesService.delete(getPath(id, recipeId))
/** Gets the images directory of the recipe with the given [recipeId]. */
fun getRecipeDirectory(recipeId: Long) = File(filesService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId"))
/** Gets the file of the image with the given [recipeId] and [id]. */
fun getPath(id: Long, recipeId: Long): String = filesService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId/$id")
}

View File

@ -1,10 +1,17 @@
package dev.fyloz.trial.colorrecipesexplorer.service
import com.nhaarman.mockitokotlin2.*
import dev.fyloz.trial.colorrecipesexplorer.exception.model.EntityNotFoundRestException
import dev.fyloz.trial.colorrecipesexplorer.model.*
import dev.fyloz.trial.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.trial.colorrecipesexplorer.service.files.FilesService
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.mock.web.MockMultipartFile
import java.io.File
import java.nio.file.NoSuchFileException
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ -131,3 +138,141 @@ class RecipeServiceTest :
}
}
}
class RecipeImageServiceTest {
private val recipeService: RecipeService = mock()
private val fileService: FilesService = mock()
private val service = spy(RecipeImageServiceImpl(recipeService, fileService))
private val recipeId = 1L
private val imageId = 5L
private val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$imageId"
private val recipe = recipe(id = recipeId)
private val recipeDirectory: File = mock()
private val imagesIds = listOf(1L, 3L, 10L, 21L)
private val imageData = byteArrayOf(64, 32, 16, 8, 4, 2, 1)
private val image = MockMultipartFile("$imageId", imageData)
@AfterEach
internal fun tearDown() {
reset(recipeService, fileService, service, recipeDirectory)
}
@Nested
inner class GetByIdForRecipe {
@Test
fun `returns data for the given recipe and image id red by the file service`() {
whenever(fileService.getPath(imagePath)).doReturn(imagePath)
whenever(fileService.readAsBytes(imagePath)).doReturn(imageData)
val found = service.getByIdForRecipe(imageId, recipeId)
assertEquals(imageData, found)
}
@Test
fun `throws EntityNotFoundRestException when no image with the given recipe and image id exists`() {
doReturn(imagePath).whenever(service).getPath(imageId, recipeId)
whenever(fileService.readAsBytes(imagePath)).doThrow(NoSuchFileException(imagePath))
val exception =
assertThrows<EntityNotFoundRestException> { service.getByIdForRecipe(imageId, recipeId) }
assertEquals("$recipeId/$imageId", exception.value)
}
}
@Nested
inner class GetAllIdsForRecipe {
@Test
fun `returns a list containing all image's identifier of the images of the given recipe`() {
val expectedFiles = imagesIds.map { File(it.toString()) }.toTypedArray()
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(true)
whenever(recipeDirectory.isDirectory).doReturn(true)
whenever(recipeDirectory.listFiles()).doReturn(expectedFiles)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val found = service.getAllIdsForRecipe(recipeId)
assertEquals(imagesIds, found)
}
@Test
fun `returns an empty list when the given recipe's directory does not exists`() {
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(false)
whenever(recipeDirectory.isDirectory).doReturn(true)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val found = service.getAllIdsForRecipe(recipeId)
assertTrue(found.isEmpty())
}
@Test
fun `returns an empty list when the given recipe's directory is not a directory`() {
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(true)
whenever(recipeDirectory.isDirectory).doReturn(false)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val found = service.getAllIdsForRecipe(recipeId)
assertTrue(found.isEmpty())
}
}
@Nested
inner class Save {
@Test
fun `writes the given image to the file service with the expected path`() {
val expectedNextAvailableId = imagesIds.maxOrNull()!! + 1
val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$expectedNextAvailableId"
doReturn(imagesIds).whenever(service).getAllIdsForRecipe(recipeId)
doReturn(imagePath).whenever(service).getPath(expectedNextAvailableId, recipeId)
service.save(image, recipeId)
verify(fileService).write(image, imagePath)
}
}
@Nested
inner class Delete {
@Test
fun `deletes the image with the given recipe and image id from the file service`() {
doReturn(imagePath).whenever(service).getPath(imageId, recipeId)
service.delete(imageId, recipeId)
verify(fileService).delete(imagePath)
}
}
@Nested
inner class GetRecipeDirectory {
@Test
fun `returns a file with the expected path`() {
val recipeDirectoryPath = "$RECIPE_IMAGES_DIRECTORY/$recipeId"
whenever(fileService.getPath(recipeDirectoryPath)).doReturn(recipeDirectoryPath)
val found = service.getRecipeDirectory(recipeId)
assertEquals(recipeDirectoryPath, found.path)
}
}
@Nested
inner class GetPath {
@Test
fun `returns the expected path`() {
whenever(fileService.getPath(any())).doAnswer { it.arguments[0] as String }
val found = service.getPath(imageId, recipeId)
assertEquals(imagePath, found)
}
}
}