DEV Community

Cover image for User Controllers, Services & File Upload (Day 4) - Creating a SaaS Startup in 30 Days
Sotiris Kourouklis
Sotiris Kourouklis

Posted on • Edited on • Originally published at sotergreco.com

User Controllers, Services & File Upload (Day 4) - Creating a SaaS Startup in 30 Days

Now that I have finished authentication on Spring Boot, I need to create the user table, controller, and services. Spring Boot has a very unique way of handling such tasks. I have to admit that it is a lot better than its competitors, and I really like the entire JVM ecosystem so far.

The only thing I dislike is that there is no hot-reload, and each time I have to rebuild the entire app. I think there has to be a tool or option, but I haven't figured it out yet.

Models

The annotations are a game-changer, and I like them a lot. I don't need to include or import the Model anywhere; it just has to have the correct annotations, and Spring Boot finds it and creates the table. So, there is no need for migrations so far.

Write in the comments if you are using migrations with Spring Boot?

Also, data classes are a Kotlin feature. In regular Java, you had to add an annotation called @data, but now we have that built into the language.

In addition as you can see in the second image the foreign keys are created automatically you just have to include OneOnOne annotation and the JoinColumn

@Entity
@Table(name = "users")
data class UserModel(
    @Id
    var id: String = UlidCreator.getUlid().toString(),
    var nickname: String = "",
    var firstname: String? = null,
    var lastname: String? = null,
    var userPassword: String? = null,
    var email: String? = null,
    var enabled: Boolean = true,
    var accountNonExpired: Boolean = true,
    var credentialsNonExpired: Boolean = true,
    var accountNonLocked: Boolean = true,

    @OneToOne
    @JoinColumn(name = "theme_id", referencedColumnName = "id")
    var theme: ThemeModel? = null,

    @OneToOne
    @JoinColumn(name = "avatar_media_id", referencedColumnName = "id")
    var avatar: MediaModel? = null,

    @Enumerated(EnumType.STRING)
    val role: RoleEnum = RoleEnum.ROLE_USER,

    ) : UserDetails {

    override fun getAuthorities(): Collection<GrantedAuthority> {
        return listOf(SimpleGrantedAuthority(role.name))
    }

    override fun isEnabled(): Boolean {
        return enabled
    }

    override fun isAccountNonExpired(): Boolean {
        return accountNonExpired
    }

    override fun isCredentialsNonExpired(): Boolean {
        return credentialsNonExpired
    }

    override fun isAccountNonLocked(): Boolean {
        return accountNonLocked
    }

    override fun getUsername(): String? {
        return email
    }

    override fun getPassword(): String? {
        return userPassword
    }
}
Enter fullscreen mode Exit fullscreen mode

Image description

Services

Like models services are defines with the Service annotation and you dependency inject the classes that you want to use. I want to mention here that Kotlin has a really cool pattern.

The Kotlin code snippet request.email?.let {user.email= it } is a common pattern for handling optional (nullable) values. It utilizes Kotlin's safe call operator (?.) and the let function to safely work with a nullable value.

That way you can move away from if statements and you can use a lambda function with the let function to handle null values.

@Service
class UserService(
    private val userRepository: UserRepository,
    private val mediaService: MediaService,
    private val themeRepository: ThemeRepository
) {
    fun patch(id: String, request: UserRequest): UserResponse {
        val user = userRepository.findById(id).orElseThrow {
            throw IllegalStateException("User not found")
        }

        request.email?.let { user.email = it }
        request.nickname?.let { user.nickname = it }
        request.firstname?.let { user.firstname = it }
        request.lastname?.let { user.lastname = it }
        request.themeId?.let {
            val theme = themeRepository.findById(it).orElseThrow {
                throw ResponseStatusException(HttpStatus.NOT_FOUND, "Theme not found")
            }
            user.theme = theme
        }

        userRepository.save(user)

        return UserResponse(user)
    }

    fun deleteAvatar(id: String): UserResponse {
        val user = userRepository.findById(id).orElseThrow {
            throw ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")
        }

        user.avatar?.let { mediaService.deleteMedia(it.id) }
        user.avatar = null
        userRepository.save(user)

        return UserResponse(user)
    }

    fun uploadAvatar(id: String, file: MultipartFile): UserResponse {
        val user = userRepository.findById(id).orElseThrow {
            throw ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")
        }

        user.avatar?.let { mediaService.deleteMedia(it.id) }

        val media = mediaService.saveMedia(file, "user")
        user.avatar = media
        userRepository.save(user)

        return UserResponse(user)
    }

    fun get(id: String): UserResponse {
        val user = userRepository.findById(id).orElseThrow() {
            throw ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")
        }

        return UserResponse(user)
    }

}
Enter fullscreen mode Exit fullscreen mode

Controllers

Now that we have created our service, we can utilize it inside the UserController. As you can see in my code, we use annotations as expected, and we specify the paths as well. Also, I have created interfaces for the Request and the Response.

This is a really simple structure that is readable and self-documented.

@RestController
@RequestMapping("/api/v1/user")
class UserController(
    private val userService: UserService
) {
    @PatchMapping("/{id}")
    fun patch(
        @RequestBody request: UserRequest,
        @PathVariable id: String,
    ): ResponseEntity<UserResponse> {
        return ResponseEntity.ok(userService.patch(id, request))
    }

    @PostMapping("/{id}/avatar")
    fun uploadAvatar(
        @PathVariable id: String,
        @RequestBody avatar: MultipartFile,
    ): ResponseEntity<UserResponse> {
        return ResponseEntity.ok(userService.uploadAvatar(id, avatar))
    }

    @DeleteMapping("/{id}/avatar")
    fun deleteAvatar(
        @PathVariable id: String,
    ): ResponseEntity<UserResponse> {
        return ResponseEntity.ok(userService.deleteAvatar(id))
    }

    @GetMapping("/{id}")
    fun get(
        @PathVariable id: String,
    ): ResponseEntity<UserResponse> {
        return ResponseEntity.ok(userService.get(id))
    }
}
Enter fullscreen mode Exit fullscreen mode

File Upload Service

Here you can see on of the functions in my media service. I want you to take a look at 2 things. The first is how we handle exceptions it is really easy and works well.

fun saveMedia(file: MultipartFile, folder: String): MediaModel {
        val saveFolder = "$mediaFolder/$folder"
        val saveFolderFile = File(saveFolder)
        val fileExt = file.originalFilename?.substringAfterLast(".") ?: throw IllegalArgumentException("Invalid file name")
        val filename = generateMediaName(fileExt)
        if (!saveFolderFile.exists()) {
            saveFolderFile.mkdirs()
        }
        val filePath = File(saveFolder, filename)

        file.inputStream.use { input ->
            filePath.outputStream().use { output ->
                input.copyTo(output)
            }
        }

        val media = MediaModel(
            filename = filename,
            folder = folder,
            contentType = file.contentType ?: throw IllegalArgumentException("Invalid content type"),
        )

        return mediaRepository.save(media)
    }
Enter fullscreen mode Exit fullscreen mode

Secondly, the way we handle the input stream with lambda functions is really unique. This is the same thing in Node.js, and as you can see, it is really complex. Even in python and PHP it is hard to do something similar.

function copyFile(inputPath, outputPath) {
    const inputStream = fs.createReadStream(inputPath);
    const outputStream = fs.createWriteStream(outputPath);

    inputStream.pipe(outputStream);

    // Optional: Handle stream events
    inputStream.on('error', (err) => {
        console.error('Error reading input file:', err);
    });

    outputStream.on('error', (err) => {
        console.error('Error writing output file:', err);
    });

    outputStream.on('finish', () => {
        console.log('File copied successfully.');
    });
}

const inputPath = 'input.txt'; // Specify your input file path
const outputPath = path.join('output', 'output.txt'); // Specify your output file path

copyFile(inputPath, outputPath);
Enter fullscreen mode Exit fullscreen mode

Conclusion

In conclusion, developing a SaaS startup with Spring Boot and Kotlin offers a robust and efficient way to handle user management and file uploads. The use of annotations and Kotlin's safe handling of nullable values simplifies the codebase while enhancing readability and maintainability.

Despite the lack of hot-reload in Spring Boot, its powerful ecosystem and straightforward approach to database modeling and service management make it a strong candidate for rapid development cycles.

As we continue to build out our startup, these tools will undoubtedly play a crucial role in our ability to deliver a scalable and user-friendly product.

Thanks for reading, and I hope you found this article helpful. If you have any questions, feel free to email me at kourouklis@pm.me, and I will respond.

You can also keep up with my latest updates by checking out my X here: x.com/sotergreco

Top comments (0)