Repository: seratch/notion-sdk-jvm (kotlin)

Issue:
Add OAuth flow support or example code
If this SDK provides easy-to-use module along with Spring Boot, Ktor, and so on, implementing the Notion OAuth flow in JVM apps can be so easy.

References:
* https://developers.notion.com/docs/authorization
* https://developers.notion.com/changelog/oauth-token-response-now-includes-more-info-about-the-workspace

Pull request description:
This pull request fixes #22

Patch:
diff --git a/core/pom.xml b/core/pom.xml
index 556aab0..e88abd2 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -16,8 +16,21 @@
     <properties>
         <maven.compiler.target>1.8</maven.compiler.target>
         <maven.compiler.source>1.8</maven.compiler.source>
+        <ktor.version>1.6.7</ktor.version>
     </properties>
 
     <dependencies>
+        <dependency>
+            <groupId>io.ktor</groupId>
+            <artifactId>ktor-server-core</artifactId>
+            <version>${ktor.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.ktor</groupId>
+            <artifactId>ktor-server-netty</artifactId>
+            <version>${ktor.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
\ No newline at end of file
diff --git a/core/src/main/kotlin/notion/api/v1/NotionClient.kt b/core/src/main/kotlin/notion/api/v1/NotionClient.kt
index 84ea4b7..314077b 100644
--- a/core/src/main/kotlin/notion/api/v1/NotionClient.kt
+++ b/core/src/main/kotlin/notion/api/v1/NotionClient.kt
@@ -12,7 +12,10 @@ import notion.api.v1.logging.StdoutLogger
 class NotionClient
 @JvmOverloads
 constructor(
-    override var token: String,
+    override var token: String? = null,
+    override var clientId: String? = null,
+    override var clientSecret: String? = null,
+    override var redirectUri: String? = null,
     override var httpClient: NotionHttpClient = defaultHttpClient,
     override var logger: NotionLogger = defaultLogger,
     override var jsonSerializer: NotionJsonSerializer = defaultJsonSerializer,
@@ -24,7 +27,8 @@ constructor(
     PagesSupport,
     BlocksSupport,
     SearchSupport,
-    UsersSupport {
+    UsersSupport,
+    OAuthSupport {
 
   companion object {
     @JvmStatic val defaultBaseUrl: String = "https://api.notion.com/v1"
diff --git a/core/src/main/kotlin/notion/api/v1/endpoint/EndpointsSupport.kt b/core/src/main/kotlin/notion/api/v1/endpoint/EndpointsSupport.kt
index f98bde2..c37c53c 100644
--- a/core/src/main/kotlin/notion/api/v1/endpoint/EndpointsSupport.kt
+++ b/core/src/main/kotlin/notion/api/v1/endpoint/EndpointsSupport.kt
@@ -4,14 +4,22 @@ import java.net.URLEncoder
 import notion.api.v1.http.UserAgent
 
 interface EndpointsSupport {
-  val token: String
+  val token: String?
 
   fun buildRequestHeaders(additionalOnes: Map<String, String>): Map<String, String> {
-    return mapOf(
-            "Authorization" to "Bearer $token",
-            "Notion-Version" to "2021-08-16",
-            "User-Agent" to UserAgent.buildUserAgent())
-        .plus(additionalOnes)
+    val commonHeaders =
+        mapOf(
+                "Authorization" to "Bearer $token",
+                "Notion-Version" to "2021-08-16",
+                "User-Agent" to UserAgent.buildUserAgent(),
+            )
+            .plus(additionalOnes)
+
+    return if (token != null) {
+      commonHeaders.plus("Authorization" to "Bearer $token")
+    } else {
+      commonHeaders
+    }
   }
 
   fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8")
diff --git a/core/src/main/kotlin/notion/api/v1/endpoint/OAuthSupport.kt b/core/src/main/kotlin/notion/api/v1/endpoint/OAuthSupport.kt
new file mode 100644
index 0000000..0838727
--- /dev/null
+++ b/core/src/main/kotlin/notion/api/v1/endpoint/OAuthSupport.kt
@@ -0,0 +1,59 @@
+package notion.api.v1.endpoint
+
+import java.util.*
+import notion.api.v1.exception.NotionOAuthAPIError
+import notion.api.v1.http.NotionHttpClient
+import notion.api.v1.json.NotionJsonSerializer
+import notion.api.v1.logging.NotionLogger
+import notion.api.v1.model.oauth.OAuthTokenResult
+import notion.api.v1.request.oauth.ExchangeAuthCodeRequest
+
+interface OAuthSupport : EndpointsSupport {
+  val clientId: String?
+  val clientSecret: String?
+  val redirectUri: String?
+  val httpClient: NotionHttpClient
+  val jsonSerializer: NotionJsonSerializer
+  val logger: NotionLogger
+  val baseUrl: String
+
+  // -----------------------------------------------
+  // search
+  // -----------------------------------------------
+
+  fun exchangeAuthCode(code: String, state: String): OAuthTokenResult {
+    return exchangeAuthCode(
+        ExchangeAuthCodeRequest(
+            code = code,
+            state = state,
+            redirectUri = this.redirectUri!!,
+        ))
+  }
+
+  fun exchangeAuthCode(request: ExchangeAuthCodeRequest): OAuthTokenResult {
+    if (this.redirectUri == null) {
+      throw IllegalStateException("Setting redirectUri to NotionClient is required")
+    }
+    val base64Value =
+        String(
+            Base64.getEncoder()
+                .encode("${this.clientId}:${this.clientSecret}".toByteArray(Charsets.UTF_8)),
+            Charsets.UTF_8)
+    val httpResponse =
+        httpClient.postTextBody(
+            logger = logger,
+            url = "$baseUrl/oauth/token",
+            body = jsonSerializer.toJsonString(request),
+            headers =
+                buildRequestHeaders(
+                    contentTypeJson().plus("Authorization" to "Basic $base64Value")))
+    if (httpResponse.status == 200) {
+      return jsonSerializer.toOAuthTokenResult(httpResponse.body)
+    } else {
+      throw NotionOAuthAPIError(
+          error = jsonSerializer.toOAuthError(httpResponse.body),
+          httpResponse = httpResponse,
+      )
+    }
+  }
+}
diff --git a/core/src/main/kotlin/notion/api/v1/exception/NotionOAuthAPIError.kt b/core/src/main/kotlin/notion/api/v1/exception/NotionOAuthAPIError.kt
new file mode 100644
index 0000000..df1e8dc
--- /dev/null
+++ b/core/src/main/kotlin/notion/api/v1/exception/NotionOAuthAPIError.kt
@@ -0,0 +1,19 @@
+package notion.api.v1.exception
+
+import notion.api.v1.http.NotionHttpResponse
+import notion.api.v1.model.error.OAuthError
+
+class NotionOAuthAPIError
+@JvmOverloads
+constructor(
+    val error: OAuthError,
+    val httpResponse: NotionHttpResponse,
+    override val message: String = buildMessage(httpResponse, error),
+    override val cause: Throwable? = null
+) : RuntimeException(message, cause) {
+
+  companion object {
+    private fun buildMessage(response: NotionHttpResponse, error: OAuthError) =
+        "Got an error from Notion (status: ${response.status}, error: ${error.error})"
+  }
+}
diff --git a/core/src/main/kotlin/notion/api/v1/json/GsonSerializer.kt b/core/src/main/kotlin/notion/api/v1/json/GsonSerializer.kt
index 789f934..f6be5b6 100644
--- a/core/src/main/kotlin/notion/api/v1/json/GsonSerializer.kt
+++ b/core/src/main/kotlin/notion/api/v1/json/GsonSerializer.kt
@@ -15,6 +15,8 @@ import notion.api.v1.model.databases.Database
 import notion.api.v1.model.databases.Databases
 import notion.api.v1.model.databases.QueryResults
 import notion.api.v1.model.error.Error
+import notion.api.v1.model.error.OAuthError
+import notion.api.v1.model.oauth.OAuthTokenResult
 import notion.api.v1.model.pages.Page
 import notion.api.v1.model.pages.PagePropertyItem
 import notion.api.v1.model.search.SearchResult
@@ -25,6 +27,7 @@ import notion.api.v1.request.blocks.AppendBlockChildrenRequest
 import notion.api.v1.request.databases.CreateDatabaseRequest
 import notion.api.v1.request.databases.QueryDatabaseRequest
 import notion.api.v1.request.databases.UpdateDatabaseRequest
+import notion.api.v1.request.oauth.ExchangeAuthCodeRequest
 import notion.api.v1.request.pages.CreatePageRequest
 import notion.api.v1.request.pages.UpdatePageRequest
 import notion.api.v1.request.search.SearchRequest
@@ -55,6 +58,8 @@ class GsonSerializer : NotionJsonSerializer {
   override fun toDatabase(body: String): Database = gson.fromJson(body, Database::class.java)
   override fun toDatabases(body: String): Databases = gson.fromJson(body, Databases::class.java)
   override fun toError(body: String): Error = gson.fromJson(body, Error::class.java)
+  override fun toOAuthError(body: String): OAuthError = gson.fromJson(body, OAuthError::class.java)
+
   override fun toPage(body: String): Page = gson.fromJson(body, Page::class.java)
   override fun toPagePropertyItem(body: String): PagePropertyItem =
       gson.fromJson(body, PagePropertyItem::class.java)
@@ -65,6 +70,9 @@ class GsonSerializer : NotionJsonSerializer {
   override fun toSearchResults(body: String): SearchResults =
       gson.fromJson(body, SearchResults::class.java)
 
+  override fun toOAuthTokenResult(body: String): OAuthTokenResult =
+      gson.fromJson(body, OAuthTokenResult::class.java)
+
   override fun toUser(body: String): User = gson.fromJson(body, User::class.java)
   override fun toUsers(body: String): Users = gson.fromJson(body, Users::class.java)
 
@@ -79,4 +87,5 @@ class GsonSerializer : NotionJsonSerializer {
   override fun toJsonString(request: SearchRequest): String = gson.toJson(request)
   override fun toJsonString(request: QueryDatabaseRequest): String = gson.toJson(request)
   override fun toJsonString(request: UpdatePageRequest): String = gson.toJson(request)
+  override fun toJsonString(request: ExchangeAuthCodeRequest): String = gson.toJson(request)
 }
diff --git a/core/src/main/kotlin/notion/api/v1/json/NotionJsonSerializer.kt b/core/src/main/kotlin/notion/api/v1/json/NotionJsonSerializer.kt
index 4149baf..3846707 100644
--- a/core/src/main/kotlin/notion/api/v1/json/NotionJsonSerializer.kt
+++ b/core/src/main/kotlin/notion/api/v1/json/NotionJsonSerializer.kt
@@ -8,6 +8,8 @@ import notion.api.v1.model.databases.Database
 import notion.api.v1.model.databases.Databases
 import notion.api.v1.model.databases.QueryResults
 import notion.api.v1.model.error.Error
+import notion.api.v1.model.error.OAuthError
+import notion.api.v1.model.oauth.OAuthTokenResult
 import notion.api.v1.model.pages.Page
 import notion.api.v1.model.pages.PagePropertyItem
 import notion.api.v1.model.search.SearchResults
@@ -17,6 +19,7 @@ import notion.api.v1.request.blocks.AppendBlockChildrenRequest
 import notion.api.v1.request.databases.CreateDatabaseRequest
 import notion.api.v1.request.databases.QueryDatabaseRequest
 import notion.api.v1.request.databases.UpdateDatabaseRequest
+import notion.api.v1.request.oauth.ExchangeAuthCodeRequest
 import notion.api.v1.request.pages.CreatePageRequest
 import notion.api.v1.request.pages.UpdatePageRequest
 import notion.api.v1.request.search.SearchRequest
@@ -28,10 +31,12 @@ interface NotionJsonSerializer {
   fun toDatabase(body: String): Database
   fun toDatabases(body: String): Databases
   fun toError(body: String): Error
+  fun toOAuthError(body: String): OAuthError
   fun toPage(body: String): Page
   fun toPagePropertyItem(body: String): PagePropertyItem
   fun toQueryResults(body: String): QueryResults
   fun toSearchResults(body: String): SearchResults
+  fun toOAuthTokenResult(body: String): OAuthTokenResult
   fun toUser(body: String): User
   fun toUsers(body: String): Users
 
@@ -43,4 +48,5 @@ interface NotionJsonSerializer {
   fun toJsonString(request: SearchRequest): String
   fun toJsonString(request: QueryDatabaseRequest): String
   fun toJsonString(request: UpdatePageRequest): String
+  fun toJsonString(request: ExchangeAuthCodeRequest): String
 }
diff --git a/core/src/main/kotlin/notion/api/v1/model/error/OAuthError.kt b/core/src/main/kotlin/notion/api/v1/model/error/OAuthError.kt
new file mode 100644
index 0000000..15118f9
--- /dev/null
+++ b/core/src/main/kotlin/notion/api/v1/model/error/OAuthError.kt
@@ -0,0 +1,8 @@
+package notion.api.v1.model.error
+
+// This data class does not have setters as developers never manually modify this
+data class OAuthError
+@JvmOverloads
+constructor(
+    val error: String,
+)
diff --git a/core/src/main/kotlin/notion/api/v1/model/oauth/OAuthTokenResult.kt b/core/src/main/kotlin/notion/api/v1/model/oauth/OAuthTokenResult.kt
new file mode 100644
index 0000000..87d6487
--- /dev/null
+++ b/core/src/main/kotlin/notion/api/v1/model/oauth/OAuthTokenResult.kt
@@ -0,0 +1,17 @@
+package notion.api.v1.model.oauth
+
+import notion.api.v1.model.users.User
+
+data class OAuthTokenResult
+@JvmOverloads
+constructor(
+    val accessToken: String,
+    val tokenType: String,
+    val workspaceId: String,
+    val workspaceName: String?,
+    val workspaceIcon: String?,
+    val botId: String,
+    val owner: Owner,
+)
+
+data class Owner @JvmOverloads constructor(val type: String, val user: User?)
diff --git a/core/src/main/kotlin/notion/api/v1/request/oauth/ExchangeAuthCodeRequest.kt b/core/src/main/kotlin/notion/api/v1/request/oauth/ExchangeAuthCodeRequest.kt
new file mode 100644
index 0000000..e822365
--- /dev/null
+++ b/core/src/main/kotlin/notion/api/v1/request/oauth/ExchangeAuthCodeRequest.kt
@@ -0,0 +1,10 @@
+package notion.api.v1.request.oauth
+
+data class ExchangeAuthCodeRequest
+@JvmOverloads
+constructor(
+    val grantType: String = "authorization_code",
+    val code: String,
+    val state: String,
+    val redirectUri: String,
+)
diff --git a/pom.xml b/pom.xml
index 045c6dd..5c68311 100644
--- a/pom.xml
+++ b/pom.xml
@@ -42,7 +42,7 @@
         <okhttp4.version>4.9.1</okhttp4.version>
         <okhttp5.version>5.0.0-alpha.3</okhttp5.version>
         <gson.version>2.8.9</gson.version>
-        <slf4j.version>1.7.32</slf4j.version>
+        <slf4j.version>1.7.33</slf4j.version>
         <logback.version>1.2.10</logback.version>
         <kotlin.version>1.6.10</kotlin.version>
         <kotlin.code.style>official</kotlin.code.style>


Test patch:
diff --git a/core/src/test/kotlin/OAuthAppExample.kt b/core/src/test/kotlin/OAuthAppExample.kt
new file mode 100644
index 0000000..238afc6
--- /dev/null
+++ b/core/src/test/kotlin/OAuthAppExample.kt
@@ -0,0 +1,127 @@
+import io.ktor.application.*
+import io.ktor.http.*
+import io.ktor.http.content.*
+import io.ktor.request.*
+import io.ktor.response.*
+import io.ktor.routing.*
+import io.ktor.server.engine.*
+import io.ktor.server.netty.*
+import notion.api.v1.NotionClient
+import notion.api.v1.exception.NotionOAuthAPIError
+import org.slf4j.LoggerFactory
+import java.net.URLEncoder
+import java.util.*
+
+class StateManager {
+  private val values: MutableMap<String, Long> = mutableMapOf()
+  fun generate(): String {
+    val state = UUID.randomUUID().toString()
+    values[state] = Date().time
+    return state
+  }
+
+  fun consume(state: String, cookies: RequestCookies): Boolean {
+    val createdAt = values.remove(state)
+    if (createdAt == null || Date().time >= (createdAt + 10 * 60 * 1000)) {
+      return false
+    }
+    val sessionValue = cookies["notion-installation-session"]
+    if (sessionValue == null || sessionValue != state) {
+      return false
+    }
+    return true
+  }
+}
+
+fun urlEncode(v: String?): String = URLEncoder.encode(v, Charsets.UTF_8)
+
+fun main() {
+  val logger = LoggerFactory.getLogger("main")
+  val client = NotionClient(
+    clientId = System.getenv("NOTION_CLIENT_ID"),
+    clientSecret = System.getenv("NOTION_CLIENT_SECRET"),
+    redirectUri = System.getenv("NOTION_REDIRECT_URI"),
+  )
+  val stateManager = StateManager()
+  client.use {
+    val server = embeddedServer(Netty, port = 3000) {
+      routing {
+        get("/notion/install") {
+          try {
+            val state = stateManager.generate()
+            val authorizeUrl = "https://api.notion.com/v1/oauth/authorize?owner=user" +
+                "&client_id=${client.clientId}" +
+                "&redirect_uri=${urlEncode(client.redirectUri)}" +
+                "&state=$state" +
+                "&response_type=code"
+            call.response.header("Location", authorizeUrl)
+            call.response.cookies.append(
+              name = "notion-installation-session",
+              value = state,
+              httpOnly = true,
+              path = "/"
+            )
+            call.respond(HttpStatusCode.TemporaryRedirect)
+          } catch (e: Exception) {
+            logger.error(e.message, e)
+            call.respond(
+              TextContent(
+                "Error: $e",
+                ContentType.parse("text/html; charset=utf-8"),
+                HttpStatusCode.InternalServerError,
+              )
+            )
+          }
+        }
+        get("/notion/callback") {
+          val code = call.request.queryParameters["code"]
+          val state = call.request.queryParameters["state"]
+          if (code != null && state != null) {
+            if (stateManager.consume(state, call.request.cookies)) {
+              call.response.cookies.appendExpired(
+                name = "notion-installation-session",
+                path = "/"
+              )
+              try {
+                val result = client.exchangeAuthCode(code = code, state = state)
+                logger.info("token API response: $result")
+                call.respond(
+                  TextContent(
+                    "OK!",
+                    ContentType.parse("text/html; charset=utf-8"),
+                    HttpStatusCode.OK,
+                  )
+                )
+              } catch (e: NotionOAuthAPIError) {
+                call.respond(
+                  TextContent(
+                    "Error: $e",
+                    ContentType.parse("text/html; charset=utf-8"),
+                    HttpStatusCode(e.httpResponse.status, ""),
+                  )
+                )
+              }
+            } else {
+              call.respond(
+                TextContent(
+                  "Error: the state value is no longer vaild",
+                  ContentType.parse("text/html; charset=utf-8"),
+                  HttpStatusCode.BadRequest,
+                )
+              )
+            }
+          } else {
+            call.respond(
+              TextContent(
+                "Error: invalid redirection",
+                ContentType.parse("text/html; charset=utf-8"),
+                HttpStatusCode.BadRequest,
+              )
+            )
+          }
+        }
+      }
+    }
+    server.start(wait = true)
+  }
+}
diff --git a/core/src/test/kotlin/readme.kt b/core/src/test/kotlin/readme.kt
index 88b6815..2f80a4b 100644
--- a/core/src/test/kotlin/readme.kt
+++ b/core/src/test/kotlin/readme.kt
@@ -1,5 +1,4 @@
 import notion.api.v1.NotionClient
-import notion.api.v1.model.common.ObjectType
 import notion.api.v1.model.pages.PageParent
 import notion.api.v1.request.search.SearchRequest
 import notion.api.v1.model.pages.PageProperty as prop
diff --git a/core/src/test/kotlin/tests/json/OAuthTokenResultParserTest.kt b/core/src/test/kotlin/tests/json/OAuthTokenResultParserTest.kt
new file mode 100644
index 0000000..d5416f0
--- /dev/null
+++ b/core/src/test/kotlin/tests/json/OAuthTokenResultParserTest.kt
@@ -0,0 +1,39 @@
+package tests.json
+
+import kotlin.test.assertNotNull
+import notion.api.v1.json.GsonSerializer
+import org.junit.Test
+
+class OAuthTokenResultParserTest {
+  private val json =
+      """
+{
+  "access_token": "secret_XXXXX",
+  "token_type": "bearer",
+  "bot_id": "4cda3e4b-XXX-XXX-XXX-XXX",
+  "workspace_name": "Kaz's Notion",
+  "workspace_icon": "https://lh3.googleusercontent.com/a-/AOh14Gi6IRO6Ea30aCqeQFbgbR5HgU7ghyRsGAUjsUo3Ito=s100",
+  "workspace_id": "841147a2-YYY-YYY-YYY-YYY",
+  "owner": {
+    "type": "user",
+    "user": {
+      "object": "user",
+      "id": "94ab783a-ZZZ-ZZZ-ZZZ-ZZZ",
+      "name": "Kazuhiro Sera",
+      "avatar_url": "https://lh3.googleusercontent.com/a-/AOh14Gi6IRO6Ea30aCqeQFbgbR5HgU7ghyRsGAUjsUo3Ito=s100",
+      "type": "person",
+      "person": {
+        "email": "seratch@example.com"
+      }
+    }
+  }
+}
+""".trimIndent()
+
+  @Test
+  fun testParsing() {
+    val serializer = GsonSerializer()
+    val result = serializer.toOAuthTokenResult(json)
+    assertNotNull(result)
+  }
+}