Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: make CsvParser a @Service #662

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ abstract class CsvExportError(
keyValues.add(Pair("exception", exception))
}
}

class CsvExportException(
val errors: Iterable<CsvExportError>,
) : Throwable("Unable to convert string to csv, because the string had ${errors.count()} error(s).")
83 changes: 37 additions & 46 deletions server/src/main/kotlin/fi/oph/kitu/csvparsing/CsvParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,38 @@ import com.fasterxml.jackson.dataformat.csv.CsvSchema
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import fi.oph.kitu.logging.add
import org.ietf.jgss.Oid
import org.slf4j.spi.LoggingEventBuilder
import org.springframework.stereotype.Service
import java.io.ByteArrayOutputStream
import java.lang.RuntimeException
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation

@Service
class CsvParser(
val event: LoggingEventBuilder,
val columnSeparator: Char = ',',
val lineSeparator: String = "\n",
val useHeader: Boolean = false,
val quoteChar: Char = '"',
) {
init {
event.add(
"serialization.schema.args.columnSeparator" to columnSeparator.toString(),
"serialization.schema.args.lineSeparator" to lineSeparator,
"serialization.schema.args.useHeader" to useHeader,
"serialization.schema.args.quoteChar" to quoteChar,
)
}

inline fun <reified T> getSchema(csvMapper: CsvMapper): CsvSchema {
event.add("serialization.schema.args.type" to T::class.java.name)

return csvMapper
.typedSchemaFor(T::class.java)
fun with(
columnSeparator: Char = ',',
lineSeparator: String = "\n",
useHeader: Boolean = false,
quoteChar: Char = '"',
) = CsvParser(columnSeparator, lineSeparator, useHeader, quoteChar)

fun getSchema(
csvMapper: CsvMapper,
type: KClass<*>,
): CsvSchema =
csvMapper
.typedSchemaFor(type.java)
.withColumnSeparator(columnSeparator)
.withLineSeparator(lineSeparator)
.withUseHeader(useHeader)
.withQuoteChar(quoteChar)
}

inline fun <reified T> CsvMapper.Builder.withFeatures(): CsvMapper.Builder {
val mapperFeatures = T::class.findAnnotation<Features>()?.features
private fun CsvMapper.Builder.withFeatures(type: KClass<*>): CsvMapper.Builder {
val mapperFeatures = type.findAnnotation<Features>()?.features
if (mapperFeatures != null) {
for (feature in mapperFeatures) {
this.enable(feature)
Expand All @@ -51,28 +49,29 @@ class CsvParser(
return this
}

fun CsvMapper.withModules(): CsvMapper {
this.registerModule(JavaTimeModule())
val oidSerializerModule = SimpleModule()
oidSerializerModule.addSerializer(Oid::class.java, OidSerializer())
this.registerModule(oidSerializerModule)
private fun CsvMapper.withModules(): CsvMapper {
this.registerModules(
JavaTimeModule(),
SimpleModule().addSerializer(Oid::class.java, OidSerializer()),
)

return this
}

inline fun <reified T> getCsvMapper(): CsvMapper =
private fun getCsvMapper(type: KClass<*>): CsvMapper =
CsvMapper
.builder()
.withFeatures<T>()
.withFeatures(type)
.build()
.withModules()

inline fun <reified T> streamDataAsCsv(
fun <T : Any> streamDataAsCsv(
outputStream: ByteArrayOutputStream,
data: Iterable<T>,
type: KClass<T>,
) {
val csvMapper: CsvMapper = getCsvMapper<T>()
val schema = getSchema<T>(csvMapper)
val csvMapper: CsvMapper = getCsvMapper(type)
val schema = getSchema(csvMapper, type)

csvMapper
.writerFor(Iterable::class.java)
Expand All @@ -83,23 +82,23 @@ class CsvParser(
/**
* Converts retrieved String response into a list that is the type of Body.
*/
inline fun <reified T> convertCsvToData(csvString: String): List<T> {
fun <T : Any> convertCsvToData(
csvString: String,
type: KClass<T>,
): List<T> {
if (csvString.isBlank()) {
event.add("serialization.isEmptyList" to true)
return emptyList()
}

event.add("serialization.isEmptyList" to false)

val csvMapper = getCsvMapper<T>()
val schema = getSchema<T>(csvMapper)
val csvMapper = getCsvMapper(type)
val schema = getSchema(csvMapper, type)

// the lines are needed to read line by line in order to distinguish all erroneous lines
val errors = mutableListOf<CsvExportError>()

val iterator =
csvMapper
.readerFor(T::class.java)
.readerFor(type.java)
.with(schema)
.readValues<T?>(csvString)

Expand All @@ -115,15 +114,7 @@ class CsvParser(
return data
}

// add all errors to log
errors.forEachIndexed { i, error ->
event.add("serialization.error[$i].index" to i)
for (kvp in error.keyValues) {
event.add("serialization.error[$i].${kvp.first}" to kvp.second)
}
}

throw RuntimeException("Unable to convert string to csv, because the string had ${errors.count()} error(s).")
throw CsvExportException(errors)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package fi.oph.kitu.csvparsing

import com.fasterxml.jackson.dataformat.csv.CsvMapper
import com.fasterxml.jackson.dataformat.csv.CsvSchema
import fi.oph.kitu.logging.add
import org.slf4j.spi.LoggingEventBuilder
import kotlin.reflect.KClass

class CsvParserWithEvent(
private val event: LoggingEventBuilder,
columnSeparator: Char,
lineSeparator: String,
useHeader: Boolean,
quoteChar: Char,
) : CsvParser(columnSeparator, lineSeparator, useHeader, quoteChar) {
init {
event.add(
"serialization.schema.args.columnSeparator" to columnSeparator.toString(),
"serialization.schema.args.lineSeparator" to lineSeparator,
"serialization.schema.args.useHeader" to useHeader,
"serialization.schema.args.quoteChar" to quoteChar,
)
}

override fun getSchema(
csvMapper: CsvMapper,
type: KClass<*>,
): CsvSchema {
event.add("serialization.schema.args.type" to type::class.java.name)
return super.getSchema(csvMapper, type)
}

override fun <T : Any> convertCsvToData(
csvString: String,
type: KClass<T>,
): List<T> {
event.add("serialization.isEmptyList" to csvString.isBlank())
return try {
super.convertCsvToData(csvString, type)
} catch (ex: CsvExportException) {
// add all errors to log
ex.errors.forEachIndexed { i, error ->
event.add("serialization.error[$i].index" to i)
for (kvp in error.keyValues) {
event.add("serialization.error[$i].${kvp.first}" to kvp.second)
}
}

throw ex
}
}
}

fun CsvParser.withEvent(event: LoggingEventBuilder) =
CsvParserWithEvent(
event,
columnSeparator,
lineSeparator,
useHeader,
quoteChar,
)
20 changes: 14 additions & 6 deletions server/src/main/kotlin/fi/oph/kitu/yki/YkiService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package fi.oph.kitu.yki

import fi.oph.kitu.PeerService
import fi.oph.kitu.csvparsing.CsvParser
import fi.oph.kitu.csvparsing.withEvent
import fi.oph.kitu.logging.Logging
import fi.oph.kitu.logging.add
import fi.oph.kitu.logging.addHttpResponse
Expand Down Expand Up @@ -34,6 +35,7 @@ class YkiService(
private val suoritusMapper: YkiSuoritusMappingService,
private val arvioijaRepository: YkiArvioijaRepository,
private val arvioijaMapper: YkiArvioijaMappingService,
private val parser: CsvParser,
) {
private val logger: Logger = LoggerFactory.getLogger(javaClass)
private val auditLogger: Logger = Logging.auditLogger()
Expand All @@ -46,7 +48,7 @@ class YkiService(
logger
.atInfo()
.withEventAndPerformanceCheck { event ->
val parser = CsvParser(event)

event.add("dryRun" to dryRun, "lastSeen" to lastSeen)

val url =
Expand All @@ -64,7 +66,11 @@ class YkiService(

event.addHttpResponse(PeerService.Solki, "suoritukset", response)

val suoritukset = parser.convertCsvToData<YkiSuoritusCsv>(response.body ?: "")
val suoritukset =
parser.withEvent(event).convertCsvToData(
response.body ?: "",
YkiSuoritusCsv::class,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

En tykkää, että mun ratkaisulla piti ottaa genericsit pois. Yritän vielä keksiä tälle jonkun ratkaisun.

)

if (dryRun != true) {
val res = suoritusRepository.saveAll(suoritusMapper.convertToEntityIterable(suoritukset))
Expand All @@ -80,7 +86,6 @@ class YkiService(
logger
.atInfo()
.withEventAndPerformanceCheck { event ->
val parser = CsvParser(event)
val response =
solkiRestClient
.get()
Expand All @@ -91,8 +96,9 @@ class YkiService(
event.addHttpResponse(PeerService.Solki, "arvioijat", response)

val arvioijat =
parser.convertCsvToData<SolkiArvioijaResponse>(
parser.withEvent(event).convertCsvToData(
response.body ?: throw Error.EmptyArvioijatResponse(),
SolkiArvioijaResponse::class,
)

event.add("yki.arvioijat.receivedCount" to arvioijat.size)
Expand All @@ -116,12 +122,14 @@ class YkiService(
logger
.atInfo()
.withEventAndPerformanceCheck { event ->
val parser = CsvParser(event, useHeader = true)
val suoritukset = allSuoritukset(includeVersionHistory)
event.add("dataCount" to suoritukset.count())
val writableData = suoritusMapper.convertToResponseIterable(suoritukset)
val outputStream = ByteArrayOutputStream()
parser.streamDataAsCsv(outputStream, writableData)
parser
.with(useHeader = true)
.withEvent(event)
.streamDataAsCsv(outputStream, writableData, YkiSuoritusCsv::class)

return@withEventAndPerformanceCheck outputStream
}.apply {
Expand Down
Loading
Loading