mirror of
https://github.com/psforever/PSF-LoginServer.git
synced 2026-07-15 16:34:42 +00:00
changing the reporting visibility of the event bus broadcast channel and removing the need to maintain an 'original channel'
This commit is contained in:
parent
f05a0ab96a
commit
dbd90e6eb1
20 changed files with 44 additions and 48 deletions
|
|
@ -13,7 +13,7 @@ import net.psforever.services.base.message.ObjectDelete
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
final case class ReleaseEnvelope(
|
final case class ReleaseEnvelope(
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
filter: PlanetSideGUID,
|
filter: PlanetSideGUID,
|
||||||
msg: Release
|
msg: Release
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import net.psforever.types.PlanetSideGUID
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
final case class PickupItemEnvelope(
|
final case class PickupItemEnvelope(
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
filter: PlanetSideGUID,
|
filter: PlanetSideGUID,
|
||||||
msg: PickupItem,
|
msg: PickupItem,
|
||||||
zone: Zone
|
zone: Zone
|
||||||
|
|
@ -33,7 +33,7 @@ object PickupItemEnvelope {
|
||||||
}
|
}
|
||||||
|
|
||||||
final case class DropItemEnvelope(
|
final case class DropItemEnvelope(
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
filter: PlanetSideGUID,
|
filter: PlanetSideGUID,
|
||||||
msg: DropItem,
|
msg: DropItem,
|
||||||
zone: Zone
|
zone: Zone
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@ import org.log4s.Logger
|
||||||
*/
|
*/
|
||||||
trait EventSystemStamp {
|
trait EventSystemStamp {
|
||||||
/*
|
/*
|
||||||
Example Classifiers: "foo", "foo.fizz", and "foo.buzz"
|
Example:
|
||||||
In general, Classifier channels will perform left-pattern matching.
|
The channels are "foo", "foo.fizz", and "foo.buzz"
|
||||||
"foo" will publish to "foo", "foo.fizz", and "foo.buzz"
|
In general, Classifier channels will perform left-pattern matching
|
||||||
To isolate "foo", one must distinguish it with a right-pattern.
|
Publishing to channel "foo" will allocate Classifiers "foo", "foo.fizz", and "foo.buzz"
|
||||||
"foo" is appended as "foo.bar" and no longer publishes to "foo.fizz.bar" or to "foo.buzz.bar"
|
To isolate "foo", one must distinguish it with a right-pattern such as "out"
|
||||||
|
Publishing to channel "foo.out" no longer publishes to "foo.fizz.out" or to "foo.buzz.out"
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Take an input channel and produce the publishing output channel.
|
* Take an input channel and produce the publishing output channel.
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ trait CachedGenericEventEnvelope
|
||||||
|
|
||||||
final case class CachedEnvelope(
|
final case class CachedEnvelope(
|
||||||
guid: PlanetSideGUID,
|
guid: PlanetSideGUID,
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
filter: PlanetSideGUID,
|
filter: PlanetSideGUID,
|
||||||
msg: EventMessage
|
msg: EventMessage
|
||||||
) extends CachedGenericEventEnvelope {
|
) extends CachedGenericEventEnvelope {
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,20 @@ class GenericEventBus
|
||||||
type Event = GenericResponseEnvelope
|
type Event = GenericResponseEnvelope
|
||||||
type Classifier = String
|
type Classifier = String
|
||||||
|
|
||||||
protected def classify(event: Event): Classifier = event.channel
|
protected def classify(event: Event): Classifier = event.outChannel
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Example Classifiers: "foo", "foo.fizz", and "foo.buzz"
|
Example:
|
||||||
In general, Classifier channels will perform left-pattern matching.
|
The channels are "foo", "foo.fizz", and "foo.buzz"
|
||||||
"foo" will publish to "foo", "foo.fizz", and "foo.buzz"
|
In general, Classifier channels will perform left-pattern matching
|
||||||
|
Publishing to channel "foo" will allocate Classifiers "foo", "foo.fizz", and "foo.buzz"
|
||||||
|
See `GenericResponseEnvelope.outChannel` to determine how this is applied
|
||||||
*/
|
*/
|
||||||
protected def subclassification: Subclassification[String] =
|
protected def subclassification: Subclassification[String] = new Subclassification[Classifier] {
|
||||||
new Subclassification[Classifier] {
|
def isEqual(x: Classifier, y: Classifier): Boolean = x.equals(y)
|
||||||
def isEqual(x: Classifier, y: Classifier): Boolean = x == y
|
|
||||||
|
|
||||||
def isSubclass(x: Classifier, y: Classifier): Boolean = x.startsWith(y)
|
def isSubclass(x: Classifier, y: Classifier): Boolean = x.startsWith(y)
|
||||||
}
|
}
|
||||||
|
|
||||||
override def publish(event: Event): Unit = {
|
override def publish(event: Event): Unit = {
|
||||||
super[SubchannelClassification].publish(event)
|
super[SubchannelClassification].publish(event)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import net.psforever.types.PlanetSideGUID
|
||||||
* Defines a channel and a filter, both of which server the purpose of routing the message to its destination.
|
* Defines a channel and a filter, both of which server the purpose of routing the message to its destination.
|
||||||
*/
|
*/
|
||||||
trait AllEnvelopes {
|
trait AllEnvelopes {
|
||||||
/** set of subscribers on an event system bus the envelope should reach */
|
/** set of subscribers on an event system bus that the envelope should reach */
|
||||||
def channel: String
|
def channel: String
|
||||||
/** specific subscriber endpoint to be excluded (the subscriber should filter themselves) */
|
/** specific subscriber endpoint to be excluded (the subscriber should filter themselves upon receipt) */
|
||||||
def filter: PlanetSideGUID
|
def filter: PlanetSideGUID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import net.psforever.types.PlanetSideGUID
|
||||||
|
|
||||||
trait GenericMessageEnvelope
|
trait GenericMessageEnvelope
|
||||||
extends AllEnvelopes {
|
extends AllEnvelopes {
|
||||||
/** channel information provided with the message */
|
|
||||||
def originalChannel: String
|
|
||||||
/** input payload transported by this envelope */
|
/** input payload transported by this envelope */
|
||||||
def msg: EventMessage
|
def msg: EventMessage
|
||||||
/** method that counts as "processing" the envelope by an event system;
|
/** method that counts as "processing" the envelope by an event system;
|
||||||
|
|
@ -25,6 +23,6 @@ object GenericMessageEnvelope {
|
||||||
* @return a tuple containing the channel, filter, and reply message
|
* @return a tuple containing the channel, filter, and reply message
|
||||||
*/
|
*/
|
||||||
def unapply(obj: GenericMessageEnvelope): Option[(String, PlanetSideGUID, EventMessage)] = {
|
def unapply(obj: GenericMessageEnvelope): Option[(String, PlanetSideGUID, EventMessage)] = {
|
||||||
Some((obj.originalChannel, obj.filter, obj.msg))
|
Some((obj.channel, obj.filter, obj.msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ trait GenericResponseEnvelope
|
||||||
def reply: EventResponse
|
def reply: EventResponse
|
||||||
/** marker indicating the routing through which the original message was processed */
|
/** marker indicating the routing through which the original message was processed */
|
||||||
def stamp: EventSystemStamp
|
def stamp: EventSystemStamp
|
||||||
|
/** channel information tailored to the event system */
|
||||||
|
def outChannel: String = stamp.routing(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
object GenericResponseEnvelope {
|
object GenericResponseEnvelope {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ case object NoReply extends EventResponse
|
||||||
* A stamp that represents not having been processed by an event system.
|
* A stamp that represents not having been processed by an event system.
|
||||||
* Should never been given out to an event system.
|
* Should never been given out to an event system.
|
||||||
*/
|
*/
|
||||||
case object Undelivered extends EventSystemStamp
|
case object Undelivered extends EventSystemStamp {
|
||||||
|
override def routing(channel: String): String = ""
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The mechanics of a proper event system envelope.
|
* The mechanics of a proper event system envelope.
|
||||||
|
|
@ -32,16 +34,11 @@ trait MessageTransformationBehavior
|
||||||
extends GenericMessageEnvelope
|
extends GenericMessageEnvelope
|
||||||
with GenericResponseEnvelope {
|
with GenericResponseEnvelope {
|
||||||
private var outputStamp: EventSystemStamp = Undelivered
|
private var outputStamp: EventSystemStamp = Undelivered
|
||||||
private var outputChannel: String = originalChannel
|
|
||||||
private var outputReply: EventResponse = NoReply
|
private var outputReply: EventResponse = NoReply
|
||||||
|
|
||||||
// satisfies GenericMessageEnvelope (and GenericResponseEnvelope)
|
|
||||||
def channel: String = outputChannel
|
|
||||||
|
|
||||||
// satisfies GenericMessageEnvelope
|
// satisfies GenericMessageEnvelope
|
||||||
def response(stamp: EventSystemStamp): GenericResponseEnvelope = {
|
def response(stamp: EventSystemStamp): GenericResponseEnvelope = {
|
||||||
outputStamp = stamp
|
outputStamp = stamp
|
||||||
outputChannel = stamp.routing(originalChannel)
|
|
||||||
outputReply = msg.response()
|
outputReply = msg.response()
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
|
|
@ -54,5 +51,5 @@ trait MessageTransformationBehavior
|
||||||
/**
|
/**
|
||||||
* A proper event system envelope.
|
* A proper event system envelope.
|
||||||
*/
|
*/
|
||||||
case class MessageEnvelope(originalChannel: String, filter: PlanetSideGUID, msg: EventMessage)
|
case class MessageEnvelope(channel: String, filter: PlanetSideGUID, msg: EventMessage)
|
||||||
extends MessageTransformationBehavior
|
extends MessageTransformationBehavior
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ case object GalaxyStamp extends EventSystemStamp {
|
||||||
if (channel.trim.isEmpty) {
|
if (channel.trim.isEmpty) {
|
||||||
"/out"
|
"/out"
|
||||||
} else {
|
} else {
|
||||||
s"/$channel/out"
|
super.routing(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import scala.annotation.tailrec
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
final case class DoorMessage(
|
final case class DoorMessage(
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
msg: IsADoorMessage,
|
msg: IsADoorMessage,
|
||||||
supportMessage: Any
|
supportMessage: Any
|
||||||
) extends GenericSupportEnvelope {
|
) extends GenericSupportEnvelope {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import scala.annotation.tailrec
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
final case class HackEntityEnvelope(
|
final case class HackEntityEnvelope(
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
filter: PlanetSideGUID,
|
filter: PlanetSideGUID,
|
||||||
msg: IsAHackMessage,
|
msg: IsAHackMessage,
|
||||||
supportMessage: Any
|
supportMessage: Any
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import net.psforever.objects.zones.{Zone, ZoneMap}
|
||||||
import net.psforever.packet.game.{InventoryStateMessage, RepairMessage}
|
import net.psforever.packet.game.{InventoryStateMessage, RepairMessage}
|
||||||
import net.psforever.types._
|
import net.psforever.types._
|
||||||
import org.specs2.mutable.Specification
|
import org.specs2.mutable.Specification
|
||||||
import net.psforever.services.avatar.AvatarAction
|
|
||||||
import net.psforever.services.base.envelope.MessageEnvelope
|
import net.psforever.services.base.envelope.MessageEnvelope
|
||||||
import net.psforever.services.base.message.{PlanetsideAttribute, SendResponse}
|
import net.psforever.services.base.message.{PlanetsideAttribute, SendResponse}
|
||||||
import net.psforever.services.vehicle.VehicleAction
|
import net.psforever.services.vehicle.VehicleAction
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import net.psforever.objects.vehicles.control.VehicleControl
|
||||||
import net.psforever.objects.zones.{Zone, ZoneMap}
|
import net.psforever.objects.zones.{Zone, ZoneMap}
|
||||||
import net.psforever.packet.game.{InventoryStateMessage, RepairMessage}
|
import net.psforever.packet.game.{InventoryStateMessage, RepairMessage}
|
||||||
import net.psforever.types._
|
import net.psforever.types._
|
||||||
import net.psforever.services.avatar.AvatarAction
|
|
||||||
import net.psforever.services.base.envelope.MessageEnvelope
|
import net.psforever.services.base.envelope.MessageEnvelope
|
||||||
import net.psforever.services.base.message.{PlanetsideAttribute, SendResponse}
|
import net.psforever.services.base.message.{PlanetsideAttribute, SendResponse}
|
||||||
import net.psforever.services.vehicle.VehicleAction
|
import net.psforever.services.vehicle.VehicleAction
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import net.psforever.packet.game._
|
||||||
import net.psforever.services.ServiceManager
|
import net.psforever.services.ServiceManager
|
||||||
import net.psforever.services.base.envelope.MessageEnvelope
|
import net.psforever.services.base.envelope.MessageEnvelope
|
||||||
import net.psforever.services.base.message.{PlanetsideAttribute, SendResponse}
|
import net.psforever.services.base.message.{PlanetsideAttribute, SendResponse}
|
||||||
import net.psforever.services.vehicle.{VehicleAction, VehicleServiceMessage}
|
import net.psforever.services.vehicle.VehicleAction
|
||||||
import net.psforever.types._
|
import net.psforever.types._
|
||||||
|
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import net.psforever.objects.{GlobalDefinitions, Player}
|
||||||
import net.psforever.types.{CharacterSex, CharacterVoice, PlanetSideEmpire, PlanetSideGUID}
|
import net.psforever.types.{CharacterSex, CharacterVoice, PlanetSideEmpire, PlanetSideGUID}
|
||||||
import org.specs2.mutable.Specification
|
import org.specs2.mutable.Specification
|
||||||
import net.psforever.services.Service
|
import net.psforever.services.Service
|
||||||
import net.psforever.services.local.LocalService
|
|
||||||
|
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
import akka.actor.typed.scaladsl.adapter._
|
import akka.actor.typed.scaladsl.adapter._
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ class EnvelopeTest extends Specification {
|
||||||
val input = MessageEnvelope("test", TestFilter, TestMessage(5))
|
val input = MessageEnvelope("test", TestFilter, TestMessage(5))
|
||||||
val output = input.response(TestStamp)
|
val output = input.response(TestStamp)
|
||||||
output match {
|
output match {
|
||||||
case reply @ GenericResponseEnvelope("/test/out", TestFilter, TestMessage(5)) =>
|
case reply @ GenericResponseEnvelope("test", TestFilter, TestMessage(5)) =>
|
||||||
reply.stamp mustEqual TestStamp
|
reply.stamp mustEqual TestStamp
|
||||||
case _ =>
|
case _ =>
|
||||||
ko
|
ko
|
||||||
|
|
@ -67,7 +67,7 @@ class EnvelopeTest extends Specification {
|
||||||
val input = MessageEnvelope("test", TestFilter, TestInputMessage(5))
|
val input = MessageEnvelope("test", TestFilter, TestInputMessage(5))
|
||||||
val output = input.response(TestStamp)
|
val output = input.response(TestStamp)
|
||||||
output match {
|
output match {
|
||||||
case reply @ GenericResponseEnvelope("/test/out", TestFilter, TestOutputEvent(7)) =>
|
case reply @ GenericResponseEnvelope("test", TestFilter, TestOutputEvent(7)) =>
|
||||||
reply.stamp mustEqual TestStamp
|
reply.stamp mustEqual TestStamp
|
||||||
case _ =>
|
case _ =>
|
||||||
ko
|
ko
|
||||||
|
|
@ -79,7 +79,7 @@ class EnvelopeTest extends Specification {
|
||||||
"construct (quick)" in {
|
"construct (quick)" in {
|
||||||
val input = GenericResponseEnvelope(TestStamp, "test", TestFilter, TestMessage(5))
|
val input = GenericResponseEnvelope(TestStamp, "test", TestFilter, TestMessage(5))
|
||||||
input match {
|
input match {
|
||||||
case reply @ GenericResponseEnvelope("/test/out", TestFilter, TestMessage(5)) =>
|
case reply @ GenericResponseEnvelope("test", TestFilter, TestMessage(5)) =>
|
||||||
reply.stamp mustEqual TestStamp
|
reply.stamp mustEqual TestStamp
|
||||||
case _ =>
|
case _ =>
|
||||||
ko
|
ko
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import scala.concurrent.duration._
|
||||||
object EventServiceCacheSupportTest {
|
object EventServiceCacheSupportTest {
|
||||||
final case class CachedSupportTestEnvelope(
|
final case class CachedSupportTestEnvelope(
|
||||||
guid: PlanetSideGUID,
|
guid: PlanetSideGUID,
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
override val msg: EventMessage,
|
override val msg: EventMessage,
|
||||||
supportMessage: Any
|
supportMessage: Any
|
||||||
) extends CachedGenericEventEnvelope with GenericSupportEnvelope {
|
) extends CachedGenericEventEnvelope with GenericSupportEnvelope {
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ class EventServiceTestNotSubscribed extends ActorTest {
|
||||||
events ! MessageEnvelope("test", Service.defaultPlayerGUID, TestMessage(5))
|
events ! MessageEnvelope("test", Service.defaultPlayerGUID, TestMessage(5))
|
||||||
val reply = probe.receiveOne(100 milliseconds)
|
val reply = probe.receiveOne(100 milliseconds)
|
||||||
reply match {
|
reply match {
|
||||||
case GenericResponseEnvelope("/test/out", _, TestMessage(5)) => ()
|
case GenericResponseEnvelope("test", _, TestMessage(5)) => ()
|
||||||
case _ => assert(false, "(2) message expected but not received")
|
case _ => assert(false, "(2) message expected but not received")
|
||||||
}
|
}
|
||||||
missedProbe.expectNoMessage(100 milliseconds)
|
missedProbe.expectNoMessage(100 milliseconds)
|
||||||
|
|
@ -127,11 +127,11 @@ class EventServiceTestLeave extends ActorTest {
|
||||||
events ! MessageEnvelope("anotherTest", Service.defaultPlayerGUID, TestMessage(5))
|
events ! MessageEnvelope("anotherTest", Service.defaultPlayerGUID, TestMessage(5))
|
||||||
val reply1 = probe.receiveN(2, 100 milliseconds)
|
val reply1 = probe.receiveN(2, 100 milliseconds)
|
||||||
reply1.head match {
|
reply1.head match {
|
||||||
case GenericResponseEnvelope("/test/out", _, _) => ()
|
case GenericResponseEnvelope("test", _, _) => ()
|
||||||
case _ => assert(false, "(3) message expected but not received")
|
case _ => assert(false, "(3) message expected but not received")
|
||||||
}
|
}
|
||||||
reply1(1) match {
|
reply1(1) match {
|
||||||
case GenericResponseEnvelope("/anotherTest/out", _, _) => ()
|
case GenericResponseEnvelope("anotherTest", _, _) => ()
|
||||||
case _ => assert(false, "(4) message expected but not received")
|
case _ => assert(false, "(4) message expected but not received")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +140,7 @@ class EventServiceTestLeave extends ActorTest {
|
||||||
events ! MessageEnvelope("anotherTest", Service.defaultPlayerGUID, TestMessage(6))
|
events ! MessageEnvelope("anotherTest", Service.defaultPlayerGUID, TestMessage(6))
|
||||||
val reply2 = probe.receiveOne(100 milliseconds)
|
val reply2 = probe.receiveOne(100 milliseconds)
|
||||||
reply2 match {
|
reply2 match {
|
||||||
case GenericResponseEnvelope("/test/out", _, TestMessage(5)) => ()
|
case GenericResponseEnvelope("test", _, TestMessage(5)) => ()
|
||||||
case _ => assert(false, "(5) message expected but not received")
|
case _ => assert(false, "(5) message expected but not received")
|
||||||
}
|
}
|
||||||
probe.expectNoMessage(250 milliseconds)
|
probe.expectNoMessage(250 milliseconds)
|
||||||
|
|
@ -161,11 +161,11 @@ class EventServiceTestLeaveAll extends ActorTest {
|
||||||
events ! MessageEnvelope("anotherTest", Service.defaultPlayerGUID, TestMessage(6))
|
events ! MessageEnvelope("anotherTest", Service.defaultPlayerGUID, TestMessage(6))
|
||||||
val reply = probe.receiveN(2,100 milliseconds)
|
val reply = probe.receiveN(2,100 milliseconds)
|
||||||
reply.head match {
|
reply.head match {
|
||||||
case GenericResponseEnvelope("/test/out", _, TestMessage(5)) => ()
|
case GenericResponseEnvelope("test", _, TestMessage(5)) => ()
|
||||||
case _ => assert(false, "(6) message expected but not received")
|
case _ => assert(false, "(6) message expected but not received")
|
||||||
}
|
}
|
||||||
reply(1) match {
|
reply(1) match {
|
||||||
case GenericResponseEnvelope("/anotherTest/out", _, TestMessage(6)) => ()
|
case GenericResponseEnvelope("anotherTest", _, TestMessage(6)) => ()
|
||||||
case _ => assert(false, "(7) message expected but not received")
|
case _ => assert(false, "(7) message expected but not received")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ object EventServiceTestBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
final case class TestSupportEnvelope(
|
final case class TestSupportEnvelope(
|
||||||
originalChannel: String,
|
channel: String,
|
||||||
msg: SupportActorRepliesWith
|
msg: SupportActorRepliesWith
|
||||||
) extends GenericSupportEnvelope {
|
) extends GenericSupportEnvelope {
|
||||||
def filter: PlanetSideGUID = Service.defaultPlayerGUID
|
def filter: PlanetSideGUID = Service.defaultPlayerGUID
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue