implement realtime feature

This commit is contained in:
Torsten Dittmann
2021-09-02 13:08:08 +02:00
parent ea068227d1
commit bc49e400a7
22 changed files with 535 additions and 47 deletions
+2 -1
View File
@@ -50,4 +50,5 @@ jobs:
SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
SIGNING_SECRET_KEY_RING_FILE: ${{ secrets.SIGNING_SECRET_KEY_RING_FILE }}
SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }}
SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }}
SDK_VERSION: ${{ github.event.release.tag_name }}
+5 -5
View File
@@ -2,12 +2,12 @@
![Maven Central](https://img.shields.io/maven-central/v/io.appwrite/sdk-for-android.svg?color=green&style=flat-square)
![License](https://img.shields.io/github/license/appwrite/sdk-for-android.svg?style=flat-square)
![Version](https://img.shields.io/badge/api%20version-0.9.0-blue.svg?style=flat-square)
![Version](https://img.shields.io/badge/api%20version-0.10.0-blue.svg?style=flat-square)
[![Build Status](https://img.shields.io/travis/com/appwrite/sdk-generator?style=flat-square)](https://travis-ci.com/appwrite/sdk-generator)
[![Twitter Account](https://img.shields.io/twitter/follow/appwrite_io?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite_io)
[![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord)
**This SDK is compatible with Appwrite server version 0.9.x. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-android/releases).**
**This SDK is compatible with Appwrite server version 0.10.x. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-android/releases).**
Appwrite is an open-source backend as a service server that abstract and simplify complex and repetitive development tasks behind a very simple to use REST API. Appwrite aims to help you develop your apps faster and in a more secure way. Use the Android SDK to integrate your app with the Appwrite server to easily start interacting with all of Appwrite backend APIs and tools. For full API documentation and tutorials go to [https://appwrite.io/docs](https://appwrite.io/docs)
@@ -38,7 +38,7 @@ repositories {
Next, add the dependency to your project's `build.gradle(.kts)` file:
```groovy
implementation("io.appwrite:sdk-for-android:0.0.1")
implementation("io.appwrite:sdk-for-android:0.1.0")
```
### Maven
@@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file:
<dependency>
<groupId>io.appwrite</groupId>
<artifactId>sdk-for-android</artifactId>
<version>0.0.1</version>
<version>0.1.0</version>
</dependency>
</dependencies>
```
@@ -144,7 +144,7 @@ try {
```
### Learn more
You can use following resources to learn more and get help
You can use the following resources to learn more and get help
- 🚀 [Getting Started Tutorial](https://appwrite.io/docs/getting-started-for-android)
- 📜 [Appwrite Docs](https://appwrite.io/docs)
- 💬 [Discord Community](https://appwrite.io/discord)
+1 -1
View File
@@ -3,7 +3,7 @@ apply plugin: 'io.github.gradle-nexus.publish-plugin'
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.4.31"
version '0.0.1'
version '0.1.0'
repositories {
maven { url "https://plugins.gradle.org/m2/" }
google()
@@ -0,0 +1,48 @@
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import io.appwrite.Client
import io.appwrite.services.Account
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Client client = new Client(getApplicationContext())
.setEndpoint("https://[HOSTNAME_OR_IP]/v1") // Your API Endpoint
.setProject("5df5acd0d48c2"); // Your project ID
Account account = new Account(client);
account.createMagicURLSession(
"email@example.com",
new Continuation<Object>() {
@NotNull
@Override
public CoroutineContext getContext() {
return EmptyCoroutineContext.INSTANCE;
}
@Override
public void resumeWith(@NotNull Object o) {
String json = "";
try {
if (o instanceof Result.Failure) {
Result.Failure failure = (Result.Failure) o;
throw failure.exception;
} else {
Response response = (Response) o;
json = response.body().string();
}
} catch (Throwable th) {
Log.e("ERROR", th.toString());
}
}
}
);
}
}
@@ -0,0 +1,49 @@
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import io.appwrite.Client
import io.appwrite.services.Account
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Client client = new Client(getApplicationContext())
.setEndpoint("https://[HOSTNAME_OR_IP]/v1") // Your API Endpoint
.setProject("5df5acd0d48c2"); // Your project ID
Account account = new Account(client);
account.updateMagicURLSession(
"[USER_ID]",
"[SECRET]"
new Continuation<Object>() {
@NotNull
@Override
public CoroutineContext getContext() {
return EmptyCoroutineContext.INSTANCE;
}
@Override
public void resumeWith(@NotNull Object o) {
String json = "";
try {
if (o instanceof Result.Failure) {
Result.Failure failure = (Result.Failure) o;
throw failure.exception;
} else {
Response response = (Response) o;
json = response.body().string();
}
} catch (Throwable th) {
Log.e("ERROR", th.toString());
}
}
}
);
}
}
@@ -0,0 +1,26 @@
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import io.appwrite.Client
import io.appwrite.services.Account
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val client = Client(applicationContext)
.setEndpoint("https://[HOSTNAME_OR_IP]/v1") // Your API Endpoint
.setProject("5df5acd0d48c2") // Your project ID
val account = Account(client)
GlobalScope.launch {
val response = account.createMagicURLSession(
email = "email@example.com",
)
val json = response.body?.string()
}
}
}
@@ -0,0 +1,27 @@
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import io.appwrite.Client
import io.appwrite.services.Account
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val client = Client(applicationContext)
.setEndpoint("https://[HOSTNAME_OR_IP]/v1") // Your API Endpoint
.setProject("5df5acd0d48c2") // Your project ID
val account = Account(client)
GlobalScope.launch {
val response = account.updateMagicURLSession(
userId = "[USER_ID]",
secret = "[SECRET]"
)
val json = response.body?.string()
}
}
}
+9 -8
View File
@@ -7,7 +7,7 @@ plugins {
ext {
PUBLISH_GROUP_ID = 'io.appwrite'
PUBLISH_ARTIFACT_ID = 'sdk-for-android'
PUBLISH_VERSION = '0.0.1'
PUBLISH_VERSION = System.getenv('SDK_VERSION')
POM_URL = 'https://github.com/appwrite/sdk-for-android'
POM_SCM_URL = 'https://github.com/appwrite/sdk-for-android'
POM_ISSUE_URL = 'https://github.com/appwrite/sdk-for-android/issues'
@@ -53,8 +53,8 @@ android {
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${KotlinCompilerVersion.VERSION}")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3")
api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1")
api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")
api(platform("com.squareup.okhttp3:okhttp-bom:4.9.0"))
api("com.squareup.okhttp3:okhttp")
@@ -65,15 +65,16 @@ dependencies {
implementation("net.gotev:cookie-store:1.3.5")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1")
implementation("androidx.lifecycle:lifecycle-common-java8:2.3.1")
implementation("androidx.appcompat:appcompat:1.2.0")
implementation("androidx.fragment:fragment-ktx:1.3.2")
implementation("androidx.activity:activity-ktx:1.2.2")
implementation("androidx.appcompat:appcompat:1.3.1")
implementation("androidx.fragment:fragment-ktx:1.3.6")
implementation("androidx.activity:activity-ktx:1.3.1")
implementation("androidx.browser:browser:1.3.0")
testImplementation 'junit:junit:4.+'
testImplementation "androidx.test.ext:junit-ktx:1.1.2"
testImplementation "androidx.test:core-ktx:1.3.0"
testImplementation "androidx.test.ext:junit-ktx:1.1.3"
testImplementation "androidx.test:core-ktx:1.4.0"
testImplementation "org.robolectric:robolectric:4.5.1"
testApi("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1")
}
apply from: "${rootProject.projectDir}/scripts/publish-module.gradle"
+23 -6
View File
@@ -5,8 +5,7 @@ import android.content.pm.PackageManager
import com.google.gson.Gson
import io.appwrite.appwrite.BuildConfig
import io.appwrite.exceptions.AppwriteException
import io.appwrite.extensions.JsonExtensions.fromJson
import io.appwrite.models.Error
import io.appwrite.extensions.fromJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -36,6 +35,7 @@ import kotlin.coroutines.resume
class Client @JvmOverloads constructor(
context: Context,
var endPoint: String = "https://appwrite.io/v1",
var endPointRealtime: String? = null,
private var selfSigned: Boolean = false
) : CoroutineScope {
@@ -71,7 +71,7 @@ class Client @JvmOverloads constructor(
"origin" to "appwrite-android://${context.packageName}",
"user-agent" to "${context.packageName}/${appVersion}, ${System.getProperty("http.agent")}",
"x-sdk-version" to "appwrite:android:${BuildConfig.SDK_VERSION}",
"x-appwrite-response-format" to "0.9.0"
"x-appwrite-response-format" to "0.10.0"
)
config = mutableMapOf()
@@ -171,7 +171,7 @@ class Client @JvmOverloads constructor(
}
/**
* Set endpoint
* Set endpoint and realtime endpoint.
*
* @param endpoint
*
@@ -179,6 +179,23 @@ class Client @JvmOverloads constructor(
*/
fun setEndpoint(endPoint: String): Client {
this.endPoint = endPoint
if (this.endPointRealtime == null && endPoint.startsWith("http")) {
this.endPointRealtime = endPoint.replaceFirst("http", "ws")
}
return this
}
/**
* Set realtime endpoint
*
* @param endpoint
*
* @return this
*/
fun setEndpointRealtime(endPoint: String): Client {
this.endPointRealtime = endPoint
return this
}
@@ -317,9 +334,9 @@ class Client @JvmOverloads constructor(
val contentType: String = response.headers["content-type"] ?: ""
val error = if (contentType.contains("application/json", ignoreCase = true)) {
bodyString.fromJson(Error::class.java)
bodyString.fromJson<AppwriteException>()
} else {
Error(bodyString, response.code)
AppwriteException(bodyString, response.code)
}
it.cancel(AppwriteException(
@@ -0,0 +1,12 @@
package io.appwrite.extensions
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
suspend fun <T> Collection<T>.forEachAsync(
callback: suspend (T) -> Unit
) = withContext(IO) {
map { async { callback.invoke(it) } }.awaitAll()
}
@@ -2,11 +2,33 @@ package io.appwrite.extensions
import com.google.gson.Gson
object JsonExtensions {
val gson = Gson()
fun Any.toJson(): String =
Gson().toJson(this)
fun Any.toJson(): String =
gson.toJson(this)
fun <T> String.fromJson(clazz: Class<T>): T =
Gson().fromJson(this, clazz)
}
fun <T> String.fromJson(clazz: Class<T>): T =
gson.fromJson(this, clazz)
inline fun <reified T> String.fromJson(): T =
gson.fromJson(this, T::class.java)
fun <T> Any.jsonCast(to: Class<T>): T =
toJson().fromJson(to)
inline fun <reified T> Any.jsonCast(): T =
toJson().fromJson(T::class.java)
fun <T> Any.tryJsonCast(to: Class<T>): T? = try {
toJson().fromJson(to)
} catch (ex: Exception) {
ex.printStackTrace()
null
}
inline fun <reified T> Any.tryJsonCast(): T? = try {
toJson().fromJson(T::class.java)
} catch (ex: Exception) {
ex.printStackTrace()
null
}
@@ -1,6 +0,0 @@
package io.appwrite.models
data class Error(
val message: String,
val code: Int
)
@@ -0,0 +1,31 @@
package io.appwrite.models
import java.io.Closeable
data class RealtimeSubscription(
private val close: () -> Unit
) : Closeable {
override fun close() = close.invoke()
}
data class RealtimeCallback(
val payloadClass: Class<*>,
val callback: (RealtimeResponseEvent<*>) -> Unit
)
open class RealtimeResponse(
val type: String,
val data: Any
)
data class RealtimeResponseEvent<T>(
val event: String,
val channels: Collection<String>,
val timestamp: Long,
var payload: T
)
enum class RealtimeCode(val value: Int) {
POLICY_VIOLATION(1008),
UNKNOWN_ERROR(-1)
}
@@ -11,7 +11,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.File
class Account(private val client: Client) : BaseService(client) {
class Account(client: Client) : Service(client) {
/**
* Get Account
@@ -313,7 +313,7 @@ class Account(private val client: Client) : BaseService(client) {
}
/**
* Complete Password Recovery
* Create Password Recovery (confirmation)
*
* Use this endpoint to complete the user account password reset. Both the
* **userId** and **secret** arguments will be passed as query parameters to
@@ -453,6 +453,81 @@ class Account(private val client: Client) : BaseService(client) {
return client.call("POST", path, headers, params)
}
/**
* Create Magic URL session
*
* Sends the user an email with a secret key for creating a session. When the
* user clicks the link in the email, the user is redirected back to the URL
* you provided with the secret key and userId values attached to the URL
* query string. Use the query string parameters to submit a request to the
* [PUT
* /account/sessions/magic-url](/docs/client/account#accountUpdateMagicURLSession)
* endpoint to complete the login process. The link sent to the user's email
* address is valid for 1 hour. If you are on a mobile device you can leave
* the URL parameter empty, so that the login completion will be handled by
* your Appwrite instance by default.
*
* @param email
* @param url
* @return [Response]
*/
@JvmOverloads
@Throws(AppwriteException::class)
suspend fun createMagicURLSession(
email: String,
url: String? = null
): Response {
val path = "/account/sessions/magic-url"
val params = mapOf<String, Any?>(
"email" to email,
"url" to url
)
val headers = mapOf(
"content-type" to "application/json"
)
return client.call("POST", path, headers, params)
}
/**
* Create Magic URL session (confirmation)
*
* Use this endpoint to complete creating the session with the Magic URL. Both
* the **userId** and **secret** arguments will be passed as query parameters
* to the redirect URL you have provided when sending your request to the
* [POST
* /account/sessions/magic-url](/docs/client/account#accountCreateMagicURLSession)
* endpoint.
*
* Please note that in order to avoid a [Redirect
* Attack](https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.md)
* the only valid redirect URLs are the ones from domains you have set when
* adding your platforms in the console interface.
*
* @param userId
* @param secret
* @return [Response]
*/
@JvmOverloads
@Throws(AppwriteException::class)
suspend fun updateMagicURLSession(
userId: String,
secret: String
): Response {
val path = "/account/sessions/magic-url"
val params = mapOf<String, Any?>(
"userId" to userId,
"secret" to secret
)
val headers = mapOf(
"content-type" to "application/json"
)
return client.call("PUT", path, headers, params)
}
/**
* Create Account Session with OAuth2
*
@@ -460,6 +535,14 @@ class Account(private val client: Client) : BaseService(client) {
* choice. Each OAuth2 provider should be enabled from the Appwrite console
* first. Use the success and failure arguments to provide a redirect URL's
* back to your app when login is completed.
*
* If there is already an active session, the new session will be attached to
* the logged-in account. If there are no active sessions, the server will
* attempt to look for a user with the same email address as the email
* received from the OAuth2 provider and attach the new session to the
* existing user. If no matching user is found - the server will create a new
* user..
*
*
* @param provider
* @param success
@@ -620,7 +703,7 @@ class Account(private val client: Client) : BaseService(client) {
}
/**
* Complete Email Verification
* Create Email Verification (confirmation)
*
* Use this endpoint to complete the user email verification process. Use both
* the **userId** and **secret** parameters that were attached to your app URL
@@ -9,7 +9,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.File
class Avatars(private val client: Client) : BaseService(client) {
class Avatars(client: Client) : Service(client) {
/**
* Get Browser Icon
@@ -7,7 +7,7 @@ import okhttp3.Cookie
import okhttp3.Response
import java.io.File
class Database(private val client: Client) : BaseService(client) {
class Database(client: Client) : Service(client) {
/**
* List Documents
@@ -7,7 +7,7 @@ import okhttp3.Cookie
import okhttp3.Response
import java.io.File
class Functions(private val client: Client) : BaseService(client) {
class Functions(client: Client) : Service(client) {
/**
* List Executions
@@ -7,7 +7,7 @@ import okhttp3.Cookie
import okhttp3.Response
import java.io.File
class Locale(private val client: Client) : BaseService(client) {
class Locale(client: Client) : Service(client) {
/**
* Get User Locale
@@ -0,0 +1,174 @@
package io.appwrite.services
import io.appwrite.Client
import io.appwrite.exceptions.AppwriteException
import io.appwrite.extensions.forEachAsync
import io.appwrite.extensions.fromJson
import io.appwrite.extensions.jsonCast
import io.appwrite.models.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.internal.concurrent.TaskRunner
import okhttp3.internal.ws.RealWebSocket
import java.util.*
import kotlin.coroutines.CoroutineContext
class Realtime(client: Client) : Service(client), CoroutineScope {
private val job = Job()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private companion object {
private const val TYPE_ERROR = "error"
private const val TYPE_EVENT = "event"
private const val DEBOUNCE_MILLIS = 1L
private var socket: RealWebSocket? = null
private var channelCallbacks = mutableMapOf<String, MutableCollection<RealtimeCallback>>()
private var errorCallbacks = mutableSetOf<(AppwriteException) -> Unit>()
private var subCallDepth = 0
}
private fun createSocket() {
val queryParamBuilder = StringBuilder()
.append("project=${client.config["project"]}")
channelCallbacks.keys.forEach {
queryParamBuilder
.append("&channels[]=$it")
}
val request = Request.Builder()
.url("${client.endPointRealtime}/realtime?$queryParamBuilder")
.build()
if (socket != null) {
closeSocket()
}
socket = RealWebSocket(
taskRunner = TaskRunner.INSTANCE,
originalRequest = request,
listener = AppwriteWebSocketListener(),
random = Random(),
pingIntervalMillis = client.http.pingIntervalMillis.toLong(),
extensions = null,
minimumDeflateSize = client.http.minWebSocketMessageToCompress
)
socket!!.connect(client.http)
}
private fun closeSocket() {
socket?.close(RealtimeCode.POLICY_VIOLATION.value, null)
}
fun subscribe(
vararg channels: String,
callback: (RealtimeResponseEvent<Any>) -> Unit,
) = subscribe(
channels = channels,
Any::class.java,
callback
)
fun <T> subscribe(
vararg channels: String,
payloadType: Class<T>,
callback: (RealtimeResponseEvent<T>) -> Unit,
): RealtimeSubscription {
channels.forEach {
if (!channelCallbacks.containsKey(it)) {
channelCallbacks[it] = mutableListOf(
RealtimeCallback(
payloadType,
callback as (RealtimeResponseEvent<*>) -> Unit
)
)
return@forEach
}
channelCallbacks[it]?.add(
RealtimeCallback(payloadType, callback as (RealtimeResponseEvent<*>) -> Unit)
)
}
launch {
subCallDepth++
delay(DEBOUNCE_MILLIS)
if (subCallDepth == 1) {
createSocket()
}
subCallDepth--
}
return RealtimeSubscription { unsubscribe(*channels) }
}
fun unsubscribe(vararg channels: String) {
channels.forEach {
channelCallbacks[it] = mutableListOf()
}
if (channelCallbacks.all { it.value.isEmpty() }) {
errorCallbacks = mutableSetOf()
closeSocket()
}
}
fun doOnError(callback: (AppwriteException) -> Unit) {
errorCallbacks.add(callback)
}
private inner class AppwriteWebSocketListener : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
super.onMessage(webSocket, text)
launch(IO) {
val message = text.fromJson<RealtimeResponse>()
when (message.type) {
TYPE_ERROR -> handleResponseError(message)
TYPE_EVENT -> handleResponseEvent(message)
}
}
}
private fun handleResponseError(message: RealtimeResponse) {
val error = message.data.jsonCast<AppwriteException>()
errorCallbacks.forEach { it.invoke(error) }
}
private suspend fun handleResponseEvent(message: RealtimeResponse) {
val event = message.data.jsonCast<RealtimeResponseEvent<Any>>()
event.channels.forEachAsync { channel ->
channelCallbacks[channel]?.forEachAsync { callbackWrapper ->
event.payload = event.payload.jsonCast(callbackWrapper.payloadClass)
callbackWrapper.callback.invoke(event)
}
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
super.onClosing(webSocket, code, reason)
if (code == RealtimeCode.POLICY_VIOLATION.value) {
return
}
launch {
delay(1000)
createSocket()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
super.onFailure(webSocket, t, response)
t.printStackTrace()
}
}
}
@@ -2,4 +2,4 @@ package io.appwrite.services
import io.appwrite.Client
abstract class BaseService(private val client: Client)
abstract class Service(val client: Client)
@@ -9,7 +9,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.File
class Storage(private val client: Client) : BaseService(client) {
class Storage(client: Client) : Service(client) {
/**
* List Files
@@ -7,7 +7,7 @@ import okhttp3.Cookie
import okhttp3.Response
import java.io.File
class Teams(private val client: Client) : BaseService(client) {
class Teams(client: Client) : Service(client) {
/**
* List Teams
@@ -195,14 +195,17 @@ class Teams(private val client: Client) : BaseService(client) {
/**
* Create Team Membership
*
* Use this endpoint to invite a new member to join your team. An email with a
* link to join the team will be sent to the new member email address if the
* member doesn't exist in the project it will be created automatically.
* Use this endpoint to invite a new member to join your team. If initiated
* from Client SDK, an email with a link to join the team will be sent to the
* new member's email address if the member doesn't exist in the project it
* will be created automatically. If initiated from server side SDKs, new
* member will automatically be added to the team.
*
* Use the 'URL' parameter to redirect the user from the invitation email back
* to your app. When the user is redirected, use the [Update Team Membership
* Status](/docs/client/teams#teamsUpdateMembershipStatus) endpoint to allow
* the user to accept the invitation to the team.
* the user to accept the invitation to the team. While calling from side
* SDKs the redirect url can be empty string.
*
* Please note that in order to avoid a [Redirect
* Attacks](https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.md)