Kotlin multiplatform deep-link definition, parsing and creation library.
Features:
- Create sealed deep-link hierarchy to exactly define each link once for server and client
- Build, parse and route in a type-safe way
- Code completion on all platforms
- Auto-generating documentation always in-line with the implementation
API documentation is available at Github Pages folder.
- Motivation
- Proposed deep-link management flow
- Link structure
- Link definition
- Building your links
- Consuming deep-links
- Create deep-links
- Some handy parsers included
- Conclusion
- As a business user I want an application to perform a certain action (open a page, update some data) by clicking a hyper-text link on a web page or in a message.
- As a business user I want to be able to push a link to a client device. For example in a marketing campaign.
- As a business user I want to share a link through third-party sources (email, social network, etc).
- As a marketing campaign operator I want app links to be well-documented across all platforms when building messages for clients.
- As a backend programmer I want to push actions to do to clients in a platform-independent way.
- As a backend programmer I want to build supported deep-links as fast as possible without searching through documentation too much. I want some help from programming tools (IDE) to build those links.
- As a client-side programmer I want a link to be parsed on a client-side with parameters checked.
- As a client-side programmer I want to know things to do when push-message arrives. A deep-link to action when parsed will give me all needed data.
- As a QA engineer I want deep-links to be documented to check if application fulfills the action it is intended to.
- As an end-user I want to share a link to some page result, especially in a web-app
- Business evaluates the new feature and decides that it's good to have a deep-link to it.
- The web and mobile teams implement the feature
- The web team reports a link on the product site to become a deep-link to be opened in application
- The deep-link supporting team implements the new
Action
definition in the link registry. - The new version of the deep-link library is distributed among teams.
- Mobile teams adjust their routers to support the link.
- Backend programmers build links with the library in backend to frontend data export.
- The new generated documentation is supplied to marketing team to plan advertising campaigns.
Application deep-links are URIs:
URI = scheme:[host]path[?search][#hash]
Each component means:
scheme
- application scheme by which a correct application is selected to handle a link on a device. It may be a custom scheme likemotorro://
or a commonhttp(s)://
(platform specific filtering logic is required in this case).host
- pre-defined host in case of linking in ahttp(s)://
scheme that defines app filteringpath
- where to go. Represents a path to activity in an application: screen, action, etc. Parameters exactly identifying the activity may be represented as path. For example, the following link opens a chat with ID123
:https://motorro.com/chats/123
search
- what to do. Represents parameters to activity specified bypath
which instruct the activity what to do. For example, the following link opens search activity and instructs it to search with specific parameters:https://motorro.com/search?type=TRAIN&from=MOSCOW&to=PARIS&date=2021-01-28
hash
- what to display. Represents extra action to do in activity. For example, you may instruct your document activity to scroll to specified anchor:
https://motorro.com/open/settings#background_location
The library is intended to help you to build a single source-of-truth for action definition and data. It defines a sealed hierarchy of deep-link actions used across all of your servers and client applications.
You could use path
, search
and hash
parts of URI to build an Action
- what needs to be done in a client
application when deep-link is processed.
To distinguish between applications an (or) domains use scheme
and host
components of URI. Check-out some sample
LinkBuilder/LinkParser`
to learn how to define your deep-link scheme.
Hint: Refer to testaction module for an example.
Create a new Kotlin-multiplatform project and add the library dependency to your build file:
sourceSets {
val commonMain by getting {
dependencies {
implementation("com.motorro.keeplink:deeplink:x.x.x")
implementation(libs.kotlin.serialization.core) // If you want to serialize with Kotlin
}
}
}
Define your required build targets and outputs (see the sample for reference):
iOS framework:
val iosArm64 = iosArm64("iosArm64")
val iosX64 = iosX64("iosX64")
configure(listOf(iosArm64, iosX64)) {
binaries {
framework(listOf(RELEASE))
}
}
val outputIos by tasks.creating(org.jetbrains.kotlin.gradle.tasks.FatFrameworkTask::class) {
group = "output"
destinationDir = file("$projectDir/output/ios/Fat")
from(
iosArm64.binaries.getFramework("RELEASE"),
iosX64.binaries.getFramework("RELEASE")
)
}
NPM module for Node.js
or a browser:
js(IR) {
moduleName = "yourmodulename"
compilations.all {
kotlinOptions.freeCompilerArgs += listOf(
"-opt-in=kotlin.js.ExperimentalJsExport"
)
}
generateTypeScriptDefinitions()
binaries.library()
useCommonJs()
nodejs {
testTask {
useMocha {
timeout = "10s"
}
}
// On customizing JS builds and distribution:
// https://kotlinlang.org/docs/reference/js-project-setup.html#choosing-execution-environment
@OptIn(ExperimentalDistributionDsl::class)
distribution {
outputDirectory.set(file("$projectDir/output/npm"))
}
}
}
Add a Dokka task to your project to build the documentation. Then you could ship int to your fellow developers and marketing team to keep a link registry actual:
subprojects {
tasks {
withType<DokkaTask>().configureEach {
dokkaSourceSets.configureEach {
includes.from("moduledoc.md")
}
}
withType<DokkaTaskPartial>().configureEach {
dokkaSourceSets.configureEach {
includes.from("moduledoc.md")
}
}
}
}
To build your link structure inherit the Action:
@JsExport
@Serializable
@OptIn(ExperimentalJsExport::class)
sealed class TestAction : Action() {
/**
* Login actions
*
* `/login`
*
*/
sealed class Login : TestAction() {
internal companion object {
const val SEGMENT = "login"
}
/**
* Path component
*/
override fun getPath(): Array<String> = super.getPath() + SEGMENT
/**
* Magic-link login action
*
* `/login/magic/{token}`
*
* @property token Login token
*/
class Magic(val token: String) : Login() {
internal companion object {
const val SEGMENT = "magic"
}
override fun getPath(): Array<String> = super.getPath() + SEGMENT + token
}
}
}
You define the structure in any way that best works for you best. The action implements the PSHComponents
which defines the path
, search
, and hash
components for your resulting URI when building.
Refer to TestAction definition to know more
To use some handy library tools, create your parsers by implementing an ActionParser:
/**
* Parses action from action components
*/
fun interface ActionParser<out A : Action> {
/**
* Tries to parse given path
* @param components Source action components
* @param pathIndex Path index to start parsing at
*/
fun parse(components: PshComponents, pathIndex: Int): A?
}
The parser accepts two parameters:
components
- parsed URIpath
,search
, andhash
components.pathIndex
- current path segment index being processed. Optionally used by utility parsers (see below)
The parser returns a parsed action or a null
if parse fails or irrelevant.
For example:
/**
* Parser for [TestAction.Login.Magic] token in
* `/login/magic/{token}`
* Validates token is not empty
*/
internal val MagicLinkHashParser = ActionParser { components, pathIndex ->
components.getPath().getOrNull(pathIndex)?.takeIf { it.isNotBlank() }?.let { TestAction.Login.Magic(it) }
}
To be completely type-safe create some predefined scheme/host builders and parsers that will build the URI string for you given a deep-link object. To do so use LinkBuilder and LinkParser utilities:
/**
* Test link parsers (JS-compatible)
*/
@JsExport
@OptIn(ExperimentalJsExport::class)
object LinkParsers {
/**
* Deep-link for URIs with `motorro` scheme:
*
* `motorro:/profile/chats/123`
*/
val MOTORRO: LinkParser<TestAction> = SchemeHostLinkParser(RootActionParser, "motorro", "")
}
/**
* Test link builders (JS-compatible)
*/
@JsExport
@OptIn(ExperimentalJsExport::class)
object LinkBuilders {
/**
* Deep-link for URIs with `motorro` scheme:
*
* `motorro:/profile/chats/123`
*/
val MOTORRO: LinkBuilder<TestAction> = SchemeHostLinkBuilder("motorro", "")
}
That's basically it. Now build your project and assemble the output artifacts.
Add your artifacts to the target project. Use a pre-defined parser to parse your deep-link:
val parser = LinkParsers.MOTORRO
val link = parser.parse("motorro:/login/magic/123")
// Optionally obtain some Urchin data
val utm = link?.utm
if (null != utm) {
// Work out some analytics event
}
// Process link type-safe
when(link.action) {
is TestLink.Login.Magic -> {
// Go to login screen
}
else -> {
//No link or parse failed. Start as usual
}
}
Imagine you have a Node
backend that provides data to your UI. Let's create a link for them:
// Select the builder scheme
const builder = LinkBuilders.MOTORRO;
// Create an action
const action = new TestAction.Login.Magic("123");
// Create a deep-link, possibly adding some Urchin
const link = deepLink(action).withUtm(utm("test"));
// Build the URI string
const linkStr = builder.build(link);
Check-out the complete Node
example and tests
to learn more.
The library comes with some handy parsers if you like. Although you are not required to use them, they could potentially
speed up your parsing and build parsers in more or less declarative way. See the complete parser setup
in the testaction
project.
Checks that current segment being parsed matches your string and calls the next parser. Used to traverse your path:
/**
* Parser for [TestAction.Login.Magic]
*/
internal val MagicLinkParser = SegmentCheckParser(
TestAction.Login.Magic.SEGMENT,
BranchActionParser(listOf(MagicLinkHashParser)) { _, _ -> TestAction.Invalid.INSTANCE }
)
The parser checks for correct segment (that is /login/magic/
) and passes control to the token parser.
This one is simple. It just returns the action you produce in action
parameter. May be used to return the action to
some intermediate segment if all it's children do not match (see BranchActionParser
below):
/**
* Profile root parser
*/
internal val ProfileParser = SegmentCheckParser(
TestAction.Profile.SEGMENT,
DefaultActionParser { TestAction.Profile() }
)
Iterates its children to find the first that returns a non-null result. If none answers positive - runs the default
fallback:
/**
* Root parsers - all known branches from root node
*/
private val rootParsers = listOf(
ProfileParser,
LoginParser,
SearchParser
)
/**
* Root parser for [TestAction]
* Returns:
* - known action if definitely known
* - Unknown action if component are not empty
* - Root action on empty components
*/
val RootActionParser = BranchActionParser(rootParsers) { components, _ ->
if (components.getPath().isEmpty()) TestAction.Root() else TestAction.Unknown(components)
}
I hope someone finds the given approach (and the library) to deep-link management handy. As for me, it gives a more or less complete solution to both the world of developers and the management. The proposed way aims to be a single source of truth for deep-links in your project providing a write-once solution for both the coding and the documenting tasks. The approach keeps you from implementation errors and also saves your time when building and processing the links.