3
\$\begingroup\$

I'm new to Kotlin and wanted to give this mini project a try. Please let me know if you have any improvements in mind. I'm thinking error handling, structuring the module, is there too much going on in the main function that I should handle elsewhere, is the logging handled correctly (it works, but it's a bit odd to put it into the constructor, no?).

The project:

  • calls an API
  • iterates over the json data
  • saves each map in a separate json file and assigns a unique name

Main.kt

import ApiCall.ApiCall
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import ApiCall.MonsterData
import ApiCall.Utils
import java.io.IOException

fun main() {
    val api = ApiCall()
    val utils = Utils()

    try {
        val result = api.callApi("https://botw-compendium.herokuapp.com/api/v3/compendium/category/monsters")
        if (api.handleResponse(result)) {

            val monsters: String = result.body()
            val mapper = ObjectMapper()
            val monsterData: MonsterData = mapper.readValue(monsters)

            val path = "/Users/me"
            utils.createDirectory(path)

            for (monster in monsterData.data) {
                val monsterName = monster.name
                    .replace(" ", "_")

                // Convert the monster object to JSON string
                val monsterJson = mapper.writeValueAsString(monster)

                // Write the JSON data to a file named after the monster
                val fileName = "$path/$monsterName.json"

                utils.writeFiles(fileName, monsterJson)
            }
        }
    } catch (e: IOException) {
        api.logger.error("$e")
    }
}

ApiCall.kt

package ApiCall

import org.slf4j.Logger
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.http.HttpResponse.BodyHandlers
import org.slf4j.LoggerFactory

class ApiCall(private val client: HttpClient = HttpClient.newHttpClient(),
              val logger: Logger = LoggerFactory.getLogger(ApiCall::class.java)) {

    fun callApi(url: String): HttpResponse<String> {
        val request = HttpRequest.newBuilder(URI.create(url))
            .header("accept", "application/json")
            .build()

        return client.send(request, BodyHandlers.ofString())
    }

    fun handleResponse(response: HttpResponse<String>): Boolean {
        val statusCode = response.statusCode()
        return when (statusCode) {
            200 -> {
                logger.info("Status 200. API is available")
                true
            }
            in 400..499 -> {
                logger.warn("Status $statusCode. Client Error – client sent an invalid request")
                false
            }
            in 500..599 -> {
                logger.error("Status $statusCode. Protocol Error – a generic error occurred on the server")
                false
            }
            else -> {
                logger.warn("Unknown status code: $statusCode")
                false
            }
        }
    }
}

Monsters.kt

package ApiCall

data class Monster(
    val category: String = "",
    val common_locations: List<String>? = emptyList(),
    val description: String = "",
    val dlc: Boolean = false,
    val drops: List<String>? = emptyList(),
    val id: Int = 0,
    val image: String = "",
    val name: String = ""
)

data class MonsterData(
    val data: List<Monster> = emptyList()
)

Utils.kt (those are just wrappers for existing functions -> maybe a bit of an overkill?)

package ApiCall

import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.Charset
import java.nio.file.Files
import java.nio.file.Paths

class Utils {

    fun createDirectory(path: String) {
        Files.createDirectories(Paths.get(path));
    }

    fun writeFiles(fileName: String, jsonString: String) {
        PrintWriter(FileWriter(fileName, Charset.defaultCharset()))
            .use { it.write(jsonString) }
    }
}
\$\endgroup\$
3
  • \$\begingroup\$ I have to ask: What is the purpose of this code? I can see what it does, but... why did you write it? Just to learn some Kotlin programming or to actually use the data for something ? \$\endgroup\$ Aug 6, 2023 at 17:55
  • 1
    \$\begingroup\$ To learn some Kotlin programming. Won't use the data for anything. \$\endgroup\$ Aug 6, 2023 at 18:16
  • \$\begingroup\$ I have rolled back Rev 2 → 1. Please see What to do when someone answers. \$\endgroup\$ Aug 8, 2023 at 22:18

3 Answers 3

3
\$\begingroup\$

I'm new to Kotlin ...

Well, you know more about Kotlin than I do, so I'll approach this strictly from a software engineering perspective.


import ApiCall.ApiCall
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import ApiCall.MonsterData
import ApiCall.Utils

If your editor supports M-x sort-lines then recommend you do that or similar. Or use an IDE or other tool that manages such administrivia for you. Similarly on the slf4j imports.

We do this to make it easier to read, and to minimize merge conflicts when one feature branch added an import and another added a different import. If everyone agrees on the "obvious" sequence for such statements, then they slot in seemlessly. Similarly when defining manifest constants, members of a set, or any other repetitive source code where the ordering is arbitrary so you may as well pick a convenient ordering.


handle errors, or don't

What is this all about?

    try {
        ...
    } catch (e: IOException) {
        api.logger.error("$e")
    }

Only java has the "checked exceptions" anti-feature, so it couldn't be that.

Am I to understand that failure to catch an IOException would silently take down this whole client process? Without logging anything? I have trouble believing that. No idea why you are "handling" an error with this logging boilerplate, as it doesn't seem to be your problem. Let the next layer up handle it.

        if (api.handleResponse(result)) {

OTOH I see no else clause here. Consider logging that an unexpected thing ("bad result!") happened. Consider eliding the if altogether -- if we throw, we throw, c'est la vie. As long as a diagnostic message is displayed which makes sense to the user, then we're good.


specifying types

            val monsters: String = result.body()
            val mapper = ObjectMapper()
            val monsterData: MonsterData = mapper.readValue(monsters)

Am I to understand that ObjectMapper offers good enough type hinting that your tool chain was able to infer it, but result.body() has a bunch of overloads or is otherwise vague on what it returns? IDK, maybe the third one is idiomatic kotlin, better than an explicit as cast? Not sure which form would be best for the Gentle Reader in this situation.


trusted inputs

                val fileName = "$path/$monsterName.json"

I will just point out that $monsterName is a string that came from the internet, in other words it is "attacker controlled data". Now result comes from a hard-coded URL, whose contents you have reviewed and which doesn't (currently) contain crazy names that have .. or / in them. You have a trust relationship with that web publisher. If the names change in future, this code snippet could be used to overwrite /etc/passwd or other important files.


In handleResponse the when is nice enough; you can certainly leave it as-is.

But it seems like a "200" vs "not 200", true vs false if would suffice.


a non-null empty list

data class Monster(
    ...
    val common_locations: List<String>? = emptyList(),
    ...
    val drops: List<String>? = emptyList(),

Maybe this is perfect as-is.

But consider insisting that there always be a (possibly zero length) string list for these. Even if the web publisher sometimes omits mentioning them.

Imposing such a class invariant can make code that calls you simpler, so a whole category of NPE bugs disappears.


This code appears to achieve its design goals.

I would be willing to delegate or accept maintenance tasks on it.

\$\endgroup\$
1
  • \$\begingroup\$ Oh very cool. Thanks for the feedback! \$\endgroup\$ Aug 6, 2023 at 13:01
4
\$\begingroup\$

The convention in Java and Kotlin is that package names are lowercase and represent a domain you control backwards, e.g. com.example.hello.world. If you don't have a domain, you can use your github account or similar, e.g. com.github.username.project


Using Jackson for serialization is perfectly fine, and I do too in many of my Kotlin projects but I would like to highlight that kotlinx.serialization might also be an option. The benefit of kotlinx serialization is that it is cross-platform, and not just restricted to running on the JVM.


The Utils class is indeed unnecessary, and it can be an object btw instead of a class so that you don't have to create a new instance of it.

There are much simpler ways to write to a file in Kotlin, by using Kotlin's extension functions on the File class

Additionally, the Jackson ObjectMapper class has a writeValue function that allows you to directly print to a File or OutputStream. No need to write as String first and then to a file.


I would recommend using ktor instead of old Java classes to make HTTP requests.

\$\endgroup\$
1
  • \$\begingroup\$ Thanks for taking the time to review my code. I'll implement your feedback. \$\endgroup\$ Aug 6, 2023 at 18:45
2
\$\begingroup\$

Improved version:

  • I replaced Jackson kotlin serialization and used ktor for the HTTP request
  • renamed the package
  • improved error handling to include more specific errors and removed the if statement for status codes
  • sanitised the monster names to remove special characters such as /
  • only specified types where necessary

main.kt

import com.github.me.botwapi.MonsterData
import com.github.me.botwapi.Utils
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.RedirectResponseException
import io.ktor.client.plugins.ServerResponseException
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Paths
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.slf4j.Logger
import org.slf4j.LoggerFactory

fun main() = runBlocking {
    val logger: Logger = LoggerFactory.getLogger("MainLogger")

    val path = "/Users/me/Documents/projects/BotwApi/botwMonsters"
    val api_url = "https://botw-compendium.herokuapp.com/api/v3/compendium/category/monsters"
    val client = HttpClient(CIO) {
        expectSuccess = true
    }

    try {
        val response = client.get(api_url)
        val statusCode = response.status.value

        logger.info("API returned status code: $statusCode. Downloading data from $api_url")
        val monsters = response.bodyAsText()

        val monsterData = Json.decodeFromString<MonsterData>(monsters)

        withContext(Dispatchers.IO) {
            Files.createDirectories(Paths.get(path))
        }

        for (monster in monsterData.data) {
            val monsterName = monster.name
                .replace(" ", "_")

            val sanitisedMonsterName = Utils.sanitiseFileName(monsterName)

            val fileName = "$path/$sanitisedMonsterName.json"

            withContext(Dispatchers.IO) {
                val json = Json { prettyPrint = true }
                File(fileName).writeText(json.encodeToString(monster))
            }
        }

        logger.info("Data written to $path")

    } catch (e: IOException) {
        logger.error("Failed to fetch or process monster data: ${e.message}")
        System.exit(1)
    } catch (e: ClientRequestException) {
        logger.error(e.message)
        System.exit(1)
    } catch (e: RedirectResponseException) {
        logger.error(e.message)
        System.exit(1)
    } catch (e: ServerResponseException) {
        logger.error(e.message)
        System.exit(1)
    }
}

Utils.kt

package com.github.me.botwapi

object Utils {

    fun sanitiseFileName(fileName: String): String {
        val allowedPattern = Regex("[^a-zA-Z0-9_-]")

        return allowedPattern.replace(fileName, "")
    }
}

Monster.kt

package com.github.me.botwapi
import kotlinx.serialization.Serializable

@Serializable
data class Monster(
    val category: String = "",
    val common_locations: List<String?>? = emptyList(),
    val description: String = "",
    val dlc: Boolean = false,
    val drops: List<String?>? = emptyList(),
    val id: Int = 0,
    val image: String = "",
    val name: String = ""
)

@Serializable
data class MonsterData(
    val data: List<Monster> = emptyList()
)
\$\endgroup\$
3
  • \$\begingroup\$ But you did not review the provided solution (which is the aim here) \$\endgroup\$ Aug 9, 2023 at 3:10
  • \$\begingroup\$ @BillalBegueradj what? I'm OP. I received feedback and this is what I made of it. I simply followed the instructions here: codereview.stackexchange.com/help/someone-answers \$\endgroup\$ Aug 9, 2023 at 7:52
  • \$\begingroup\$ Indeed, you are right. Sorry. I thought this improved version is from a person different than the OP. Sorry, +2 \$\endgroup\$ Aug 9, 2023 at 7:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.