Server : Add item service.

This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2025-04-02 15:50:21 +02:00
parent b09a6d5184
commit 0aaa56a4aa
24 changed files with 607 additions and 28 deletions

View file

@ -114,7 +114,7 @@ class CharacterSheetStore(
private suspend fun handleMessage(message: SocketMessage) {
when (message) {
is ApiSynchronisation -> try {
is ApiSynchronisation.CharacterSheetApiSynchronisation -> try {
when (message) {
is ApiSynchronisation.CharacterSheetUpdate -> {
_detailFlow.update(
@ -137,9 +137,6 @@ class CharacterSheetStore(
sheets.toMutableMap().also { it.remove(message.characterSheetId) }
}
}
is ApiSynchronisation.AlterationUpdate -> Unit
is ApiSynchronisation.AlterationDelete -> Unit
}
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling

View file

@ -4,6 +4,8 @@ import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.campaign.CampaignStore
import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.server.lwa.model.character.CharacterSheetStore
import com.pixelized.server.lwa.model.item.ItemService
import com.pixelized.server.lwa.model.item.ItemStore
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.utils.PathProvider
@ -37,6 +39,7 @@ val storeDependencies
singleOf(::CampaignStore)
singleOf(::AlterationStore)
singleOf(::TagStore)
singleOf(::ItemStore)
}
val serviceDependencies
@ -44,4 +47,5 @@ val serviceDependencies
singleOf(::CharacterSheetService)
singleOf(::CampaignService)
singleOf(::AlterationService)
singleOf(::ItemService)
}

View file

@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.stateIn
class AlterationService(
private val alterationStore: AlterationStore,
tagStore: TagStore,
private val tagStore: TagStore,
factory: AlterationJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
@ -26,26 +26,18 @@ class AlterationService(
initialValue = emptyMap()
)
private val alterationTags = tagStore.alterationTags()
.map { it.values.toList() }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun alterations(): List<AlterationJson> {
return alterationHashFlow.value.values.toList()
}
fun tags(): List<TagJson> {
return alterationTags.value
}
fun alteration(alterationId: String): AlterationJson? {
return alterationHashFlow.value[alterationId]
}
fun tags(): List<TagJson> {
return tagStore.alterationTags().value.values.toList()
}
@Throws
fun save(
json: AlterationJson,
@ -59,8 +51,6 @@ class AlterationService(
@Throws
fun delete(alterationId: String) {
return alterationStore.delete(
id = alterationId,
)
alterationStore.delete(id = alterationId)
}
}

View file

@ -0,0 +1,56 @@
package com.pixelized.server.lwa.model.item
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactory
import com.pixelized.shared.lwa.model.tag.TagJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class ItemService(
private val itemStore: ItemStore,
private val tagStore: TagStore,
factory: ItemJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val itemHashFlow = itemStore.itemsFlow()
.map { items -> items.associate { it.id to factory.convertToJson(it) } }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap()
)
fun items(): List<ItemJson> {
return itemHashFlow.value.values.toList()
}
fun item(itemId: String): ItemJson? {
return itemHashFlow.value[itemId]
}
fun tags(): List<TagJson> {
return tagStore.itemTags().value.values.toList()
}
@Throws
fun save(
json: ItemJson,
create: Boolean,
) {
itemStore.save(
json = json,
create = create,
)
}
@Throws
fun delete(itemId: String) {
itemStore.delete(id = itemId)
}
}

View file

@ -0,0 +1,185 @@
package com.pixelized.server.lwa.model.item
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
import com.pixelized.server.lwa.server.exception.JsonCodingException
import com.pixelized.server.lwa.server.exception.JsonConversionException
import com.pixelized.shared.lwa.model.item.Item
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactory
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
import java.text.Collator
class ItemStore(
private val pathProvider: PathProvider,
private val factory: ItemJsonFactory,
private val json: Json,
) {
private val directory = File(pathProvider.itemsPath()).also { it.mkdirs() }
private val itemFlow = MutableStateFlow<List<Item>>(emptyList())
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
scope.launch {
updateItemsFlow()
}
}
fun itemsFlow(): StateFlow<List<Item>> = itemFlow
private fun updateItemsFlow() {
itemFlow.value = try {
load()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyList()
}
}
@Throws(
FileReadException::class,
JsonConversionException::class,
)
private fun load(
directory: File = this.directory,
): List<Item> {
return directory
.listFiles()
?.mapNotNull { file ->
val json = try {
file.readText(charset = Charsets.UTF_8)
} catch (exception: Exception) {
throw FileReadException(root = exception)
}
// Guard, if the json is blank no item have been save, ignore this file.
if (json.isBlank()) {
return@mapNotNull null
}
// decode the file
val data = try {
this.json.decodeFromString<ItemJson>(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// parse the json string.
try {
factory.convertFromJson(data)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
}
?.sortedWith(compareBy(Collator.getInstance()) { it.metadata.name })
?: emptyList()
}
@Throws(
BusinessException::class,
JsonConversionException::class,
JsonCodingException::class,
FileWriteException::class,
)
fun save(
json: ItemJson,
create: Boolean,
) {
val file = itemFile(id = json.id)
// Guard case on update alteration
if (create && file.exists()) {
throw BusinessException(
message = "Item already exist, creation is impossible.",
)
}
// Transform the json into the model.
val item = try {
factory.convertFromJson(json)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
if (item.id.isEmpty()) {
throw BusinessException(
message = "Item 'id' is a mandatory field.",
code = APIResponse.ErrorCode.ItemId,
)
}
if (item.metadata.name.isEmpty()) {
throw BusinessException(
message = "Item 'name' is a mandatory field.",
code = APIResponse.ErrorCode.ItemName,
)
}
// Encode the json into a string.
val data = try {
this.json.encodeToString(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// Write the alteration into a file.
try {
file.writeText(
text = data,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
itemFlow.update { items ->
val index = items.indexOfFirst { it.id == json.id }
items.toMutableList()
.also {
if (index >= 0) {
it[index] = item
} else {
it.add(item)
}
}
.sortedWith(compareBy(Collator.getInstance()) {
it.metadata.name
})
}
}
@Throws(BusinessException::class)
fun delete(id: String) {
val file = itemFile(id = id)
// Guard case on the file existence.
if (file.exists().not()) {
throw BusinessException(
message = "Item doesn't not exist, deletion is impossible.",
)
}
// Guard case on the file deletion
if (file.delete().not()) {
throw BusinessException(
message = "Item file have not been deleted for unknown reason.",
)
}
// Update the data model with the deleted alteration.
itemFlow.update { items ->
items.toMutableList()
.also { item ->
item.removeIf { it.id == id }
}
.sortedWith(compareBy(Collator.getInstance()) {
it.metadata.name
})
}
}
private fun itemFile(id: String): File {
return File("${pathProvider.itemsPath()}${id}.json")
}
}

View file

@ -16,6 +16,7 @@ import java.io.File
private const val CHARACTER = "character"
private const val ALTERATION = "alteration"
private const val ITEM = "item"
class TagStore(
private val pathProvider: PathProvider,
@ -23,6 +24,7 @@ class TagStore(
) {
private val alterationTagsFlow = MutableStateFlow<Map<String, TagJson>>(emptyMap())
private val characterTagsFlow = MutableStateFlow<Map<String, TagJson>>(emptyMap())
private val itemTagsFlow = MutableStateFlow<Map<String, TagJson>>(emptyMap())
init {
// make the file path.
@ -39,11 +41,16 @@ class TagStore(
flow = characterTagsFlow,
file = characterFile(),
)
update(
flow = itemTagsFlow,
file = itemFile(),
)
}
}
fun alterationTags(): StateFlow<Map<String, TagJson>> = alterationTagsFlow
fun characterTags(): StateFlow<Map<String, TagJson>> = characterTagsFlow
fun itemTags(): StateFlow<Map<String, TagJson>> = itemTagsFlow
private fun update(
flow: MutableStateFlow<Map<String, TagJson>>,
@ -101,6 +108,6 @@ class TagStore(
}
private fun characterFile() = File("${pathProvider.tagsPath()}$CHARACTER.json")
private fun alterationFile() = File("${pathProvider.tagsPath()}$ALTERATION.json")
private fun itemFile() = File("${pathProvider.tagsPath()}$ITEM.json")
}

View file

@ -3,6 +3,7 @@ package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.model.alteration.AlterationService
import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.server.lwa.model.item.ItemService
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
@ -16,6 +17,7 @@ class Engine(
val characterService: CharacterSheetService,
val campaignService: CampaignService,
val alterationService: AlterationService,
val itemService: ItemService,
val campaignJsonFactory: CampaignJsonFactory,
) {
val webSocket = MutableSharedFlow<SocketMessage>()

View file

@ -21,6 +21,11 @@ import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration
import com.pixelized.server.lwa.server.rest.character.putCharacterDamage
import com.pixelized.server.lwa.server.rest.character.putCharacterDiminished
import com.pixelized.server.lwa.server.rest.character.putCharacterFatigue
import com.pixelized.server.lwa.server.rest.item.deleteItem
import com.pixelized.server.lwa.server.rest.item.getItem
import com.pixelized.server.lwa.server.rest.item.getItemTags
import com.pixelized.server.lwa.server.rest.item.getItems
import com.pixelized.server.lwa.server.rest.item.putItem
import com.pixelized.shared.lwa.SERVER_PORT
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.sharedModuleDependencies
@ -113,12 +118,13 @@ class LocalServer {
}
}.onFailure { exception ->
// TODO
println("WebSocket exception: ${exception.localizedMessage}")
println("WebSocket exception: ${exception.message}")
}.also {
job.cancel()
}
}
)
// TODO Tags.
route(
path = "/alteration",
) {
@ -217,6 +223,28 @@ class LocalServer {
body = engine.putCampaignScene(),
)
}
route(path = "item") {
get(
path = "/all",
body = engine.getItems(),
)
get(
path = "/detail",
body = engine.getItem(),
)
get(
path = "/tags",
body = engine.getItemTags(),
)
put(
path = "/update",
body = engine.putItem(),
)
delete(
path = "/delete",
body = engine.deleteItem(),
)
}
}
}
)

View file

@ -13,8 +13,7 @@ fun Engine.getAlteration(): suspend RoutingContext.() -> Unit {
// get the query parameter
val alterationId = call.queryParameters.alterationId
// get the alteration of the given id.
val alteration = alterationService
.alteration(alterationId = alterationId)
val alteration = alterationService.alteration(alterationId = alterationId)
?: error("Alteration with id:$alterationId not found.")
// send it back to the user.
call.respond(

View file

@ -0,0 +1,36 @@
package com.pixelized.server.lwa.server.rest.item
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.server.lwa.utils.extentions.itemId
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.deleteItem(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val itemId = call.parameters.itemId
// delete the alteration.
itemService.delete(
itemId = itemId
)
// API & WebSocket responses.
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = ApiSynchronisation.ItemDelete(
timestamp = System.currentTimeMillis(),
itemId = itemId,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -0,0 +1,30 @@
package com.pixelized.server.lwa.server.rest.item
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.server.lwa.utils.extentions.itemId
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getItem(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val itemId = call.queryParameters.itemId
// get the alteration of the given id.
val item = itemService.item(itemId = itemId)
?: error("Item with id:$itemId not found.")
// send it back to the user.
call.respond(
message = APIResponse.success(
data = item
)
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -0,0 +1,23 @@
package com.pixelized.server.lwa.server.rest.item
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getItemTags(): suspend RoutingContext.() -> Unit {
return {
try {
call.respond(
message = APIResponse.success(
data = itemService.tags(),
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -0,0 +1,23 @@
package com.pixelized.server.lwa.server.rest.item
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getItems(): suspend RoutingContext.() -> Unit {
return {
try {
call.respond(
message = APIResponse.success(
data = itemService.items(),
)
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -0,0 +1,39 @@
package com.pixelized.server.lwa.server.rest.item
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.create
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putItem(): suspend RoutingContext.() -> Unit {
return {
try {
val form = call.receive<ItemJson>()
val create = call.queryParameters.create
itemService.save(
json = form,
create = create,
)
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = ApiSynchronisation.ItemUpdate(
timestamp = System.currentTimeMillis(),
itemId = form.id,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -13,6 +13,11 @@ val Parameters.alterationId
this[param] ?: throw MissingParameterException(name = param)
}
val Parameters.itemId
get() = "itemId".let { param ->
this[param] ?: throw MissingParameterException(name = param)
}
val Parameters.create
get() = "create".let { param ->
this[param]?.toBooleanStrictOrNull() ?: throw MissingParameterException(name = param)

View file

@ -7,6 +7,8 @@ import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonV1Factory
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonV2Factory
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonV1Factory
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactory
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactoryV1
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
import com.pixelized.shared.lwa.parser.dice.DiceParser
import com.pixelized.shared.lwa.parser.expression.ExpressionParser
@ -48,6 +50,8 @@ val factoryDependencies
factoryOf(::AlteredCharacterSheetFactory)
factoryOf(::AlterationJsonFactory)
factoryOf(::TagJsonFactory)
factoryOf(::ItemJsonFactory)
factoryOf(::ItemJsonFactoryV1)
}
val parserDependencies

View file

@ -0,0 +1,21 @@
package com.pixelized.shared.lwa.model.item
data class Item(
val id: String,
val metadata: MetaData,
val options: Options,
val tags: List<String>,
val alterations: List<String>,
) {
data class MetaData(
val name: String,
val description: String,
val thumbnail: String?,
val image: String?,
)
data class Options(
val stackable: Boolean,
val equipable: Boolean,
)
}

View file

@ -0,0 +1,8 @@
package com.pixelized.shared.lwa.model.item
import kotlinx.serialization.Serializable
@Serializable
sealed interface ItemJson {
val id: String
}

View file

@ -0,0 +1,27 @@
package com.pixelized.shared.lwa.model.item
import kotlinx.serialization.Serializable
@Serializable
data class ItemJsonV1(
override val id: String,
val metadata: ItemMetadataJsonV1,
val options: ItemOptionJsonV1,
val tags: List<String>,
val alterations: List<String>,
) : ItemJson {
@Serializable
data class ItemMetadataJsonV1(
val name: String,
val description: String,
val thumbnail: String?,
val image: String?,
)
@Serializable
data class ItemOptionJsonV1(
val stackable: Boolean,
val equipable: Boolean,
)
}

View file

@ -0,0 +1,19 @@
package com.pixelized.shared.lwa.model.item.factory
import com.pixelized.shared.lwa.model.item.Item
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.item.ItemJsonV1
class ItemJsonFactory(
private val v1: ItemJsonFactoryV1,
) {
fun convertFromJson(json: ItemJson): Item {
return when (json) {
is ItemJsonV1 -> v1.convertFromJson(json = json)
}
}
fun convertToJson(item: Item): ItemJson {
return v1.convertToJson(item = item)
}
}

View file

@ -0,0 +1,43 @@
package com.pixelized.shared.lwa.model.item.factory
import com.pixelized.shared.lwa.model.item.Item
import com.pixelized.shared.lwa.model.item.ItemJsonV1
class ItemJsonFactoryV1 {
fun convertFromJson(json: ItemJsonV1): Item {
return Item(
id = json.id,
metadata = Item.MetaData(
name = json.metadata.name,
description = json.metadata.description,
image = json.metadata.image,
thumbnail = json.metadata.thumbnail,
),
options = Item.Options(
stackable = json.options.stackable,
equipable = json.options.equipable,
),
tags = json.tags,
alterations = json.alterations,
)
}
fun convertToJson(item: Item): ItemJsonV1 {
return ItemJsonV1(
id = item.id,
metadata = ItemJsonV1.ItemMetadataJsonV1(
name = item.metadata.name,
description = item.metadata.description,
image = item.metadata.image,
thumbnail = item.metadata.thumbnail,
),
options = ItemJsonV1.ItemOptionJsonV1(
stackable = item.options.stackable,
equipable = item.options.equipable,
),
tags = item.tags,
alterations = item.alterations,
)
}
}

View file

@ -14,6 +14,8 @@ data class APIResponse<T>(
enum class ErrorCode {
AlterationId,
AlterationName,
ItemId,
ItemName,
CharacterSheetId,
}

View file

@ -5,27 +5,48 @@ import kotlinx.serialization.Serializable
@Serializable
sealed interface ApiSynchronisation : SocketMessage {
@Serializable
sealed interface CharacterSheetApiSynchronisation : ApiSynchronisation, CharacterSheetIdMessage
@Serializable
data class CharacterSheetDelete(
override val timestamp: Long,
override val characterSheetId: String,
) : ApiSynchronisation, CharacterSheetIdMessage
) : CharacterSheetApiSynchronisation
@Serializable
data class CharacterSheetUpdate(
override val timestamp: Long,
override val characterSheetId: String,
) : ApiSynchronisation, CharacterSheetIdMessage
) : CharacterSheetApiSynchronisation
@Serializable
sealed interface AlterationApiSynchronisation : ApiSynchronisation
@Serializable
data class AlterationUpdate(
override val timestamp: Long,
val alterationId: String,
) : ApiSynchronisation
) : AlterationApiSynchronisation
@Serializable
data class AlterationDelete(
override val timestamp: Long,
val alterationId: String,
) : ApiSynchronisation
) : AlterationApiSynchronisation
@Serializable
sealed interface ItemApiSynchronisation : ApiSynchronisation
@Serializable
data class ItemUpdate(
override val timestamp: Long,
val itemId: String,
) : ItemApiSynchronisation
@Serializable
data class ItemDelete(
override val timestamp: Long,
val itemId: String,
) : ItemApiSynchronisation
}

View file

@ -54,6 +54,16 @@ class PathProvider(
}
}
fun itemsPath(
os: OperatingSystem = this.operatingSystem,
app: String = this.appName,
): String {
return when (os) {
OperatingSystem.Windows -> "${storePath(os = os, app = app)}items\\"
OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}items/"
}
}
fun tagsPath(
os: OperatingSystem = this.operatingSystem,
app: String = this.appName,