Skip to content

Commit

Permalink
Add WebBrowser as a device
Browse files Browse the repository at this point in the history
  • Loading branch information
xelahalo committed Mar 8, 2024
1 parent e058fc8 commit 71ba790
Show file tree
Hide file tree
Showing 15 changed files with 408 additions and 48 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package dk.cachet.carp.common.application

import dk.cachet.carp.common.infrastructure.serialization.createCarpStringPrimitiveSerializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlin.js.JsExport

/**
* Represents a Uniform Resource Locator (URL) according to RFC 3986. We use [URIRegex] to match
* [stringRepresentation], but it cannot do actual validation. Therefore, [DefaultURLValidator] is used to validate
* the URL.
*
* The URL of the web page which contains the task to be performed.
* The URL may contain [URL.Variable] patterns which will be replaced with the corresponding values by the client
* runtime.
*
* @param stringRepresentation The string representation of the URL.
*/
@Serializable( URLSerializer::class )
@JsExport
class URL ( val stringRepresentation: String )
{
init
{
require(
URIRegex.matches( stringRepresentation ) && DefaultURLValidator.isValid( stringRepresentation )
)
{
"Invalid URI string representation."
}
}

companion object
{
private fun markup( name: String ) = name.uppercase().replace( ' ', '-' ).let { "{{$it}}" }
}

fun replaceVariables( participantId: UUID, deploymentId: UUID, triggerId: Int ): String =
stringRepresentation.replace( Variable.PARTICIPANT_ID.pattern, participantId.toString() )
.replace( Variable.DEPLOYMENT_ID.pattern, deploymentId.toString() )
.replace( Variable.TRIGGER_ID.pattern, triggerId.toString() )

override fun equals( other: Any? ): Boolean
{
if ( this === other ) return true
if ( other !is URL ) return false

return stringRepresentation == other.stringRepresentation
}

override fun hashCode(): Int = stringRepresentation.hashCode()

override fun toString(): String = stringRepresentation

enum class Variable( val pattern: String )
{
/**
* Uniquely identifies the participant in the study.
*/
PARTICIPANT_ID(markup("participant id")),

/**
* Uniquely identifies the deployment (group of participants and devices) of the study.
*/
DEPLOYMENT_ID(markup("deployment id")),

/**
* Identifies the condition, defined by the study protocol, which causes the event to be triggered.
*/
TRIGGER_ID(markup("trigger id"))
}
}

/**
* Regular expression to match any URI according to
* [Appendix B of RFC 3986/STD 0066](https://www.rfc-editor.org/rfc/rfc3986#appendix-B)
*
* TODO: [URIRegex] allows us to write our own validation, but for the most part, what we have now is sufficient.
*/
val URIRegex = Regex("^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?")

/**
* A custom serializer for [URL].
*/
object URLSerializer : KSerializer<URL> by createCarpStringPrimitiveSerializer( { URL( it ) } )

/**
* A default [URLValidator], which propagates the validation to the platform-specific implementation.
*
* NOTE: we do not do scheme-specific validation (e.g., checking if the host portion is not empty for http/https URLs).
* This is because the URL class is used to represent any kind of URL, including custom schemes.
*/
expect object DefaultURLValidator : URLValidator
{
override fun isValid( url: String ): Boolean
}

interface URLValidator
{
fun isValid( url: String ): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
@file:Suppress("NON_EXPORTABLE_TYPE")

package dk.cachet.carp.common.application.devices

import dk.cachet.carp.common.application.Trilean
import dk.cachet.carp.common.application.URL
import dk.cachet.carp.common.application.data.DataType
import dk.cachet.carp.common.application.sampling.DataTypeSamplingSchemeMap
import dk.cachet.carp.common.application.sampling.SamplingConfiguration
import dk.cachet.carp.common.application.tasks.TaskConfigurationList
import dk.cachet.carp.common.application.toTrilean
import dk.cachet.carp.common.infrastructure.serialization.NotSerializable
import kotlinx.serialization.Required
import kotlinx.serialization.Serializable
import kotlin.js.JsExport
import kotlin.reflect.KClass


@Serializable
@JsExport
data class WebBrowser(
val possibleTypes: Set<String>,
override val roleName: String,
override val isOptional: Boolean = false
) : PrimaryDeviceConfiguration<WebBrowserDeviceRegistration, WebBrowserDeviceRegistrationBuilder>()
{
object Sensors : DataTypeSamplingSchemeMap()
object Tasks : TaskConfigurationList()

override fun getSupportedDataTypes(): Set<DataType> = Sensors.keys

override val defaultSamplingConfiguration: Map<DataType, SamplingConfiguration> = emptyMap()

override fun getDataTypeSamplingSchemes(): DataTypeSamplingSchemeMap = Sensors

override fun createDeviceRegistrationBuilder(): WebBrowserDeviceRegistrationBuilder =
WebBrowserDeviceRegistrationBuilder()

override fun getRegistrationClass(): KClass<WebBrowserDeviceRegistration> = WebBrowserDeviceRegistration::class

override fun isValidRegistration( registration: WebBrowserDeviceRegistration ): Trilean =
possibleTypes.any { it.equals( registration.browser, ignoreCase = true ) }.toTrilean()
}


@Serializable
@JsExport
data class WebBrowserDeviceRegistration(
val browser: String,
val url: URL,
@Required
override val deviceDisplayName: String? = null
) : DeviceRegistration()
{
@Required
override val deviceId: String = url.hashCode().toString()
}


@Suppress( "SERIALIZER_TYPE_INCOMPATIBLE" )
@Serializable( NotSerializable::class )
@JsExport
class WebBrowserDeviceRegistrationBuilder : DeviceRegistrationBuilder<WebBrowserDeviceRegistration>()
{
var browser: String = ""
var url: String = ""
override fun build(): WebBrowserDeviceRegistration = WebBrowserDeviceRegistration( browser, URL( url ) )
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

package dk.cachet.carp.common.application.tasks

import dk.cachet.carp.common.application.URL
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.application.data.NoData
import dk.cachet.carp.common.application.tasks.WebTask.UrlVariable
import kotlinx.serialization.*
import kotlin.js.JsExport

Expand All @@ -19,45 +19,20 @@ data class WebTask(
override val name: String,
/**
* The URL of the web page which contains the task to be performed.
* The URL may contain [UrlVariable] patterns which will be replaced with the corresponding values by the client runtime.
* The URL may contain [URL.Variable] patterns which will be replaced with the corresponding values by the client runtime.
*/
val url: String,
override val description: String? = null,
override val measures: List<Measure> = emptyList(),
) : TaskConfiguration<NoData> // The execution of the task is delegated to a web page, so this task uploads no data.
{
companion object
{
private fun markup( name: String ) = name.uppercase().replace( ' ', '-' ).let { "{{$it}}" }
}


enum class UrlVariable( val pattern: String )
{
/**
* Uniquely identifies the participant in the study.
*/
PARTICIPANT_ID( markup( "participant id" ) ),

/**
* Uniquely identifies the deployment (group of participants and devices) of the study.
*/
DEPLOYMENT_ID( markup( "deployment id" ) ),

/**
* Identifies the condition, defined by the study protocol, which caused the [WebTask] to be triggered.
*/
TRIGGER_ID( markup( "trigger id" ) )
}


/**
* Replace the variables in [url] with the specified runtime values, if the variables are present.
*
* Propagates construction of [url] to [URL]. Kept for backwards compatibility.
*/
fun constructUrl( participantId: UUID, studyDeploymentId: UUID, triggerId: Int ): String = url
.replace( UrlVariable.PARTICIPANT_ID.pattern, participantId.toString() )
.replace( UrlVariable.DEPLOYMENT_ID.pattern, studyDeploymentId.toString() )
.replace( UrlVariable.TRIGGER_ID.pattern, triggerId.toString() )
fun constructUrl( participantId: UUID, studyDeploymentId: UUID, triggerId: Int ): String =
URL( url ).replaceVariables( participantId, studyDeploymentId, triggerId )
}


Expand All @@ -69,7 +44,7 @@ class WebTaskBuilder : TaskConfigurationBuilder<WebTask>()
{
/**
* The URL of the web page which contains the task to be performed.
* The URL may contain [UrlVariable] patterns which will be replaced with the corresponding values by the client runtime.
* The URL may contain [URL.Variable] patterns which will be replaced with the corresponding values by the client runtime.
*/
var url: String = ""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ val COMMON_SERIAL_MODULE = SerializersModule {
{
subclass( CustomProtocolDevice::class )
subclass( Smartphone::class )
subclass( WebBrowser::class )

subclass( CustomPrimaryDeviceConfiguration::class )
}
Expand All @@ -87,6 +88,7 @@ val COMMON_SERIAL_MODULE = SerializersModule {
subclass( BLESerialNumberDeviceRegistration::class )
subclass( DefaultDeviceRegistration::class )
subclass( MACAddressDeviceRegistration::class )
subclass( WebBrowserDeviceRegistration::class )

subclass( CustomDeviceRegistration::class )
defaultDeserializer { DeviceRegistrationSerializer }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ val commonInstances = listOf(
BLESerialNumberDeviceRegistration( "123456789" ),
CustomProtocolDevice( "User's phone" ),
Smartphone( "User's phone" ),
WebBrowser( setOf( "CHROME" ), "Example browser" ),
WebBrowserDeviceRegistration( "CHROME", URL("https://example.com" ), "User's browser"),

// Shared device registrations in `devices` namespace.
DefaultDeviceRegistration(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package dk.cachet.carp.common.application

import dk.cachet.carp.common.application.tasks.WebTask
import dk.cachet.carp.common.application.tasks.WebTaskBuilder
import dk.cachet.carp.common.infrastructure.serialization.createDefaultJSON
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class URLTest
{

@Test
fun can_serialize_and_deserialize_URL_using_JSON()
{
val url = URL( "https://www.example.com" )

val json = createDefaultJSON()
val serialized = json.encodeToString( URL.serializer(), url )
val parsed = json.decodeFromString( URL.serializer(), serialized )

assertEquals( url, parsed )
}

@Test
fun cant_initialize_url()
{
// if string is empty
assertFailsWith<IllegalArgumentException> { URL( "" ) }
// with no scheme
assertFailsWith<IllegalArgumentException> { URL ( "www.example.com" ) }
}

@Test
fun can_initialize_url()
{
// with custom scheme
val url1 = URL( "app://welcome" )
// with explicit port
val url2 = URL( "http://localhost:3000" )
// with query parameters
val url3 = URL( "https://www.example.com?param1=value1&param2=value2" )
// with redirect uri
val url4 = URL( "https://www.example.com/?url=https://www.example2.com" )

assertEquals( "app://welcome", url1.stringRepresentation )
assertEquals( "http://localhost:3000", url2.stringRepresentation )
assertEquals( "https://www.example.com?param1=value1&param2=value2", url3.stringRepresentation )
assertEquals( "https://www.example.com/?url=https://www.example2.com", url4.stringRepresentation )
}

@Test
fun url_variable_pattern_is_expected_format()
{
val id = URL.Variable.PARTICIPANT_ID

assertEquals( "{{PARTICIPANT-ID}}", id.pattern )
}

@Test
fun replaceVariables_succeeds()
{
val surveyUrl = "http://awesomesurvey.com/42"
val urlWithVariables =
"$surveyUrl?" +
"participantId=${URL.Variable.PARTICIPANT_ID.pattern}" +
"&deploymentId=${URL.Variable.DEPLOYMENT_ID.pattern}" +
"&triggerId=${URL.Variable.TRIGGER_ID.pattern}"

val url = URL( urlWithVariables )

val participantId = UUID.randomUUID()
val deploymentId = UUID.randomUUID()
val triggerId = 42
val constructedUrl = url.replaceVariables( participantId, deploymentId, triggerId )

assertEquals(
"$surveyUrl?participantId=$participantId&deploymentId=$deploymentId&triggerId=$triggerId",
constructedUrl
)
}
}
Loading

0 comments on commit 71ba790

Please sign in to comment.