Skip to content

Commit

Permalink
Release 1.1.2
Browse files Browse the repository at this point in the history
Merge pull request #442 from cph-cachet/develop
  • Loading branch information
Whathecode authored May 8, 2023
2 parents cbda553 + aea801b commit 6c99354
Show file tree
Hide file tree
Showing 229 changed files with 3,947 additions and 5,338 deletions.
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ CARP Core is a software framework to help developers build research platforms to
It provides modules to define, deploy, and monitor research studies, and to collect data from multiple devices at multiple locations.

It is the result of a collaboration between [iMotions](https://imotions.com/) and the [Copenhagen Center for Health Technology (CACHET)](https://www.cachet.dk/).
Both use CARP Core to implement their respective research platforms: the [iMotions Mobile Research Platform](https://imotions.com/mobile-platform-landing-page-submissions/) and the [Copenhagen Research Platform (CARP)](https://carp.cachet.dk/).
CARP Core is now maintained fully by iMotions (since 1.0), but [still part of CARP](https://carp.cachet.dk/core/) as an ongoing collaboration.
Both use CARP Core to implement their respective research platforms: the [iMotions Mobile Research Platform](https://imotions.com/products/imotions-mobile/) and the [Copenhagen Research Platform (CARP)](https://carp.cachet.dk/).

Following [domain-driven design](https://en.wikipedia.org/wiki/Domain-driven_design), this project contains all domain models and application services for all CARP subsystems ([depicted below](#architecture)), not having any dependencies on concrete infrastructure.
As such, this project defines an **open standard for distributed data collection**, [available for Kotlin, the Java runtime, and JavaScript](#usage), which others can build upon to create their own infrastructure.
Expand Down Expand Up @@ -52,8 +51,9 @@ Two key **design goals** differentiate this project from similar projects:
- [Stub classes](#stub-classes)
- [Usage](#usage)
- [Example](#example)
- [Building the project](#building-the-project)
- [Development](#development)
- [Gradle tasks](#gradle-tasks)
- [Release management](#release-management)
- [Development checklists](#development-checklists)

## Architecture
Expand Down Expand Up @@ -192,7 +192,7 @@ val ownerId = UUID.randomUUID()
val protocol = StudyProtocol( ownerId, "Track patient movement" )

// Define which devices are used for data collection.
val phone = Smartphone( "Patient's phone" )
val phone = Smartphone.create( "Patient's phone" )
{
// Configure device-specific options, e.g., frequency to collect data at.
defaultSamplingConfiguration {
Expand Down Expand Up @@ -359,7 +359,7 @@ if ( status is StudyStatus.RegisteringDevices )
}
```

## Building the project
## Development

In case you want to contribute, please follow our [contribution guidelines](https://github.com/cph-cachet/carp.core-kotlin/blob/develop/CONTRIBUTING.md).

Expand All @@ -384,6 +384,29 @@ For `carp.core-kotlin`:
Preface with `setSnapshotVersion` task to publish to the snapshot repository, substituting the suffix of the version specified in `ext.globalVersion` with `-SNAPSHOT`.
See main `build.gradle` for details.

### Release management

[Semantic versioning](https://semver.org/) is used for releases.
Backwards compatibility is assessed from the perspective of clients using an implementation of the framework,
as opposed to developers using the framework to implement an infrastructure.
In other words, versioning is based on the exposed API (`application` namespaces), but not the domain used to implement infrastructures (`domain` namespaces).
Breaking changes between `minor` versions can occur in domain objects, including the need to do database migrations.

Module versions are configured in the main `build.gradle` in `ext.globalVersion` and `ext.clientsVersion`.

Workflows:
- Each push to `develop` triggers a snapshot release of the currently configured version.
- Each push to `master` triggers a release to Maven using the currently configured version.

Releases require a couple of manual steps:
- Before merging into `master`, make sure new versions are set in `build.gradle`.
This should be done already in the last step, but you may decide to make a bigger version increment.
- Merge into master; **don't rebase**. Rebasing causes branch commit histories to diverge which complicates later releases and messes up the visible commit history with duplicate commits.
- Create a release tag on `master` with release notes.
- Add `javascript-typescript-sources.zip` and `rpc-examples.zip` assets to release.
This should be automated in the future: [#371](https://github.com/cph-cachet/carp.core-kotlin/issues/371) and [#416](https://github.com/cph-cachet/carp.core-kotlin/issues/416) respectively.
- Bump versions on `develop` so that snapshot releases target the next version.

### Development checklists

When changes are made to CARP Core, various parts in the codebase sometimes need to be updated accordingly.
Expand Down
103 changes: 70 additions & 33 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,37 @@ buildscript {
ext {
// Version used for submodule artifacts.
// Snapshot publishing changes (or adds) the suffix after '-' with 'SNAPSHOT' prior to publishing.
globalVersion = '1.1.1'
clientsVersion = '1.1.1-alpha.1' // The clients subsystem is still expected to change drastically.
globalVersion = '1.1.2'
clientsVersion = '1.1.2-alpha.1' // The clients subsystem is still expected to change drastically.

versions = [
// Kotlin multiplatform versions.
kotlin:'1.8.0',
serialization:'1.4.1',
coroutines:'1.6.4',
kotlin:'1.8.21',
serialization:'1.5.0',
coroutines:'1.7.0',
datetime:'0.4.0',

// JVM versions.
jvmTarget:'1.8',
dokkaPlugin:'1.7.20',
dokkaPlugin:'1.8.10',
reflections:'0.10.2',

// JS versions.
nodePlugin:'3.5.0',
nodePlugin:'4.0.0',
bigJs:'6.2.1',

// DevOps versions.
detektPlugin:'1.22.0',
detektVerifyImplementation:'1.2.5',
nexusPublishPlugin:'1.1.0',
nexusPublishPlugin:'1.3.0',
apacheCommons:'2.11.0'
]

commonModule = subprojects.find { it.name == 'carp.common' }
coreModules = subprojects.findAll { it.name.endsWith( '.core' ) }
devOpsModules = subprojects.findAll { it.name == 'carp.detekt' || it.name == 'rpc' }
publishNpmModule = subprojects.find { it.name == 'publish-npm-packages' }
devOpsModules =
subprojects.findAll {it.name == 'carp.detekt' || it.name == 'rpc' } + publishNpmModule
}

dependencies {
Expand Down Expand Up @@ -82,9 +84,11 @@ configure( subprojects - devOpsModules ) {
useJUnitPlatform()
}
}
js(LEGACY) {
binaries.executable()
js(IR) {
moduleName = project.name.replaceAll("\\.", "-") + "-generated"
binaries.executable() // Export JS/TypeScript files.
browser()
generateTypeScriptDefinitions()
}

sourceSets {
Expand Down Expand Up @@ -114,6 +118,7 @@ configure( subprojects - devOpsModules ) {
// We do not mind being early adopters of Jetbrains APIs likely to change in the future.
optIn('kotlin.RequiresOptIn')
optIn('kotlin.time.ExperimentalTime')
optIn('kotlin.js.ExperimentalJsExport')
if (isTestSourceSet)
{
optIn('kotlinx.coroutines.ExperimentalCoroutinesApi')
Expand Down Expand Up @@ -234,41 +239,73 @@ task setupTsProject(type: NpmTask) {
args = ['install']
}
task copyTestJsSources(type: Copy, dependsOn: setupTsProject) {
// Make sure no old imported packages are left behind.
def importedPackages = file("$rootDir/build/js/packages_imported")
if (importedPackages.exists()) importedPackages.eachFile { it.delete() }

// Compile all subprojects which compile to JS.
// TODO: Can the compiled sources be copied from the tasks of which we want to test the output directly?
// We only need main sources of coreModules and commonModules since these are the only ones tested.
// But, only adding dependencies on those triggers warnings since other outputs exist in `/build//js/packages`.
def projects = subprojects - devOpsModules
// Compile production sources for CARP, and the JS publication project (`publishNpmModule`).
def projects = coreModules + commonModule + publishNpmModule
projects.each {
def project = it.name
dependsOn("$project:jsBrowserDistribution")
dependsOn("$project:compileTestKotlinJs")
dependsOn("$project:jsProductionExecutableCompileSync")
}

// Copy compiled sources and dependencies to test project node_modules.
from "$rootDir/build/js/packages"
exclude '**/node_modules/**'
from(importedPackages) {
eachFile {
def path = it.path
if (path == ".visited") return // We don't need this file.
// Copy compiled JS and TypeScript sources to test project's node_modules.
from("$rootDir/build/js/packages/publish-npm-packages-generated") {
include "**/*.js"
}
from("$rootDir/build/js/packages") {
// Use individually generated TypeScript declarations to exclude publish-npm-packages exports.
include "**/*.d.ts"
includeEmptyDirs = false
}
eachFile { file ->
// Compiled sources have the name of the module they represent, followed by ".js" and ".d.ts".
// To be recognized by node, place them as "index.js" and "index.d.ts" in "node_modules/@cachet/<module-name>".
def fileMatch = file.name =~ /(.+)\.(js|d\.ts)/
def moduleName = fileMatch[0][1]
def extension = fileMatch[0][2]
file.relativePath = new RelativePath(true, moduleName, "index.$extension")

// Non-exported types show up as `any/* some.unknown.Type */` in generated TypeScript sources.
// Types for which a facade has been manually added can be replaced with the actual type (instead of `any`).
def knownFacadeTypes = []
def knownFacadeTypesFile = new File("$rootDir/publish-npm-packages/src/known-facade-types")
knownFacadeTypesFile.eachLine { type -> knownFacadeTypes << type }

// Modify sources to act like modules with exported named members.
file.filter { line ->
// Compiled sources refer to other modules as adjacent .js source files.
// Change these to the named modules created in the previous step.
def namedModules = line.replaceAll(~/'\.\/(.+?)\.js'/, "'@cachet/\$1'")

// Remove intermediate version directory: e.g. "kotlin/1.5.10/kotlin.js"
it.path = path.replaceFirst(/\d+\.\d+.\d+(-.+)?\//, "")
// Replace `any` types with actual types for which facades are specified.
def replacedTypes = knownFacadeTypes.inject(namedModules) { curLine, type ->
def knownType = curLine.replaceAll(
~/any\/\* $type(<.+?>)? \*\//,
"$type\$1"
)
knownType.replaceAll(~/UnknownType \*/, "any")
}

// Add additional internal types to be exported, as configured in `forced-exports`.
def toExport = []
def forcedExportsFile = new File("$rootDir/publish-npm-packages/src/forced-exports/$moduleName")
if (forcedExportsFile.exists()) {
forcedExportsFile.eachLine { type -> toExport << type }
}
def toExportList = toExport.collect { "_.\\\$_\\\$.$it = $it\n " }
def additionalExports = replacedTypes.replaceAll(
~/return \_;/,
toExportList.join() + "return _;"
)
additionalExports
}
}
into "./$typescriptFolder/node_modules"
into "./$typescriptFolder/node_modules/@cachet/"
}
task compileTs(type: NpmTask, dependsOn: copyTestJsSources) {
workingDir = file(typescriptFolder)
args = ['run', 'tsc']
}
task verifyTsDeclarations(type: NodeTask, dependsOn: compileTs) {
script = file("${typescriptFolder}/node_modules/mocha/bin/mocha")
script = file("${typescriptFolder}/node_modules/mocha/bin/mocha.js")
execOverrides {
it.workingDir = typescriptFolder
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package dk.cachet.carp.common.test.infrastructure

import dk.cachet.carp.common.application.services.ApplicationService
import dk.cachet.carp.common.application.services.IntegrationEvent
import dk.cachet.carp.common.infrastructure.services.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertTrue


/**
* Base class to test whether an application service decorator correctly invokes the decorated service.
*/
@ExperimentalCoroutinesApi
@Suppress(
"FunctionName",
"UnnecessaryAbstractClass" // Prevent test being picked up by test runner.
)
abstract class ApplicationServiceDecoratorTest<
TService : ApplicationService<TService, TEvent>,
TEvent : IntegrationEvent<TService>,
TRequest : ApplicationServiceRequest<TService, *>
>(
private val requestsTest: ApplicationServiceRequestsTest<TService, TRequest>,
private val serviceInvoker: ApplicationServiceInvoker<TService, TRequest>
)
{
@Test
fun request_invoker_calls_service() = runTest {
// Create logged service.
val service = requestsTest.createService()
val logger = ApplicationServiceLogger<TService, TEvent>()
val eventBusLog = EventBusLog( SingleThreadedEventBus() ) // Ignore events.
val ignoreServiceInvocation =
object : Command<TRequest>
{
// The returned result goes unused in this test, so just return null.
override suspend fun invoke( request: TRequest ): Any? = null
}
val loggedService = requestsTest.decoratedServiceConstructor( service )
{ ApplicationServiceRequestLogger( eventBusLog, logger::addLog, ignoreServiceInvocation ) }

// Test whether each invoked method on the decorated service is converted back into the same request object.
// `requestTest` guarantees a request for each call is available.
requestsTest.requests.forEach {
serviceInvoker.invokeOnService( it, loggedService )
assertTrue(
logger.wasCalled( it ),
"Service wasn't called or parameters of called request don't match: $it"
)
logger.clear()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
package dk.cachet.carp.common.test.infrastructure

import dk.cachet.carp.common.application.services.ApplicationService
import dk.cachet.carp.common.infrastructure.services.ApplicationServiceLoggingProxy
import dk.cachet.carp.common.infrastructure.services.ApplicationServiceRequest
import dk.cachet.carp.common.infrastructure.services.*
import dk.cachet.carp.common.infrastructure.test.createTestJSON
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*
import kotlin.test.*


/**
* Base class to test whether application service request objects can be serialized,
* and whether they correctly call the application service on invoke.
* Base class to test whether application service request objects can be serialized.
*/
@ExperimentalCoroutinesApi
@Suppress( "FunctionName" )
abstract class ApplicationServiceRequestsTest<
TService : ApplicationService<TService, *>,
TRequest : ApplicationServiceRequest<TService, *>
>(
val decoratedServiceConstructor: (TService, (Command<TRequest>) -> Command<TRequest>) -> TService,
private val requestSerializer: KSerializer<TRequest>,
private val requests: List<TRequest>
val requests: List<TRequest>
)
{
abstract fun createServiceLoggingProxy(): ApplicationServiceLoggingProxy<TService, *>
abstract fun createService(): TService


@ExperimentalSerializationApi
Expand All @@ -42,20 +40,6 @@ abstract class ApplicationServiceRequestsTest<
assertEquals( allRequestObjects, testedRequestObjects )
}

@Suppress( "UNCHECKED_CAST" )
@Test
fun invokeOn_requests_call_service() = runTest {
val serviceLog = createServiceLoggingProxy()

requests.forEach { request ->
try { request.invokeOn( serviceLog as TService ) }
catch ( ignore: Exception ) { } // Requests do not have to succeed to verify request arrived.
assertTrue( serviceLog.wasCalled( request ) )

serviceLog.clear()
}
}

@Test
fun can_serialize_and_deserialize_requests()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ abstract class BackwardsCompatibilityTest<TService : ApplicationService<TService
private suspend fun replayLoggedRequests( fileName: String, loggedRequests: List<LoggedJsonRequest> )
{
val (service, eventBus) = createService()
val apiMigrator = serviceInfo.apiMigrator as ApplicationServiceApiMigrator<TService>
val apiMigrator = serviceInfo.apiMigrator as ApplicationServiceApiMigrator<TService, *>

loggedRequests.forEachIndexed { index, logged ->
val replayErrorBase = "Couldn't replay requests in: $fileName. Request #${index + 1}"
Expand Down
Loading

0 comments on commit 6c99354

Please sign in to comment.