Ajout du support des images dans l'API REST (incompatible avec la version précédente)
This commit is contained in:
parent
bb8c0cb4c5
commit
c38552d703
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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) :
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue