A lot of small refactoring in html

This commit is contained in:
Alexander Nozik 2020-12-12 10:44:41 +03:00
parent 17beb29217
commit d68f5a9840
14 changed files with 146 additions and 237 deletions

View File

@ -4,7 +4,7 @@ plugins {
kotlin("js") apply false kotlin("js") apply false
} }
val dataforgeVersion: String by extra("0.2.0") val dataforgeVersion: String by extra("0.2.1-dev-2")
val ktorVersion: String by extra("1.4.3") val ktorVersion: String by extra("1.4.3")
val rsocketVersion by extra("0.11.1") val rsocketVersion by extra("0.11.1")

View File

@ -2,13 +2,12 @@ package hep.dataforge.control.api
import hep.dataforge.context.ContextAware import hep.dataforge.context.ContextAware
import hep.dataforge.control.api.Device.Companion.DEVICE_TARGET import hep.dataforge.control.api.Device.Companion.DEVICE_TARGET
import hep.dataforge.io.Envelope
import hep.dataforge.io.EnvelopeBuilder
import hep.dataforge.meta.Meta import hep.dataforge.meta.Meta
import hep.dataforge.meta.MetaItem import hep.dataforge.meta.MetaItem
import hep.dataforge.provider.Type import hep.dataforge.provider.Type
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.io.Closeable import kotlinx.io.Closeable
/** /**
@ -28,27 +27,15 @@ public interface Device : Closeable, ContextAware {
public val actionDescriptors: Collection<ActionDescriptor> public val actionDescriptors: Collection<ActionDescriptor>
/** /**
* The supervisor scope encompassing all operations on a device. When canceled, cancels all running processes * The supervisor scope encompassing all operations on a device. When canceled, cancels all running processes.
*/ */
public val scope: CoroutineScope public val scope: CoroutineScope
/**
* Register a new property change listener for this device.
* [owner] is provided optionally in order for listener to be
* easily removable
*/
public fun registerListener(listener: DeviceListener, owner: Any? = listener)
/**
* Remove all listeners belonging to the specified owner
*/
public fun removeListeners(owner: Any?)
/** /**
* Get the value of the property or throw error if property in not defined. * Get the value of the property or throw error if property in not defined.
* Suspend if property value is not available * Suspend if property value is not available
*/ */
public suspend fun getProperty(propertyName: String): MetaItem<*> public suspend fun getProperty(propertyName: String): MetaItem<*>?
/** /**
* Invalidate property and force recalculate * Invalidate property and force recalculate
@ -61,11 +48,16 @@ public interface Device : Closeable, ContextAware {
*/ */
public suspend fun setProperty(propertyName: String, value: MetaItem<*>) public suspend fun setProperty(propertyName: String, value: MetaItem<*>)
/**
* The [SharedFlow] of property changes
*/
public val propertyFlow: SharedFlow<Pair<String, MetaItem<*>>>
/** /**
* Send an action request and suspend caller while request is being processed. * Send an action request and suspend caller while request is being processed.
* Could return null if request does not return a meaningful answer. * Could return null if request does not return a meaningful answer.
*/ */
public suspend fun execute(command: String, argument: MetaItem<*>? = null): MetaItem<*>? public suspend fun execute(action: String, argument: MetaItem<*>? = null): MetaItem<*>?
override fun close() { override fun close() {
scope.cancel("The device is closed") scope.cancel("The device is closed")
@ -76,14 +68,10 @@ public interface Device : Closeable, ContextAware {
} }
} }
public interface ResponderDevice{ public suspend fun Device.getState(): Meta = Meta{
/** for(descriptor in propertyDescriptors) {
* descriptor.name put getProperty(descriptor.name)
* A request with binary data or for binary response (or both). This request does not cover basic functionality like }
* [setProperty], [getProperty] or [execute] and not defined for a generic device.
*
*/
public suspend fun respondWithData(request: Envelope): EnvelopeBuilder
} }
public suspend fun Device.execute(name: String, meta: Meta?): MetaItem<*>? = execute(name, meta?.let { MetaItem.NodeItem(it) }) //public suspend fun Device.execute(name: String, meta: Meta?): MetaItem<*>? = execute(name, meta?.let { MetaItem.NodeItem(it) })

View File

@ -1,14 +0,0 @@
package hep.dataforge.control.api
import hep.dataforge.meta.MetaItem
/**
* PropertyChangeListener Interface
* [value] is a new value that property has after a change; null is for invalid state.
*/
public interface DeviceListener {
public fun propertyChanged(propertyName: String, value: MetaItem<*>?)
public fun actionExecuted(action: String, argument: MetaItem<*>?, result: MetaItem<*>?) {}
//TODO add general message listener method
}

View File

@ -3,18 +3,25 @@ package hep.dataforge.control.base
import hep.dataforge.context.Context import hep.dataforge.context.Context
import hep.dataforge.control.api.ActionDescriptor import hep.dataforge.control.api.ActionDescriptor
import hep.dataforge.control.api.Device import hep.dataforge.control.api.Device
import hep.dataforge.control.api.DeviceListener
import hep.dataforge.control.api.PropertyDescriptor import hep.dataforge.control.api.PropertyDescriptor
import hep.dataforge.meta.DFExperimental
import hep.dataforge.meta.MetaItem import hep.dataforge.meta.MetaItem
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
//TODO move to DataForge-core
@DFExperimental
public data class LogEntry(val content: String, val priority: Int = 0)
/** /**
* Baseline implementation of [Device] interface * Baseline implementation of [Device] interface
*/ */
@Suppress("EXPERIMENTAL_API_USAGE")
public abstract class DeviceBase(override val context: Context) : Device { public abstract class DeviceBase(override val context: Context) : Device {
private val _properties = HashMap<String, ReadOnlyDeviceProperty>() private val _properties = HashMap<String, ReadOnlyDeviceProperty>()
@ -22,25 +29,21 @@ public abstract class DeviceBase(override val context: Context) : Device {
private val _actions = HashMap<String, DeviceAction>() private val _actions = HashMap<String, DeviceAction>()
public val actions: Map<String, DeviceAction> get() = _actions public val actions: Map<String, DeviceAction> get() = _actions
private val listeners = ArrayList<Pair<Any?, DeviceListener>>(4) private val sharedPropertyFlow = MutableSharedFlow<Pair<String, MetaItem<*>>>()
override fun registerListener(listener: DeviceListener, owner: Any?) { override val propertyFlow: SharedFlow<Pair<String, MetaItem<*>>> get() = sharedPropertyFlow
listeners.add(owner to listener)
}
override fun removeListeners(owner: Any?) { private val sharedLogFlow = MutableSharedFlow<LogEntry>()
listeners.removeAll { it.first == owner }
}
internal fun notifyListeners(block: DeviceListener.() -> Unit) { /**
listeners.forEach { it.second.block() } * The [SharedFlow] of log messages
} */
@DFExperimental
public val logFlow: SharedFlow<LogEntry>
get() = sharedLogFlow
public fun notifyPropertyChanged(propertyName: String) { protected suspend fun log(message: String, priority: Int = 0) {
scope.launch { sharedLogFlow.emit(LogEntry(message, priority))
val value = getProperty(propertyName)
notifyListeners { propertyChanged(propertyName, value) }
}
} }
override val propertyDescriptors: Collection<PropertyDescriptor> override val propertyDescriptors: Collection<PropertyDescriptor>
@ -72,8 +75,8 @@ public abstract class DeviceBase(override val context: Context) : Device {
) )
} }
override suspend fun execute(command: String, argument: MetaItem<*>?): MetaItem<*>? = override suspend fun execute(action: String, argument: MetaItem<*>?): MetaItem<*>? =
(_actions[command] ?: error("Request with name $command not defined")).invoke(argument) (_actions[action] ?: error("Request with name $action not defined")).invoke(argument)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private open inner class BasicReadOnlyDeviceProperty( private open inner class BasicReadOnlyDeviceProperty(
@ -94,8 +97,8 @@ public abstract class DeviceBase(override val context: Context) : Device {
override fun updateLogical(item: MetaItem<*>) { override fun updateLogical(item: MetaItem<*>) {
state.value = item state.value = item
notifyListeners { scope.launch {
propertyChanged(name, item) sharedPropertyFlow.emit(Pair(name, item))
} }
} }
@ -206,11 +209,7 @@ public abstract class DeviceBase(override val context: Context) : Device {
) : DeviceAction { ) : DeviceAction {
override suspend fun invoke(arg: MetaItem<*>?): MetaItem<*>? = override suspend fun invoke(arg: MetaItem<*>?): MetaItem<*>? =
withContext(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) { withContext(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) {
block(arg).also { block(arg)
notifyListeners {
actionExecuted(name, arg, it)
}
}
} }
} }

View File

@ -1,62 +1,42 @@
package hep.dataforge.control.controllers package hep.dataforge.control.controllers
import hep.dataforge.control.api.* import hep.dataforge.control.api.Device
import hep.dataforge.control.api.DeviceHub
import hep.dataforge.control.api.get
import hep.dataforge.control.messages.* import hep.dataforge.control.messages.*
import hep.dataforge.io.Consumer import hep.dataforge.meta.DFExperimental
import hep.dataforge.io.Envelope import hep.dataforge.meta.Meta
import hep.dataforge.io.Responder import hep.dataforge.meta.MetaItem
import hep.dataforge.io.SimpleEnvelope
import hep.dataforge.meta.*
import hep.dataforge.names.Name import hep.dataforge.names.Name
import hep.dataforge.names.toName import hep.dataforge.names.toName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.io.Binary
/**
* The [DeviceController] wraps device operations in [DeviceMessage]
*/
@OptIn(DFExperimental::class) @OptIn(DFExperimental::class)
public class DeviceController( public class DeviceController(
public val device: Device, public val device: Device,
public val deviceName: String, public val deviceName: String,
public val scope: CoroutineScope = device.scope, ) {
) : Responder, Consumer, DeviceListener {
init { private val propertyChanges = device.propertyFlow.map { (propertyName: String, value: MetaItem<*>) ->
device.registerListener(this, this) PropertyChangedMessage(
}
private val outputChannel = Channel<Envelope>(Channel.CONFLATED)
public suspend fun respondMessage(message: DeviceMessage): DeviceMessage =
respondMessage(device, deviceName, message)
override suspend fun respond(request: Envelope): Envelope = respond(device, deviceName, request)
override fun propertyChanged(propertyName: String, value: MetaItem<*>?) {
if (value == null) return
scope.launch {
val change = PropertyChangedMessage(
sourceDevice = deviceName, sourceDevice = deviceName,
key = propertyName, key = propertyName,
value = value, value = value,
) )
val envelope = SimpleEnvelope(change.toMeta(), Binary.EMPTY)
outputChannel.send(envelope)
}
} }
public fun receiving(): Flow<Envelope> = outputChannel.consumeAsFlow() /**
* The flow of outgoing messages
*/
public val messages: Flow<DeviceMessage> get() = propertyChanges
public suspend fun respondMessage(message: DeviceMessage): DeviceMessage =
respondMessage(device, deviceName, message)
@DFExperimental
override fun consume(message: Envelope) {
// Fire the respond procedure and forget about the result
scope.launch {
respond(message)
}
}
public companion object { public companion object {
public const val GET_PROPERTY_ACTION: String = "read" public const val GET_PROPERTY_ACTION: String = "read"
@ -65,29 +45,21 @@ public class DeviceController(
public const val PROPERTY_LIST_ACTION: String = "propertyList" public const val PROPERTY_LIST_ACTION: String = "propertyList"
public const val ACTION_LIST_ACTION: String = "actionList" public const val ACTION_LIST_ACTION: String = "actionList"
internal suspend fun respond(device: Device, deviceTarget: String, request: Envelope): Envelope { // internal suspend fun respond(device: Device, deviceTarget: String, request: Envelope): Envelope {
val target = request.meta["target"].string // val target = request.meta["target"].string
return try { // return try {
if (request.data == null) { // if (device is Responder) {
respondMessage(device, deviceTarget, DeviceMessage.fromMeta(request.meta)).toEnvelope() // device.respond(request)
} else if (target != null && target != deviceTarget) { // } else if (request.data == null) {
error("Wrong target name $deviceTarget expected but $target found") // respondMessage(device, deviceTarget, DeviceMessage.fromMeta(request.meta)).toEnvelope()
} else { // } else if (target != null && target != deviceTarget) {
if (device is ResponderDevice) { // error("Wrong target name $deviceTarget expected but $target found")
val response = device.respondWithData(request).apply { // } else error("Device does not support binary response")
meta { // } catch (ex: Exception) {
"target" put request.meta["source"].string // val requestSourceName = request.meta[DeviceMessage.SOURCE_KEY].string
"source" put deviceTarget // DeviceMessage.error(ex, sourceDevice = deviceTarget, targetDevice = requestSourceName).toEnvelope()
} // }
} // }
response.seal()
} else error("Device does not support binary response")
}
} catch (ex: Exception) {
val requestSourceName = request.meta[DeviceMessage.SOURCE_KEY].string
DeviceMessage.error(ex, sourceDevice = deviceTarget, targetDevice = requestSourceName).toEnvelope()
}
}
internal suspend fun respondMessage( internal suspend fun respondMessage(
device: Device, device: Device,

View File

@ -1,67 +1,53 @@
package hep.dataforge.control.controllers package hep.dataforge.control.controllers
import hep.dataforge.control.api.DeviceHub import hep.dataforge.control.api.DeviceHub
import hep.dataforge.control.api.DeviceListener
import hep.dataforge.control.api.get import hep.dataforge.control.api.get
import hep.dataforge.control.messages.DeviceMessage import hep.dataforge.control.messages.DeviceMessage
import hep.dataforge.control.messages.PropertyChangedMessage
import hep.dataforge.control.messages.toEnvelope
import hep.dataforge.io.Consumer
import hep.dataforge.io.Envelope
import hep.dataforge.io.Responder
import hep.dataforge.meta.DFExperimental import hep.dataforge.meta.DFExperimental
import hep.dataforge.meta.MetaItem
import hep.dataforge.meta.get
import hep.dataforge.meta.string
import hep.dataforge.names.Name import hep.dataforge.names.Name
import hep.dataforge.names.NameToken
import hep.dataforge.names.toName import hep.dataforge.names.toName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@OptIn(DFExperimental::class) @OptIn(DFExperimental::class)
public class HubController( public class HubController(
public val hub: DeviceHub, public val hub: DeviceHub,
public val scope: CoroutineScope, ) {
) : Consumer, Responder {
private val messageOutbox = Channel<DeviceMessage>(Channel.CONFLATED) private val messageOutbox = Channel<DeviceMessage>(Channel.CONFLATED)
private val envelopeOutbox = Channel<Envelope>(Channel.CONFLATED) // private val envelopeOutbox = Channel<Envelope>(Channel.CONFLATED)
public fun messageOutput(): Flow<DeviceMessage> = messageOutbox.consumeAsFlow() public fun messageOutput(): Flow<DeviceMessage> = messageOutbox.consumeAsFlow()
public fun envelopeOutput(): Flow<Envelope> = envelopeOutbox.consumeAsFlow() // public fun envelopeOutput(): Flow<Envelope> = envelopeOutbox.consumeAsFlow()
private val packJob = scope.launch { // private val packJob = scope.launch {
while (isActive) { // while (isActive) {
val message = messageOutbox.receive() // val message = messageOutbox.receive()
envelopeOutbox.send(message.toEnvelope()) // envelopeOutbox.send(message.toEnvelope())
} // }
} // }
private val listeners: Map<NameToken, DeviceListener> = hub.devices.mapValues { (name, device) -> // private val listeners: Map<NameToken, DeviceListener> = hub.devices.mapValues { (deviceNameToken, device) ->
object : DeviceListener { // object : DeviceListener {
override fun propertyChanged(propertyName: String, value: MetaItem<*>?) { // override fun propertyChanged(propertyName: String, value: MetaItem<*>?) {
if (value == null) return // if (value == null) return
scope.launch { // scope.launch {
val change = PropertyChangedMessage( // val change = PropertyChangedMessage(
sourceDevice = name.toString(), // sourceDevice = deviceNameToken.toString(),
key = propertyName, // key = propertyName,
value = value // value = value
) // )
messageOutbox.send(change) // messageOutbox.send(change)
} // }
} // }
}.also { // }.also {
device.registerListener(it) // device.registerListener(it)
} // }
} // }
public suspend fun respondMessage(message: DeviceMessage): DeviceMessage = try { public suspend fun respondMessage(message: DeviceMessage): DeviceMessage = try {
val targetName = message.targetDevice?.toName() ?: Name.EMPTY val targetName = message.targetDevice?.toName() ?: Name.EMPTY
@ -70,24 +56,24 @@ public class HubController(
} catch (ex: Exception) { } catch (ex: Exception) {
DeviceMessage.error(ex, sourceDevice = null, targetDevice = message.sourceDevice) DeviceMessage.error(ex, sourceDevice = null, targetDevice = message.sourceDevice)
} }
//
override suspend fun respond(request: Envelope): Envelope = try { // override suspend fun respond(request: Envelope): Envelope = try {
val targetName = request.meta[DeviceMessage.TARGET_KEY].string?.toName() ?: Name.EMPTY // val targetName = request.meta[DeviceMessage.TARGET_KEY].string?.toName() ?: Name.EMPTY
val device = hub[targetName] ?: error("The device with name $targetName not found in $hub") // val device = hub[targetName] ?: error("The device with name $targetName not found in $hub")
if (request.data == null) { // if (request.data == null) {
DeviceController.respondMessage(device, targetName.toString(), DeviceMessage.fromMeta(request.meta)) // DeviceController.respondMessage(device, targetName.toString(), DeviceMessage.fromMeta(request.meta))
.toEnvelope() // .toEnvelope()
} else { // } else {
DeviceController.respond(device, targetName.toString(), request) // DeviceController.respond(device, targetName.toString(), request)
} // }
} catch (ex: Exception) { // } catch (ex: Exception) {
DeviceMessage.error(ex, sourceDevice = null).toEnvelope() // DeviceMessage.error(ex, sourceDevice = null).toEnvelope()
} // }
//
override fun consume(message: Envelope) { // override fun consume(message: Envelope) {
// Fire the respond procedure and forget about the result // // Fire the respond procedure and forget about the result
scope.launch { // scope.launch {
respond(message) // respond(message)
} // }
} // }
} }

View File

@ -1,30 +0,0 @@
package hep.dataforge.control.controllers
import hep.dataforge.control.api.Device
import hep.dataforge.control.api.DeviceListener
import hep.dataforge.meta.MetaItem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
/**
* Flow changes of all properties of a given device ignoring invalidation events
*/
@OptIn(ExperimentalCoroutinesApi::class)
public suspend fun Device.flowPropertyChanges(): Flow<Pair<String, MetaItem<*>>> = callbackFlow {
val listener = object : DeviceListener {
override fun propertyChanged(propertyName: String, value: MetaItem<*>?) {
if (value != null) {
launch {
send(propertyName to value)
}
}
}
}
registerListener(listener)
awaitClose {
removeListeners(listener)
}
}

View File

@ -2,8 +2,6 @@ package hep.dataforge.control.messages
import hep.dataforge.io.SimpleEnvelope import hep.dataforge.io.SimpleEnvelope
import hep.dataforge.meta.* import hep.dataforge.meta.*
import hep.dataforge.names.Name
import hep.dataforge.names.asName
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -18,9 +16,6 @@ public sealed class DeviceMessage{
public companion object { public companion object {
public val SOURCE_KEY: Name = DeviceMessage::sourceDevice.name.asName()
public val TARGET_KEY: Name = DeviceMessage::targetDevice.name.asName()
public fun error( public fun error(
cause: Throwable, cause: Throwable,
sourceDevice: String?, sourceDevice: String?,
@ -131,6 +126,18 @@ public data class EmptyDeviceMessage(
override val comment: String? = null, override val comment: String? = null,
) : DeviceMessage() ) : DeviceMessage()
/**
* Information log message
*/
@Serializable
@SerialName("log")
public data class DeviceLogMessage(
val message: String,
override val sourceDevice: String? = null,
override val targetDevice: String? = null,
override val comment: String? = null,
) : DeviceMessage()
/** /**
* The evaluation of the message produced a service error * The evaluation of the message produced a service error
*/ */

View File

@ -3,10 +3,6 @@ plugins {
id("ru.mipt.npm.publish") id("ru.mipt.npm.publish")
} }
kscience {
useSerialization()
}
val dataforgeVersion: String by rootProject.extra val dataforgeVersion: String by rootProject.extra
val ktorVersion: String by rootProject.extra val ktorVersion: String by rootProject.extra

View File

@ -2,13 +2,8 @@ plugins {
id("ru.mipt.npm.mpp") id("ru.mipt.npm.mpp")
} }
val ktorVersion: String by rootProject.extra val ktorVersion: String by rootProject.extra
kscience{
useCoroutines()
}
kotlin { kotlin {
sourceSets { sourceSets {
commonMain { commonMain {

View File

@ -23,6 +23,8 @@ dependencies{
implementation("no.tornado:tornadofx:1.7.20") implementation("no.tornado:tornadofx:1.7.20")
implementation(kotlin("stdlib-jdk8")) implementation(kotlin("stdlib-jdk8"))
implementation("kscience.plotlykt:plotlykt-server:0.3.0") implementation("kscience.plotlykt:plotlykt-server:0.3.0")
implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
} }
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
@ -37,5 +39,5 @@ javafx{
} }
application{ application{
mainClassName = "hep.dataforge.control.demo.DemoControllerViewKt" mainClass.set("hep.dataforge.control.demo.DemoControllerViewKt")
} }

View File

@ -0,0 +1,10 @@
package hep.dataforge.control.demo
import com.github.ricky12awesome.jss.encodeToSchema
import com.github.ricky12awesome.jss.globalJson
import hep.dataforge.control.messages.DeviceMessage
fun main() {
val schema = globalJson.encodeToSchema(DeviceMessage.serializer(), generateDefinitions = false)
println(schema)
}

View File

@ -7,7 +7,6 @@ kscience {
useSerialization{ useSerialization{
json() json()
} }
useCoroutines("1.4.0", configuration = ru.mipt.npm.gradle.DependencyConfiguration.API)
} }
val dataforgeVersion: String by rootProject.extra val dataforgeVersion: String by rootProject.extra

View File

@ -7,7 +7,6 @@ kscience {
useSerialization{ useSerialization{
json() json()
} }
useCoroutines("1.4.1", configuration = ru.mipt.npm.gradle.DependencyConfiguration.API)
} }
val dataforgeVersion: String by rootProject.extra val dataforgeVersion: String by rootProject.extra