From 048c11ee6252f36dd440d555ca3010a7e85d9ab3 Mon Sep 17 00:00:00 2001 From: Sambo Chea Date: Thu, 21 Apr 2022 11:57:44 +0700 Subject: [PATCH] Add uploader and file storage provider with openapi docs and demo for modules --- .../com/cubetiqs/web/config/JpaConfig.kt | 10 ++ .../cubetiqs/web/modules/ModuleInitializer.kt | 20 ++++ .../web/modules/uploader/FileResponse.kt | 8 ++ .../modules/uploader/FileStorageFactory.kt | 37 +++++++ .../uploader/FileStorageLocalProvider.kt | 54 +++++++++++ .../modules/uploader/FileStorageProvider.kt | 9 ++ .../modules/uploader/UploaderController.kt | 77 +++++++++++++-- .../web/modules/uploader/UploaderEntity.kt | 97 +++++++++++++++++++ .../web/modules/user/UserController.kt | 32 +++++- api/src/main/resources/application.yml | 2 + 10 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 api/src/main/kotlin/com/cubetiqs/web/config/JpaConfig.kt create mode 100644 api/src/main/kotlin/com/cubetiqs/web/modules/ModuleInitializer.kt create mode 100644 api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileResponse.kt create mode 100644 api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageFactory.kt create mode 100644 api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageLocalProvider.kt create mode 100644 api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageProvider.kt diff --git a/api/src/main/kotlin/com/cubetiqs/web/config/JpaConfig.kt b/api/src/main/kotlin/com/cubetiqs/web/config/JpaConfig.kt new file mode 100644 index 0000000..c42c868 --- /dev/null +++ b/api/src/main/kotlin/com/cubetiqs/web/config/JpaConfig.kt @@ -0,0 +1,10 @@ +package com.cubetiqs.web.config + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@ConditionalOnProperty(prefix = "spring.datasource", name = ["enabled"], havingValue = "true") +@Configuration +@EnableJpaAuditing +class JpaConfig \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/ModuleInitializer.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/ModuleInitializer.kt new file mode 100644 index 0000000..b0b2985 --- /dev/null +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/ModuleInitializer.kt @@ -0,0 +1,20 @@ +package com.cubetiqs.web.modules + +import com.cubetiqs.web.modules.uploader.FileStorageFactory +import com.cubetiqs.web.modules.uploader.FileStorageLocalProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +@Component +@Lazy(false) +class ModuleInitializer constructor( + @Value("\${module.uploader.local.path:./uploads}") + private val fileBasePath: String, +) { + init { + FileStorageFactory.setProvider( + FileStorageLocalProvider(fileBasePath) + ) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileResponse.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileResponse.kt new file mode 100644 index 0000000..d555e8d --- /dev/null +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileResponse.kt @@ -0,0 +1,8 @@ +package com.cubetiqs.web.modules.uploader + +import java.io.File + +open class FileResponse( + open var file: File? = null, + open var shortPath: String? = null, +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageFactory.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageFactory.kt new file mode 100644 index 0000000..32ca75f --- /dev/null +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageFactory.kt @@ -0,0 +1,37 @@ +package com.cubetiqs.web.modules.uploader + +import org.springframework.web.multipart.MultipartFile +import java.io.File + +object FileStorageFactory { + private var provider: FileStorageProvider? = null + + fun setProvider(provider: FileStorageProvider) { + this.provider = provider + } + + fun getProvider(): FileStorageProvider { + return provider ?: throw IllegalStateException("FileStorageProvider is not set") + } + + fun store(file: File): FileResponse { + return getProvider().store(file) + } + + fun store(file: MultipartFile): FileResponse { + val tempPath = System.getProperty("java.io.tmpdir") ?: if (System.getProperty("os.name").lowercase() + .contains("win") + ) "C:\\Windows\\Temp" else "/tmp" + val temp = File("$tempPath/${file.originalFilename}") + file.transferTo(temp) + return this.store(temp) + } + + fun delete(fileName: String) { + getProvider().delete(fileName) + } + + fun get(fileName: String): FileResponse { + return getProvider().get(fileName) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageLocalProvider.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageLocalProvider.kt new file mode 100644 index 0000000..c21b26a --- /dev/null +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageLocalProvider.kt @@ -0,0 +1,54 @@ +package com.cubetiqs.web.modules.uploader + +import java.io.File +import java.io.FileNotFoundException + +open class FileStorageLocalProvider( + private val basePath: String, +) : FileStorageProvider { + private fun loadBasePath(fileName: String): String { + val prefixPath = if (basePath.endsWith("/")) { + "" + } else { + "/" + } + + return basePath + prefixPath + fileName + } + + override fun store(file: File): FileResponse { + if (!file.exists()) { + throw FileNotFoundException("File not found") + } + + val path = loadBasePath(file.name) + val savedFile = file.copyTo(File(path), true) + return FileResponse( + file = savedFile, + shortPath = path, + ) + } + + override fun get(fileName: String): FileResponse { + val path = loadBasePath(fileName) + val file = File(path) + if (!file.exists()) { + throw FileNotFoundException("File $fileName not found") + } + + return FileResponse( + file = file, + shortPath = path, + ) + } + + override fun delete(fileName: String) { + val path = loadBasePath(fileName) + val file = File(path) + if (file.isFile) { + file.delete() + } else { + throw IllegalArgumentException("File $fileName not found") + } + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageProvider.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageProvider.kt new file mode 100644 index 0000000..7d3da70 --- /dev/null +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/FileStorageProvider.kt @@ -0,0 +1,9 @@ +package com.cubetiqs.web.modules.uploader + +import java.io.File + +interface FileStorageProvider { + fun store(file: File): FileResponse + fun get(fileName: String): FileResponse + fun delete(fileName: String) +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderController.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderController.kt index 3a55fec..3c97023 100644 --- a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderController.kt +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderController.kt @@ -1,14 +1,19 @@ package com.cubetiqs.web.modules.uploader import com.cubetiqs.web.util.RouteConstants +import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springdoc.core.converters.models.PageableAsQueryParam import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.http.MediaType +import org.springframework.util.FileCopyUtils import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile import java.util.* +import javax.servlet.http.HttpServletResponse @UploaderModule @Tag(name = "Uploader Controller") @@ -19,6 +24,7 @@ class UploaderController @Autowired constructor( ) { @GetMapping @PageableAsQueryParam + @Operation(summary = "Get all files") fun getAll( @Parameter(hidden = true) pageable: Pageable?, @@ -26,35 +32,88 @@ class UploaderController @Autowired constructor( return repository.findAll(pageable ?: Pageable.unpaged()) } - @ResponseStatus(value = org.springframework.http.HttpStatus.CREATED) - @PostMapping - fun create( - @RequestBody body: UploaderEntity + @ResponseStatus(value = org.springframework.http.HttpStatus.OK) + @GetMapping("/{id}") + @Operation(summary = "Get a file by id") + fun get( + @PathVariable id: String, ): UploaderEntity { - return repository.save(body) + val entity = repository.findById(UUID.fromString(id)).orElseThrow { + throw IllegalArgumentException("File not found") + } + return repository.save(entity) + } + + @ResponseStatus(value = org.springframework.http.HttpStatus.OK) + @GetMapping("/{id}/stream", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + @Operation(summary = "Get file stream by id") + fun stream( + @PathVariable id: String, + @RequestParam(required = false, value = "download") download: Boolean?, + response: HttpServletResponse, + ) { + val entity = repository.findById(UUID.fromString(id)).orElseThrow { + throw IllegalArgumentException("User not found") + } + + if (!entity.isFileExists()) { + throw IllegalArgumentException("File not found") + } + + val file = entity.getFile() ?: throw IllegalArgumentException("File source not found") + val disposition = if (download == true) { + "attachment" + } else { + "inline" + } + + response.setHeader("Content-Disposition", "$disposition; filename=\"${entity.filename}\"") + response.contentType = entity.contentType + response.setContentLengthLong(file.length()) + + FileCopyUtils.copy(file.readBytes(), response.outputStream) + } + + @ResponseStatus(value = org.springframework.http.HttpStatus.CREATED) + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @Operation(summary = "Upload a file") + fun create( + @RequestPart file: MultipartFile, + ): UploaderEntity { + val entity = UploaderEntity.fromFile(file) + return repository.save(entity) } @ResponseStatus(value = org.springframework.http.HttpStatus.OK) @PutMapping("/{id}") + @Operation(summary = "Update a file by id") fun update( @PathVariable id: String, @RequestBody body: UploaderEntity ): UploaderEntity { - val user = repository.findById(UUID.fromString(id)).orElseThrow { + val entity = repository.findById(UUID.fromString(id)).orElseThrow { throw IllegalArgumentException("File not found") } - body.id = user.id + body.id = entity.id return repository.save(body) } @ResponseStatus(value = org.springframework.http.HttpStatus.NO_CONTENT) @DeleteMapping("/{id}") + @Operation(summary = "Delete a file by id") fun delete( @PathVariable id: String, ) { - val user = repository.findById(UUID.fromString(id)).orElseThrow { + val entity = repository.findById(UUID.fromString(id)).orElseThrow { throw IllegalArgumentException("File not found") } - repository.delete(user) + repository.delete(entity) + } + + @ResponseStatus(value = org.springframework.http.HttpStatus.NO_CONTENT) + @DeleteMapping + @Operation(summary = "Delete all files") + fun deleteAll() { + repository.deleteAll() } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderEntity.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderEntity.kt index f3dfdb5..dcbb823 100644 --- a/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderEntity.kt +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/uploader/UploaderEntity.kt @@ -1,6 +1,13 @@ package com.cubetiqs.web.modules.uploader +import com.fasterxml.jackson.annotation.JsonIgnore import org.hibernate.Hibernate +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import org.springframework.web.multipart.MultipartFile +import java.io.File +import java.io.InputStream import java.io.Serializable import java.util.* import javax.persistence.* @@ -8,6 +15,7 @@ import javax.persistence.* @UploaderModule @Entity @Table(name = "uploader") +@EntityListeners(AuditingEntityListener::class) open class UploaderEntity( @Id @GeneratedValue(strategy = GenerationType.AUTO) @@ -27,12 +35,40 @@ open class UploaderEntity( @Temporal(TemporalType.TIMESTAMP) @Column(name = "created_at") + @CreatedDate open var createdAt: Date? = null, @Temporal(TemporalType.TIMESTAMP) @Column(name = "updated_at") + @LastModifiedDate open var updatedAt: Date? = null, + + @Column(length = 30) + open var providerType: String? = null, ) : Serializable { + @Transient + @JsonIgnore + private var partFile: MultipartFile? = null + + @Transient + @JsonIgnore + private var file: File? = null + + @Transient + fun isFileExists(): Boolean { + val temp = getFile() + return temp?.exists() ?: false + } + + @Transient + @JsonIgnore + fun getFile(): File? { + if (file == null) { + file = File(path ?: return null) + } + return file + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false @@ -42,4 +78,65 @@ open class UploaderEntity( } override fun hashCode(): Int = javaClass.hashCode() + + companion object { + fun fromFile(file: MultipartFile): UploaderEntity { + // transfer to file storage first + val store = FileStorageFactory.store(file) + val uploader = UploaderEntity() + uploader.partFile = file + uploader.providerType = "local" + uploader.filename = file.originalFilename + uploader.contentType = file.contentType + uploader.contentLength = file.size + uploader.path = store.shortPath + return uploader + } + + fun fromUploader(uploader: UploaderEntity): MultipartFile? { + if (uploader.partFile != null) { + return uploader.partFile + } + + val file = try { + File(uploader.path!!) + } catch (ex: Exception) { + null + } ?: return null + + return object : MultipartFile { + override fun getInputStream(): InputStream { + return file.inputStream() + } + + override fun getName(): String { + return file.name + } + + override fun getOriginalFilename(): String? { + return uploader.filename + } + + override fun getContentType(): String? { + return uploader.contentType + } + + override fun isEmpty(): Boolean { + return file.length() == 0L + } + + override fun getSize(): Long { + return file.length() + } + + override fun getBytes(): ByteArray { + return file.readBytes() + } + + override fun transferTo(dest: File) { + file.copyTo(dest) + } + } + } + } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/cubetiqs/web/modules/user/UserController.kt b/api/src/main/kotlin/com/cubetiqs/web/modules/user/UserController.kt index ba211e8..479ef97 100644 --- a/api/src/main/kotlin/com/cubetiqs/web/modules/user/UserController.kt +++ b/api/src/main/kotlin/com/cubetiqs/web/modules/user/UserController.kt @@ -1,6 +1,7 @@ package com.cubetiqs.web.modules.user import com.cubetiqs.web.util.RouteConstants +import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springdoc.core.converters.models.PageableAsQueryParam @@ -19,6 +20,7 @@ class UserController @Autowired constructor( ) { @GetMapping @PageableAsQueryParam + @Operation(summary = "Get all users") fun getAll( @Parameter(hidden = true) pageable: Pageable?, @@ -26,8 +28,21 @@ class UserController @Autowired constructor( return repository.findAll(pageable ?: Pageable.unpaged()) } + @ResponseStatus(value = org.springframework.http.HttpStatus.OK) + @GetMapping("/{id}") + @Operation(summary = "Get a user by id") + fun get( + @PathVariable id: String, + ): UserEntity { + val entity = repository.findById(UUID.fromString(id)).orElseThrow { + throw IllegalArgumentException("User not found") + } + return repository.save(entity) + } + @ResponseStatus(value = org.springframework.http.HttpStatus.CREATED) @PostMapping + @Operation(summary = "Create a user") fun create( @RequestBody body: UserEntity ): UserEntity { @@ -36,25 +51,34 @@ class UserController @Autowired constructor( @ResponseStatus(value = org.springframework.http.HttpStatus.OK) @PutMapping("/{id}") + @Operation(summary = "Update a user by id") fun update( @PathVariable id: String, @RequestBody body: UserEntity ): UserEntity { - val user = repository.findById(UUID.fromString(id)).orElseThrow { + val entity = repository.findById(UUID.fromString(id)).orElseThrow { throw IllegalArgumentException("User not found") } - body.id = user.id + body.id = entity.id return repository.save(body) } @ResponseStatus(value = org.springframework.http.HttpStatus.NO_CONTENT) @DeleteMapping("/{id}") + @Operation(summary = "Delete user by id") fun delete( @PathVariable id: String, ) { - val user = repository.findById(UUID.fromString(id)).orElseThrow { + val entity = repository.findById(UUID.fromString(id)).orElseThrow { throw IllegalArgumentException("User not found") } - repository.delete(user) + repository.delete(entity) + } + + @ResponseStatus(value = org.springframework.http.HttpStatus.NO_CONTENT) + @DeleteMapping + @Operation(summary = "Delete all users") + fun deleteAll() { + repository.deleteAll() } } \ No newline at end of file diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml index d438a01..1780b3c 100644 --- a/api/src/main/resources/application.yml +++ b/api/src/main/resources/application.yml @@ -34,6 +34,8 @@ module: enabled: ${MODULE_USER_ENABLED:true} uploader: enabled: ${MODULE_UPLOADER_ENABLED:true} + local: + path: ${MODULE_UPLOADER_FILE_PATH:${cubetiq.app.data-dir}/uploads} cubetiq: app: