diff --git a/planet/src/main/kotlin/ext/Ext.kt b/planet/src/main/kotlin/ext/Ext.kt index 2a790312..fe233fa2 100644 --- a/planet/src/main/kotlin/ext/Ext.kt +++ b/planet/src/main/kotlin/ext/Ext.kt @@ -1,278 +1,156 @@ -@file:OptIn(ExperimentalStdlibApi::class) - -package ext - -import icu.samnyan.aqua.net.utils.ApiException -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.serialization.kotlinx.json.* -import jakarta.persistence.Query -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.apache.tika.Tika -import org.apache.tika.mime.MimeTypes -import org.slf4j.LoggerFactory -import org.springframework.context.ApplicationContext -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity.BodyBuilder -import org.springframework.web.bind.annotation.* -import java.io.File -import java.lang.reflect.Field -import java.nio.charset.StandardCharsets -import java.nio.file.Path -import java.security.MessageDigest -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.ZoneOffset.UTC -import java.time.format.DateTimeFormatter -import java.util.* -import java.util.concurrent.locks.Lock -import kotlin.reflect.KCallable -import kotlin.reflect.KClass -import kotlin.reflect.KMutableProperty1 -import kotlin.reflect.full.declaredMemberProperties -import kotlin.reflect.full.isSubclassOf -import kotlin.reflect.full.memberProperties -import kotlin.reflect.jvm.javaField -import kotlin.reflect.jvm.jvmErasure - -typealias RP = RequestParam -typealias RB = RequestBody -typealias RT = RequestPart -typealias RH = RequestHeader -typealias PV = PathVariable -typealias API = RequestMapping -typealias Var = KMutableProperty1 -typealias Str = String -typealias Bool = Boolean -typealias JavaSerializable = java.io.Serializable - -typealias JDict = Map -typealias MutJDict = MutableMap - -fun HttpServletRequest.details() = mapOf( - "method" to method, - "uri" to requestURI, - "query" to queryString, - "remote" to remoteAddr, - "headers" to headerNames.asSequence().associateWith { getHeader(it) } -) - -fun HttpServletResponse.details() = mapOf( - "status" to status, - "headers" to headerNames.asSequence().associateWith { getHeader(it) }, -) - -@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER) -@Retention(AnnotationRetention.RUNTIME) -annotation class Doc( - val desc: String, - val ret: String = "" -) - -@Target(AnnotationTarget.PROPERTY) -@Retention(AnnotationRetention.RUNTIME) -annotation class SettingField( - val game: String -) - -// Reflection -@Suppress("UNCHECKED_CAST") -fun KClass.ownVars() = declaredMemberProperties.sortedBy { it.javaField?.declaringClass?.declaredFields?.indexOf(it.javaField) ?: Int.MAX_VALUE }.mapNotNull { it as? Var } -@Suppress("UNCHECKED_CAST") -fun KClass.vars(): List> = supertypes.mapNotNull { it.classifier as? KClass<*> }.filter { !it.java.isInterface }.flatMap{ it.vars() as List> } + ownVars() -fun KClass.varsMap() = vars().associateBy { it.name } -fun KClass.getters() = java.methods.filter { it.name.startsWith("get") } -fun KClass.gettersMap() = getters().associateBy { it.name.removePrefix("get").firstCharLower() } -infix fun KCallable<*>.returns(type: KClass<*>) = returnType.jvmErasure.isSubclassOf(type) -@Suppress("UNCHECKED_CAST") -fun Var.setCast(obj: C, value: String) = set(obj, when (returnType.classifier) { - String::class -> value - Int::class -> value.toInt() - Boolean::class -> value.toBoolean() - else -> 400 - "Invalid field type $returnType" -} as T) -inline fun Field.gets(obj: Any): T? = get(obj)?.let { it as T } - -// HTTP -operator fun HttpStatus.invoke(message: String? = null): Nothing = throw ApiException(value(), message ?: this.reasonPhrase) -operator fun Int.minus(message: String): Nothing { - ApiException.log.info("> Error $this: $message") - throw ApiException(this, message) -} -fun parsing(block: () -> R) = try { block() } -catch (e: ApiException) { throw e } -catch (e: Exception) { 400 - e.message.toString() } -fun BodyBuilder.headers(vararg pairs: Pair) = headers(HttpHeaders().apply { pairs.forEach { (k, v) -> set(k, v) } }) - -// Email validation -// https://www.baeldung.com/java-email-validation-regex -val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_-]+(\\.[\\p{L}0-9_-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex() -fun Str.isValidEmail(): Bool = emailRegex.matches(this) - -// Global Tools -val HTTP = HttpClient(CIO) { - install(ContentNegotiation) { - json(JSON) - } -} -val TIKA = Tika() -val MIMES = MimeTypes.getDefaultMimeTypes() - -// Class resource -object Ext { val log = logger() } -fun res(name: Str) = Ext::class.java.getResourceAsStream(name) -fun resStr(name: Str) = res(name)?.reader()?.readText() -inline fun resJson(name: Str, warn: Boolean = true) = resStr(name)?.let { - JSON.decodeFromString(it) -} ?: run { if (warn) Ext.log.warn("Resource $name is not found"); null } - -// Date and time -val JST_ZONE = ZoneId.of("Asia/Tokyo") -fun jstNow() = LocalDateTime.now(JST_ZONE) -fun millis() = System.currentTimeMillis() -fun utcNow() = LocalDateTime.now(UTC) -val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd") -fun LocalDate.isoDate() = format(DATE_FORMAT) -fun String.isoDate() = DATE_FORMAT.parse(this, LocalDate::from) -fun Date.utc() = toInstant().atZone(UTC).toLocalDate() -fun LocalDate.toDate() = Date(atStartOfDay().toInstant(UTC).toEpochMilli()) -fun LocalDateTime.isoDateTime() = format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) -fun String.isoDateTime() = LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) -val URL_SAFE_DT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") -fun LocalDateTime.urlSafeStr() = format(URL_SAFE_DT) -val DATE_2018 = LocalDateTime.parse("2018-01-01T00:00:00") - -val ALT_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") -fun Str.asDateTime() = try { LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) } -catch (e: Exception) { try { LocalDateTime.parse(this, ALT_DATETIME_FORMAT) } -catch (e: Exception) { null } } - -val Calendar.year get() = get(Calendar.YEAR) -val Calendar.month get() = get(Calendar.MONTH) + 1 -val Calendar.day get() = get(Calendar.DAY_OF_MONTH) -fun cal() = Calendar.getInstance() -fun Date.cal() = Calendar.getInstance().apply { time = this@cal } -operator fun Calendar.invoke(field: Int) = get(field) -val Date.sec get() = time / 1000 - -// Encodings -fun Long.toHex(len: Int = 16): Str = "0x${this.toString(len).padStart(len, '0').uppercase()}" -fun Map.toUrl() = entries.joinToString("&") { (k, v) -> "$k=$v" } -fun String.firstCharLower() = replaceFirstChar { it.lowercase() } - -fun Any.long() = when (this) { - is Long -> this - is Boolean -> if (this) 1L else 0 - is Number -> toLong() - is String -> toLong() - else -> 400 - "Invalid number: $this" -} -fun Any.uint32() = long() and 0xFFFFFFFF -fun Any.int() = long().toInt() -val Any.long get() = long() -val Any.int get() = int() -val Any.double get() = when (this) { - is Boolean -> if (this) 1.0 else 0.0 - is Number -> toDouble() - is String -> toDouble() - else -> 400 - "Invalid number: $this" -} -operator fun Bool.unaryPlus() = if (this) 1 else 0 -val Any?.truthy get() = when (this) { - null -> false - is Bool -> this - is Float -> this != 0f && !isNaN() - is Double -> this != 0.0 && !isNaN() - is Number -> this != 0 - is String -> this.isNotBlank() - is Collection<*> -> isNotEmpty() - is Map<*, *> -> isNotEmpty() - else -> true -} -val Any?.str get() = toString() - -// Collections -fun ls(vararg args: T) = args.toList() -inline fun arr(vararg args: T) = arrayOf(*args) -operator fun Map.plus(map: Map) = mut.apply { putAll(map) } -operator fun MutableMap.plusAssign(map: Map) { putAll(map) } -fun Map.vNotNull(): Map = filterValues { it != null }.mapValues { it.value!! } -fun MutableList.popAll(list: List) = list.also { removeAll(it) } -fun MutableList.popAll(vararg items: T) = popAll(items.toList()) -inline fun Iterable.mapApply(block: T.() -> Unit) = map { it.apply(block) } -inline fun Iterable.mapApplyI(block: T.(Int) -> Unit) = mapIndexed { i, e -> e.apply { block(i) } } -@Suppress("UNCHECKED_CAST") -fun Map.recursiveNotNull(): Map = mapNotNull { (k, v) -> - k to if (v is Map<*, *>) (v as Map).recursiveNotNull() else v -}.toMap() as Map - -val List.mut get() = toMutableList() -val Map.mut get() = toMutableMap() -val Set.mut get() = toMutableSet() - -fun List.unique(fn: (T) -> Any) = distinctBy(fn).ifEmpty { null } -val Collection.csv get() = joinToString(",") -val IntArray.csv get() = joinToString(",") - -// Optionals -operator fun Optional.invoke(): T? = orElse(null) -fun Optional.expect(message: Str = "Value is not present") = orElseGet { (400 - message) } - -// Strings -operator fun Str.get(range: IntRange) = substring(range.first, (range.last + 1).coerceAtMost(length)) -operator fun Str.get(start: Int, end: Int) = substring(start, end.coerceAtMost(length)) -fun Str.center(width: Int, padChar: Char = ' ') = padStart((length + width) / 2, padChar).padEnd(width, padChar) -fun Str.splitLines() = replace("\r\n", "\n").split('\n') -fun Str.hash(algo: Str) = MessageDigest.getInstance(algo).digest(toByteArray(StandardCharsets.UTF_8)) -fun Str.md5() = hash("MD5") -fun Str.fromChusanUsername() = String(this.toByteArray(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8) -fun Str.truncate(len: Int) = if (this.length > len) this.take(len) + "..." else this -val Str.some get() = ifBlank { null } -val ByteArray.hexStr get() = toHexString() -operator fun StringBuilder.plusAssign(other: String) { this.append(other) } - -// Coroutine -suspend fun async(block: suspend kotlinx.coroutines.CoroutineScope.() -> T): T = withContext(Dispatchers.IO) { block() } -fun thread(block: () -> T) = Thread { block() }.apply { start() } -fun Lock.maybeLock(block: () -> T) = if (tryLock()) try { block() } finally { unlock() } else null - -// Paths -fun path(part1: Str, vararg parts: Str) = Path.of(part1, *parts) -fun Str.path() = Path.of(this) -operator fun Path.div(part: Str) = resolve(part) -operator fun File.div(fileName: Str) = File(this, fileName) -fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/" -fun Str.ensureNoEndingSlash() = if (endsWith('/')) dropLast(1) else this - -fun T.logger() = LoggerFactory.getLogger(this::class.java) - -// I hate this ;-; (list destructuring) -operator fun List.component6(): E = get(5) -operator fun List.component7(): E = get(6) -operator fun List.component8(): E = get(7) -operator fun List.component9(): E = get(8) -operator fun List.component10(): E = get(9) -operator fun List.component11(): E = get(10) -operator fun List.component12(): E = get(11) -operator fun List.component13(): E = get(12) - -inline operator fun List.invoke(i: Int) = get(i) as E -val empty = emptyList() -val emptyMap = emptyMap() - -val Pair.l get() = component1() -val Pair<*, S>.r get() = component2() - -// Database -val Query.exec get() = resultList.map { (it as Array<*>).toList() } -fun List>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" + - joinToString("\n") { it.joinToString(",") } - -// DI -inline fun ApplicationContext.lazy() = lazy { getBean(T::class.java) } +@file:OptIn(ExperimentalStdlibApi::class) + +package ext + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import java.security.MessageDigest +import java.util.* +import java.util.concurrent.locks.Lock + +typealias Str = String +typealias Bool = Boolean +typealias JavaSerializable = java.io.Serializable + +typealias JDict = Map +typealias MutJDict = MutableMap + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Doc( + val desc: String, + val ret: String = "" +) + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class SettingField( + val game: String +) + +// Email validation +val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_-]+(\\.[\\p{L}0-9_-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex() +fun Str.isValidEmail(): Bool = emailRegex.matches(this) + +// Class resource +object Ext { val log = logger() } +fun res(name: Str) = Ext::class.java.getResourceAsStream(name) +fun resStr(name: Str) = res(name)?.reader()?.readText() +inline fun resJson(name: Str, warn: Boolean = true) = resStr(name)?.let { + JSON.decodeFromString(it) +} ?: run { if (warn) Ext.log.warn("Resource $name is not found"); null } + +// Encodings +fun Long.toHex(len: Int = 16): Str = "0x${this.toString(len).padStart(len, '0').uppercase()}" +fun Map.toUrl() = entries.joinToString("&") { (k, v) -> "$k=$v" } +fun String.firstCharLower() = replaceFirstChar { it.lowercase() } + +fun Any.long() = when (this) { + is Long -> this + is Boolean -> if (this) 1L else 0 + is Number -> toLong() + is String -> toLong() + else -> 400 - "Invalid number: $this" +} +fun Any.uint32() = long() and 0xFFFFFFFF +fun Any.int() = long().toInt() +val Any.long get() = long() +val Any.int get() = int() +val Any.double get() = when (this) { + is Boolean -> if (this) 1.0 else 0.0 + is Number -> toDouble() + is String -> toDouble() + else -> 400 - "Invalid number: $this" +} +operator fun Bool.unaryPlus() = if (this) 1 else 0 +val Any?.truthy get() = when (this) { + null -> false + is Bool -> this + is Float -> this != 0f && !isNaN() + is Double -> this != 0.0 && !isNaN() + is Number -> this != 0 + is String -> this.isNotBlank() + is Collection<*> -> isNotEmpty() + is Map<*, *> -> isNotEmpty() + else -> true +} +val Any?.str get() = toString() + +// Collections +fun ls(vararg args: T) = args.toList() +inline fun arr(vararg args: T) = arrayOf(*args) +operator fun Map.plus(map: Map) = mut.apply { putAll(map) } +operator fun MutableMap.plusAssign(map: Map) { putAll(map) } +fun Map.vNotNull(): Map = filterValues { it != null }.mapValues { it.value!! } +fun MutableList.popAll(list: List) = list.also { removeAll(it) } +fun MutableList.popAll(vararg items: T) = popAll(items.toList()) +inline fun Iterable.mapApply(block: T.() -> Unit) = map { it.apply(block) } +inline fun Iterable.mapApplyI(block: T.(Int) -> Unit) = mapIndexed { i, e -> e.apply { block(i) } } +@Suppress("UNCHECKED_CAST") +fun Map.recursiveNotNull(): Map = mapNotNull { (k, v) -> + k to if (v is Map<*, *>) (v as Map).recursiveNotNull() else v +}.toMap() as Map + +val List.mut get() = toMutableList() +val Map.mut get() = toMutableMap() +val Set.mut get() = toMutableSet() + +fun List.unique(fn: (T) -> Any) = distinctBy(fn).ifEmpty { null } +val Collection.csv get() = joinToString(",") +val IntArray.csv get() = joinToString(",") + +// Optionals +operator fun Optional.invoke(): T? = orElse(null) +fun Optional.expect(message: Str = "Value is not present") = orElseGet { (400 - message) } + +// Strings +operator fun Str.get(range: IntRange) = substring(range.first, (range.last + 1).coerceAtMost(length)) +operator fun Str.get(start: Int, end: Int) = substring(start, end.coerceAtMost(length)) +fun Str.center(width: Int, padChar: Char = ' ') = padStart((length + width) / 2, padChar).padEnd(width, padChar) +fun Str.splitLines() = replace("\r\n", "\n").split('\n') +fun Str.hash(algo: Str) = MessageDigest.getInstance(algo).digest(toByteArray(StandardCharsets.UTF_8)) +fun Str.md5() = hash("MD5") +fun Str.fromChusanUsername() = String(this.toByteArray(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8) +fun Str.truncate(len: Int) = if (this.length > len) this.take(len) + "..." else this +val Str.some get() = ifBlank { null } +val ByteArray.hexStr get() = toHexString() +operator fun StringBuilder.plusAssign(other: String) { this.append(other) } + +// Coroutine +suspend fun async(block: suspend kotlinx.coroutines.CoroutineScope.() -> T): T = withContext(Dispatchers.IO) { block() } +fun thread(block: () -> T) = Thread { block() }.apply { start() } +fun Lock.maybeLock(block: () -> T) = if (tryLock()) try { block() } finally { unlock() } else null + +// Paths +fun path(part1: Str, vararg parts: Str) = Path.of(part1, *parts) +fun Str.path() = Path.of(this) +operator fun Path.div(part: Str) = resolve(part) +operator fun File.div(fileName: Str) = File(this, fileName) +fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/" +fun Str.ensureNoEndingSlash() = if (endsWith('/')) dropLast(1) else this + +fun T.logger() = LoggerFactory.getLogger(this::class.java) + +// I hate this ;-; (list destructuring) +operator fun List.component6(): E = get(5) +operator fun List.component7(): E = get(6) +operator fun List.component8(): E = get(7) +operator fun List.component9(): E = get(8) +operator fun List.component10(): E = get(9) +operator fun List.component11(): E = get(10) +operator fun List.component12(): E = get(11) +operator fun List.component13(): E = get(12) + +inline operator fun List.invoke(i: Int) = get(i) as E +val empty = emptyList() +val emptyMap = emptyMap() + +val Pair.l get() = component1() +val Pair<*, S>.r get() = component2() + +fun List>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" + + joinToString("\n") { it.joinToString(",") } diff --git a/planet/src/main/kotlin/ext/Jackson.kt b/planet/src/main/kotlin/ext/Jackson.kt new file mode 100644 index 00000000..8ef2c452 --- /dev/null +++ b/planet/src/main/kotlin/ext/Jackson.kt @@ -0,0 +1,62 @@ +package ext + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.* +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +// Jackson +val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null") +val ACCEPTABLE_TRUE = setOf("1", "true", "yes", "on", "True") +val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : JsonDeserializer() { + override fun deserialize(parser: JsonParser, context: DeserializationContext) = when(parser.text) { + in ACCEPTABLE_FALSE -> false + in ACCEPTABLE_TRUE -> true + else -> 400 - "Invalid boolean value ${parser.text}" + } +}) +val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer() { + override fun deserialize(parser: JsonParser, context: DeserializationContext) = + // First try standard formats via asDateTime() method + parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try { + // Try maimai2 format (yyyy-MM-dd HH:mm:ss.0) + LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0")) + } catch (e: Exception) { + 400 - "Invalid date time value ${parser.text}" + } } +}) +val JACKSON = jacksonObjectMapper().apply { + setSerializationInclusion(JsonInclude.Include.NON_NULL) + setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) + findAndRegisterModules() + registerModule(JSON_FUZZY_BOOLEAN) + registerModule(JSON_DATETIME) + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + propertyNamingStrategy = PropertyNamingStrategies.LOWER_CAMEL_CASE; +} +inline fun ObjectMapper.parse(str: Str) = readValue(str, T::class.java) +inline fun ObjectMapper.parse(map: Map<*, *>) = convertValue(map, T::class.java) +// TODO: https://stackoverflow.com/q/78197784/7346633 +fun Str.parseJackson(cls: Class) = if (contains("null")) { + val map = JACKSON.parse>(this) + JACKSON.convertValue(map.recursiveNotNull(), cls) +} +else JACKSON.readValue(this, cls) +fun T.toJson() = JACKSON.writeValueAsString(this) + +inline fun String.json() = try { + if (isEmpty() || this == "null") null + else JACKSON.readValue(this, T::class.java) +} +catch (e: Exception) { + println("Failed to parse JSON: $this") + throw e +} + +fun String.jsonMap(): Map = json() ?: emptyMap() +fun String.jsonArray(): List> = json() ?: emptyList() +fun String.jsonMaybeMap(): Map? = json() +fun String.jsonMaybeArray(): List>? = json() diff --git a/planet/src/main/kotlin/ext/Json.kt b/planet/src/main/kotlin/ext/Json.kt index f7acf3ac..2f53cc57 100644 --- a/planet/src/main/kotlin/ext/Json.kt +++ b/planet/src/main/kotlin/ext/Json.kt @@ -1,68 +1,8 @@ package ext -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.* -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -// Jackson -val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null") -val ACCEPTABLE_TRUE = setOf("1", "true", "yes", "on", "True") -val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : JsonDeserializer() { - override fun deserialize(parser: JsonParser, context: DeserializationContext) = when(parser.text) { - in ACCEPTABLE_FALSE -> false - in ACCEPTABLE_TRUE -> true - else -> 400 - "Invalid boolean value ${parser.text}" - } -}) -val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer() { - override fun deserialize(parser: JsonParser, context: DeserializationContext) = - // First try standard formats via asDateTime() method - parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try { - // Try maimai2 format (yyyy-MM-dd HH:mm:ss.0) - LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0")) - } catch (e: Exception) { - 400 - "Invalid date time value ${parser.text}" - } } -}) -val JACKSON = jacksonObjectMapper().apply { - setSerializationInclusion(JsonInclude.Include.NON_NULL) - setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) - findAndRegisterModules() - registerModule(JSON_FUZZY_BOOLEAN) - registerModule(JSON_DATETIME) - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - propertyNamingStrategy = PropertyNamingStrategies.LOWER_CAMEL_CASE; -} -inline fun ObjectMapper.parse(str: Str) = readValue(str, T::class.java) -inline fun ObjectMapper.parse(map: Map<*, *>) = convertValue(map, T::class.java) -// TODO: https://stackoverflow.com/q/78197784/7346633 -fun Str.parseJackson(cls: Class) = if (contains("null")) { - val map = JACKSON.parse>(this) - JACKSON.convertValue(map.recursiveNotNull(), cls) -} -else JACKSON.readValue(this, cls) -fun T.toJson() = JACKSON.writeValueAsString(this) - -inline fun String.json() = try { - if (isEmpty() || this == "null") null - else JACKSON.readValue(this, T::class.java) -} -catch (e: Exception) { - println("Failed to parse JSON: $this") - throw e -} - -fun String.jsonMap(): Map = json() ?: emptyMap() -fun String.jsonArray(): List> = json() ?: emptyList() -fun String.jsonMaybeMap(): Map? = json() -fun String.jsonMaybeArray(): List>? = json() // KotlinX Serialization @OptIn(ExperimentalSerializationApi::class) @@ -73,12 +13,3 @@ val JSON = Json { explicitNulls = false coerceInputValues = true } - -// Bean for default jackson object mapper -//@Configuration -//class JacksonConfig { -// @Bean -// fun objectMapper(): ObjectMapper { -// return JACKSON -// } -//} diff --git a/planet/src/main/kotlin/ext/Ktor.kt b/planet/src/main/kotlin/ext/Ktor.kt new file mode 100644 index 00000000..b1ccaa11 --- /dev/null +++ b/planet/src/main/kotlin/ext/Ktor.kt @@ -0,0 +1,12 @@ +package ext + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* + +val HTTP = HttpClient(CIO) { + install(ContentNegotiation) { + json(JSON) + } +} diff --git a/planet/src/main/kotlin/ext/Reflect.kt b/planet/src/main/kotlin/ext/Reflect.kt new file mode 100644 index 00000000..30cab7de --- /dev/null +++ b/planet/src/main/kotlin/ext/Reflect.kt @@ -0,0 +1,30 @@ +package ext + +import java.lang.reflect.Field +import kotlin.reflect.KCallable +import kotlin.reflect.KClass +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.jvmErasure + +typealias Var = KMutableProperty1 + +// Reflection +@Suppress("UNCHECKED_CAST") +fun KClass.ownVars() = declaredMemberProperties.sortedBy { it.javaField?.declaringClass?.declaredFields?.indexOf(it.javaField) ?: Int.MAX_VALUE }.mapNotNull { it as? Var } +@Suppress("UNCHECKED_CAST") +fun KClass.vars(): List> = supertypes.mapNotNull { it.classifier as? KClass<*> }.filter { !it.java.isInterface }.flatMap{ it.vars() as List> } + ownVars() +fun KClass.varsMap() = vars().associateBy { it.name } +fun KClass.getters() = java.methods.filter { it.name.startsWith("get") } +fun KClass.gettersMap() = getters().associateBy { it.name.removePrefix("get").firstCharLower() } +infix fun KCallable<*>.returns(type: KClass<*>) = returnType.jvmErasure.isSubclassOf(type) +@Suppress("UNCHECKED_CAST") +fun Var.setCast(obj: C, value: String) = set(obj, when (returnType.classifier) { + String::class -> value + Int::class -> value.toInt() + Boolean::class -> value.toBoolean() + else -> 400 - "Invalid field type $returnType" +} as T) +inline fun Field.gets(obj: Any): T? = get(obj)?.let { it as T } diff --git a/planet/src/main/kotlin/ext/Spring.kt b/planet/src/main/kotlin/ext/Spring.kt new file mode 100644 index 00000000..3048ba62 --- /dev/null +++ b/planet/src/main/kotlin/ext/Spring.kt @@ -0,0 +1,48 @@ +package ext + +import icu.samnyan.aqua.net.utils.ApiException +import jakarta.persistence.Query +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.context.ApplicationContext +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity.BodyBuilder +import org.springframework.web.bind.annotation.* + +typealias RP = RequestParam +typealias RB = RequestBody +typealias RT = RequestPart +typealias RH = RequestHeader +typealias PV = PathVariable +typealias API = RequestMapping + +fun HttpServletRequest.details() = mapOf( + "method" to method, + "uri" to requestURI, + "query" to queryString, + "remote" to remoteAddr, + "headers" to headerNames.asSequence().associateWith { getHeader(it) } +) + +fun HttpServletResponse.details() = mapOf( + "status" to status, + "headers" to headerNames.asSequence().associateWith { getHeader(it) }, +) + +// HTTP +operator fun HttpStatus.invoke(message: String? = null): Nothing = throw ApiException(value(), message ?: this.reasonPhrase) +operator fun Int.minus(message: String): Nothing { + ApiException.log.info("> Error $this: $message") + throw ApiException(this, message) +} +fun parsing(block: () -> R) = try { block() } +catch (e: ApiException) { throw e } +catch (e: Exception) { 400 - e.message.toString() } +fun BodyBuilder.headers(vararg pairs: Pair) = headers(HttpHeaders().apply { pairs.forEach { (k, v) -> set(k, v) } }) + +// Database +val Query.exec get() = resultList.map { (it as Array<*>).toList() } + +// DI +inline fun ApplicationContext.lazy() = lazy { getBean(T::class.java) } diff --git a/planet/src/main/kotlin/ext/Tika.kt b/planet/src/main/kotlin/ext/Tika.kt new file mode 100644 index 00000000..d2672c16 --- /dev/null +++ b/planet/src/main/kotlin/ext/Tika.kt @@ -0,0 +1,7 @@ +package ext + +import org.apache.tika.Tika +import org.apache.tika.mime.MimeTypes + +val TIKA = Tika() +val MIMES = MimeTypes.getDefaultMimeTypes() diff --git a/planet/src/main/kotlin/ext/Time.kt b/planet/src/main/kotlin/ext/Time.kt new file mode 100644 index 00000000..86c964de --- /dev/null +++ b/planet/src/main/kotlin/ext/Time.kt @@ -0,0 +1,37 @@ +package ext + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset.UTC +import java.time.format.DateTimeFormatter +import java.util.* + +// Date and time +val JST_ZONE = ZoneId.of("Asia/Tokyo") +fun jstNow() = LocalDateTime.now(JST_ZONE) +fun millis() = System.currentTimeMillis() +fun utcNow() = LocalDateTime.now(UTC) +val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd") +fun LocalDate.isoDate() = format(DATE_FORMAT) +fun String.isoDate() = DATE_FORMAT.parse(this, LocalDate::from) +fun Date.utc() = toInstant().atZone(UTC).toLocalDate() +fun LocalDate.toDate() = Date(atStartOfDay().toInstant(UTC).toEpochMilli()) +fun LocalDateTime.isoDateTime() = format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) +fun String.isoDateTime() = LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) +val URL_SAFE_DT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") +fun LocalDateTime.urlSafeStr() = format(URL_SAFE_DT) +val DATE_2018 = LocalDateTime.parse("2018-01-01T00:00:00") + +val ALT_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") +fun Str.asDateTime() = try { LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) } +catch (e: Exception) { try { LocalDateTime.parse(this, ALT_DATETIME_FORMAT) } +catch (e: Exception) { null } } + +val Calendar.year get() = get(Calendar.YEAR) +val Calendar.month get() = get(Calendar.MONTH) + 1 +val Calendar.day get() = get(Calendar.DAY_OF_MONTH) +fun cal() = Calendar.getInstance() +fun Date.cal() = Calendar.getInstance().apply { time = this@cal } +operator fun Calendar.invoke(field: Int) = get(field) +val Date.sec get() = time / 1000