Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ISSUE-1289 Mobile APP app grid #1347

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
= Ecosystem Discovery
:navtitle: Ecosystem Discovery


Allows clients to discover Linagora ecosystem services.
An example case for the Mobile APP grid in the application.

== How to use it

Clients can access the ecosystem discovery endpoint using the following URL:

```
http://${jmapBaseServerUrl}/.well-known/linagora-ecosystem
```

The JSON object response sample:
```json
{
"linShareApiUrl": "https://linshare.linagora.com/linshare/webservice",
"linToApiUrl": "https://linto.ai/demo",
"linToApiKey": "apiKey",
"twakeApiUrl": "https://api.twake.app",
"mobileApps": {
"Twake Chat": {
"logoURL": "https://twake-chat.xyz/tild3837-6430-4761-b135-303536323633__twake-chat_1.svg",
"appId": "twake-chat"
},
"Twake Drive": {
"logoURL": "https://twake-drive.xyz/tild6334-3137-4635-b331-636632306164__drive_1.svg",
"webLink": "https://tdrive.linagora.com"
}
}
}
```

== How to configure the response JSON

Provide the key-value in the `linagora-ecosystem.properties` file in the configure path.
Use `.` for nested keys, representing hierarchical configuration.
Example: `service.database.url` translates to a JSON structure:
```json
{
"service": {
"database": {
"url": "..."
}
}
}
```
Use `_` as a substitute for spaces in keys for better readability.
Example: `key_with_space` can be interpreted as `key with space` in json key.
When `app.key_with_space.apikey=1234` is set in the properties file, it will be translated to:
```json
{
"app": {
"key with space": {
"apikey": "1234"
}
}
}
```
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/tmail-backend/configure/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ This includes:
- link:rabbitmq.adoc[Additional RabbitMQ configuration]
- xref:tmail-backend/imap-extensions/imapAuthDelegationExtension.adoc[IMAP extensions]
- xref:tmail-backend/smtp-extensions/smtpAuthDelegationExtension.adoc[SMTP extensions]
- link:ecosystem-discovery.adoc[Linagora Ecosystem discovery]
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@
linShareApiUrl=https://linshare.linagora.com/linshare/webservice
linToApiUrl=https://linto.ai/demo
linToApiKey=apiKey
twakeApiUrl=https://api.twake.app
twakeApiUrl=https://api.twake.app

# mobileApps.Twake_Chat.logoURL=https://twake-chat.xyz/tild3837-6430-4761-b135-303536323633__twake-chat_1.svg
# mobileApps.Twake_Chat.appId=twake-chat
# mobileApps.Twake_Drive.logoURL=https://twake-drive.xyz/tild6334-3137-4635-b331-636632306164__drive_1.svg
# mobileApps.Twake_Drive.webLink=https://tdrive.linagora.com
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@
linShareApiUrl=https://linshare.linagora.com/linshare/webservice
linToApiUrl=https://linto.ai/demo
linToApiKey=apiKey
twakeApiUrl=https://api.twake.app
twakeApiUrl=https://api.twake.app

# mobileApps.Twake_Chat.logoURL=https://twake-chat.xyz/tild3837-6430-4761-b135-303536323633__twake-chat_1.svg
# mobileApps.Twake_Chat.appId=twake-chat
# mobileApps.Twake_Drive.logoURL=https://twake-drive.xyz/tild6334-3137-4635-b331-636632306164__drive_1.svg
# mobileApps.Twake_Drive.webLink=https://tdrive.linagora.com
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,20 @@ trait LinagoraServicesDiscoveryRoutesContract {
assertThatJson(response)
.isEqualTo(
s"""{
| "linShareApiUrl": "https://linshare.linagora.com/linshare/webservice",
| "linToApiUrl": "https://linto.ai/demo",
| "linToApiKey": "apiKey",
| "twakeApiUrl": "https://api.twake.app"
| "mobileApps": {
| "Twake Chat": {
| "logoURL": "https://twake-chat.xyz/tild3837-6430-4761-b135-303536323633__twake-chat_1.svg",
| "appId": "twake-chat"
| },
| "Twake Drive": {
| "logoURL": "https://twake-drive.xyz/tild6334-3137-4635-b331-636632306164__drive_1.svg",
| "webLink": "https://tdrive.linagora.com"
| }
| },
| "linToApiUrl": "https://linto.ai/demo",
| "twakeApiUrl": "https://api.twake.app",
| "linToApiKey": "apiKey",
| "linShareApiUrl": "https://linshare.linagora.com/linshare/webservice"
|}""".stripMargin)
}

Expand All @@ -68,10 +78,20 @@ trait LinagoraServicesDiscoveryRoutesContract {
assertThatJson(response)
.isEqualTo(
s"""{
| "linShareApiUrl": "https://linshare.linagora.com/linshare/webservice",
| "linToApiUrl": "https://linto.ai/demo",
| "linToApiKey": "apiKey",
| "twakeApiUrl": "https://api.twake.app"
| "mobileApps": {
| "Twake Chat": {
| "logoURL": "https://twake-chat.xyz/tild3837-6430-4761-b135-303536323633__twake-chat_1.svg",
| "appId": "twake-chat"
| },
| "Twake Drive": {
| "logoURL": "https://twake-drive.xyz/tild6334-3137-4635-b331-636632306164__drive_1.svg",
| "webLink": "https://tdrive.linagora.com"
| }
| },
| "linToApiUrl": "https://linto.ai/demo",
| "twakeApiUrl": "https://api.twake.app",
| "linToApiKey": "apiKey",
| "linShareApiUrl": "https://linshare.linagora.com/linshare/webservice"
|}""".stripMargin)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
linShareApiUrl=https://linshare.linagora.com/linshare/webservice
linToApiUrl=https://linto.ai/demo
linToApiKey=apiKey
twakeApiUrl=https://api.twake.app
twakeApiUrl=https://api.twake.app
mobileApps.Twake_Chat.logoURL=https://twake-chat.xyz/tild3837-6430-4761-b135-303536323633__twake-chat_1.svg
mobileApps.Twake_Chat.appId=twake-chat
mobileApps.Twake_Drive.logoURL=https://twake-drive.xyz/tild6334-3137-4635-b331-636632306164__drive_1.svg
mobileApps.Twake_Drive.webLink=https://tdrive.linagora.com
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE
import io.netty.handler.codec.http.HttpResponseStatus._
import io.netty.handler.codec.http.{HttpMethod, HttpResponseStatus}
import jakarta.inject.{Inject, Named}
import org.apache.commons.lang3.StringUtils
import org.apache.james.jmap.HttpConstants.JSON_CONTENT_TYPE
import org.apache.james.jmap.core.ProblemDetails
import org.apache.james.jmap.exceptions.UnauthorizedException
Expand All @@ -18,20 +19,27 @@ import org.apache.james.jmap.json.ResponseSerializer
import org.apache.james.jmap.{Endpoint, JMAPRoute, JMAPRoutes}
import org.apache.james.utils.PropertiesProvider
import org.slf4j.{Logger, LoggerFactory}
import play.api.libs.json.{JsObject, JsString, Json, OWrites, Writes}
import play.api.libs.json.{JsObject, JsString, JsValue, Json}
import reactor.core.publisher.Mono
import reactor.netty.http.server.{HttpServerRequest, HttpServerResponse}

private[discovery] object Serializers {
private implicit val serviceItemsWrites: OWrites[List[LinagoraServicesDiscoveryItem]] =
(ids: List[LinagoraServicesDiscoveryItem]) => {
ids.foldLeft(JsObject.empty)((jsObject, item) => {
jsObject.+(item.key, JsString.apply(item.value))
})
}
private implicit val responseWrites: Writes[LinagoraServicesDiscoveryConfiguration] = Json.valueWrites[LinagoraServicesDiscoveryConfiguration]
private[jmap] object ServicesDiscoveryConfigurationSerializers {
private val NESTED_DELIMITER = "\\."
private val UNDERSCORE_DELIMITER = "_"

def serialize(response: LinagoraServicesDiscoveryConfiguration): String =
Json.stringify(response.services.foldLeft(Json.obj()) { (json, service) =>
insertNestedJson(json,
service.key.split(NESTED_DELIMITER).map(_.replace(UNDERSCORE_DELIMITER, StringUtils.SPACE)).toList,
JsString(service.value))
})

def serialize(response: LinagoraServicesDiscoveryConfiguration): String = Json.stringify(Json.toJson(response))
private def insertNestedJson(base: JsObject, path: List[String], value: JsValue): JsObject =
path match {
case head :: Nil => base + (head -> value)
case head :: tail => base + (head -> insertNestedJson((base \ head).asOpt[JsObject].getOrElse(Json.obj()), tail, value))
case Nil => base
}
}

class LinagoraServicesDiscoveryModule() extends AbstractModule {
Expand Down Expand Up @@ -70,10 +78,10 @@ class LinagoraServicesDiscoveryRoutes @Inject()(val servicesDiscoveryConfigurati
.flatMap(_ => response
.status(HttpResponseStatus.OK)
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.sendString(Mono.fromCallable(() => Serializers.serialize(servicesDiscoveryConfiguration)))
.sendString(Mono.fromCallable(() => ServicesDiscoveryConfigurationSerializers.serialize(servicesDiscoveryConfiguration)))
.`then`())
.cast(classOf[Void])
.onErrorResume(_ match {
.onErrorResume {
case e: UnauthorizedException =>
LOGGER.warn("Unauthorized", e)
respondDetails(e.addHeaders(response),
Expand All @@ -82,7 +90,7 @@ class LinagoraServicesDiscoveryRoutes @Inject()(val servicesDiscoveryConfigurati
LOGGER.error("Unexpected error upon service discovering", e)
respondDetails(response,
ProblemDetails(status = INTERNAL_SERVER_ERROR, detail = e.getMessage), INTERNAL_SERVER_ERROR)
})
}

private def respondDetails(httpServerResponse: HttpServerResponse, problemDetails: ProblemDetails, statusCode: HttpResponseStatus): Mono[Void] =
Mono.fromCallable(() => ResponseSerializer.serialize(problemDetails))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.linagora.tmail.james.jmap.json

import com.linagora.tmail.james.jmap.service.discovery.{LinagoraServicesDiscoveryConfiguration, LinagoraServicesDiscoveryItem, ServicesDiscoveryConfigurationSerializers => testee}
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import play.api.libs.json.Json

class LinagoraServicesDiscoveryConfigurationSerializeTest {

@Test
def serializeShouldHandleFlatPropertiesCorrectly(): Unit = {
val input = LinagoraServicesDiscoveryConfiguration(services = List(
LinagoraServicesDiscoveryItem("linShareApiUrl", "https://linshare.linagora.com/linshare/webservice"),
LinagoraServicesDiscoveryItem("linToApiUrl", "https://linto.ai/demo"),
LinagoraServicesDiscoveryItem("linToApiKey", "apiKey")))

assertThat(Json.parse(testee.serialize(input)))
.isEqualTo(Json.parse(
"""{
| "linShareApiUrl" : "https://linshare.linagora.com/linshare/webservice",
| "linToApiUrl" : "https://linto.ai/demo",
| "linToApiKey" : "apiKey"
| }""".stripMargin))
}

@Test
def serializeShouldHandleNestedPropertiesCorrectly(): Unit = {
val input = LinagoraServicesDiscoveryConfiguration(services = List(
LinagoraServicesDiscoveryItem("mobileApps.Lin1.url1", "url1"),
LinagoraServicesDiscoveryItem("mobileApps.Lin1.url2", "url2"),
LinagoraServicesDiscoveryItem("mobileApps.Lin2.url3", "url3"),
LinagoraServicesDiscoveryItem("mobileApps.Lin4", "url4")))

assertThat(Json.parse(testee.serialize(input)))
.isEqualTo(Json.parse(
"""{
| "mobileApps": {
| "Lin1": {
| "url1": "url1",
| "url2": "url2"
| },
| "Lin2": {
| "url3": "url3"
| },
| "Lin4": "url4"
| }
|}""".stripMargin))
}

@Test
def serializeShouldTransformUnderscoreToSpaceInNestedKeys(): Unit = {
val input = LinagoraServicesDiscoveryConfiguration(services = List(
LinagoraServicesDiscoveryItem("linToApiKey", "apiKey"),
LinagoraServicesDiscoveryItem("mobileApps.Twake_Chat.logoURL", "https://xyz"),
LinagoraServicesDiscoveryItem("mobileApps.Twake_Chat.appId", "abc"),
LinagoraServicesDiscoveryItem("mobileApps.Twake_Drive.logoURL", "https://xyz"),
LinagoraServicesDiscoveryItem("mobileApps.Twake_Drive.webLink", "https://tdrive.linagora.com"),
LinagoraServicesDiscoveryItem("mobileApps.TwakeXyz.Logo_URL", "https://xyz")
))

assertThat(Json.parse(testee.serialize(input)))
.isEqualTo(Json.parse(
"""{
| "mobileApps": {
| "TwakeXyz": {
| "Logo URL": "https://xyz"
| },
| "Twake Chat": {
| "logoURL": "https://xyz",
| "appId": "abc"
| },
| "Twake Drive": {
| "logoURL": "https://xyz",
| "webLink": "https://tdrive.linagora.com"
| }
| },
| "linToApiKey": "apiKey"
|}""".stripMargin))
}

@Test
def serilizeShouldSuccessWhenEmptyConfiguration(): Unit = {
val input = LinagoraServicesDiscoveryConfiguration(services = List())

assertThat(Json.parse(testee.serialize(input)))
.isEqualTo(Json.parse("{}"))
}
}