Compare commits
9 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
b05b8f4c4a | |
|
|
e0e959a1ba | |
|
|
d24fa43b31 | |
|
|
b07d975aff | |
|
|
96689c6ba5 | |
|
|
57ba9f36d9 | |
|
|
5e7ff8ca5a | |
|
|
0769a2afad | |
|
|
e86e88c27b |
|
|
@ -29,21 +29,13 @@ allprojects {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
println("Enabling IDEA plugin in project ${project.name}...")
|
|
||||||
apply(plugin = "idea")
|
apply(plugin = "idea")
|
||||||
}
|
}
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
println("Enabling Kotlin JVM plugin in project ${project.name}...")
|
|
||||||
apply(plugin = "org.jetbrains.kotlin.jvm")
|
apply(plugin = "org.jetbrains.kotlin.jvm")
|
||||||
|
|
||||||
println("Enabling Kotlin Spring plugin in project ${project.name}...")
|
|
||||||
apply(plugin = "org.jetbrains.kotlin.plugin.spring")
|
apply(plugin = "org.jetbrains.kotlin.plugin.spring")
|
||||||
|
|
||||||
println("Enabling Spring Boot Dependency Management in project ${project.name}...")
|
|
||||||
apply(plugin = "io.spring.dependency-management")
|
apply(plugin = "io.spring.dependency-management")
|
||||||
|
|
||||||
println("Enabling Detekt support in project ${project.name}...")
|
|
||||||
apply(plugin = "io.gitlab.arturbosch.detekt")
|
apply(plugin = "io.gitlab.arturbosch.detekt")
|
||||||
|
|
||||||
detekt {
|
detekt {
|
||||||
|
|
@ -81,6 +73,7 @@ subprojects {
|
||||||
dependency("org.junit.jupiter:junit-jupiter-engine:5.4.2")
|
dependency("org.junit.jupiter:junit-jupiter-engine:5.4.2")
|
||||||
|
|
||||||
dependency("org.liquibase:liquibase-core:4.4.0")
|
dependency("org.liquibase:liquibase-core:4.4.0")
|
||||||
|
dependency("org.telegram:telegrambots-spring-boot-starter:6.4.0")
|
||||||
|
|
||||||
dependency("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
|
dependency("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
|
||||||
dependency("org.mockito:mockito-inline:3.11.0")
|
dependency("org.mockito:mockito-inline:3.11.0")
|
||||||
|
|
@ -97,6 +90,9 @@ subprojects {
|
||||||
dependency("software.amazon.awssdk:s3:2.10.11")
|
dependency("software.amazon.awssdk:s3:2.10.11")
|
||||||
|
|
||||||
dependency("com.google.firebase:firebase-admin:9.0.0")
|
dependency("com.google.firebase:firebase-admin:9.0.0")
|
||||||
|
dependency("com.google.api-client:google-api-client:1.33.0")
|
||||||
|
dependency("com.google.apis:google-api-services-sheets:v4-rev20210629-1.32.1")
|
||||||
|
dependency("com.google.auth:google-auth-library-oauth2-http:1.3.0")
|
||||||
|
|
||||||
dependency("com.github.ua-parser:uap-java:1.5.3")
|
dependency("com.github.ua-parser:uap-java:1.5.3")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import org.springframework.data.annotation.LastModifiedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
|
import javax.persistence.Column
|
||||||
import javax.persistence.EntityListeners
|
import javax.persistence.EntityListeners
|
||||||
import javax.persistence.MappedSuperclass
|
import javax.persistence.MappedSuperclass
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ import javax.persistence.MappedSuperclass
|
||||||
abstract class BaseEntity : Serializable {
|
abstract class BaseEntity : Serializable {
|
||||||
|
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
|
@Column(updatable = false)
|
||||||
lateinit var createdAt: ZonedDateTime
|
lateinit var createdAt: ZonedDateTime
|
||||||
|
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package ru.touchin.common.spring.security.utils
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||||
|
|
||||||
|
object PasswordEncoder {
|
||||||
|
|
||||||
|
fun String.encode(): String = BCryptPasswordEncoder().encode(this)
|
||||||
|
|
||||||
|
fun String.verify(passwordHash: String) = BCryptPasswordEncoder().matches(this, passwordHash)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,18 @@ package ru.touchin.common.spring.test.jpa.repository
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.test.context.ActiveProfiles
|
import org.springframework.test.context.ActiveProfiles
|
||||||
|
import org.springframework.test.context.junit.jupiter.DisabledIf
|
||||||
import ru.touchin.common.spring.test.annotations.SlowTest
|
import ru.touchin.common.spring.test.annotations.SlowTest
|
||||||
|
|
||||||
@ActiveProfiles("test", "test-slow")
|
@ActiveProfiles("test", "test-slow")
|
||||||
@SlowTest
|
@SlowTest
|
||||||
@DataJpaTest
|
@DataJpaTest
|
||||||
@Import(RepositoryTestConfiguration::class)
|
@Import(RepositoryTestConfiguration::class)
|
||||||
|
@DisabledIf(
|
||||||
|
expression = """
|
||||||
|
#{systemProperties['tests.slow.enabled'] != null
|
||||||
|
? systemProperties['tests.slow.enabled'].toLowerCase().contains('false')
|
||||||
|
: false}
|
||||||
|
"""
|
||||||
|
)
|
||||||
annotation class RepositoryTest
|
annotation class RepositoryTest
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package ru.touchin.common.spring.test.jpa.repository
|
||||||
import com.zaxxer.hikari.HikariConfig
|
import com.zaxxer.hikari.HikariConfig
|
||||||
import com.zaxxer.hikari.HikariDataSource
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||||
import org.springframework.boot.test.context.TestConfiguration
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.ComponentScan
|
import org.springframework.context.annotation.ComponentScan
|
||||||
|
|
@ -18,6 +19,7 @@ import javax.sql.DataSource
|
||||||
@TestConfiguration
|
@TestConfiguration
|
||||||
@EnableJpaAuditingExtra
|
@EnableJpaAuditingExtra
|
||||||
@ComponentScan
|
@ComponentScan
|
||||||
|
@ConditionalOnProperty(name = ["tests.slow.enabled"], matchIfMissing = true)
|
||||||
class RepositoryTestConfiguration {
|
class RepositoryTestConfiguration {
|
||||||
|
|
||||||
// запуск и остановка контейнера по lifecycle-событиями компонента (1)
|
// запуск и остановка контейнера по lifecycle-событиями компонента (1)
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,4 @@ spring:
|
||||||
tests:
|
tests:
|
||||||
slow:
|
slow:
|
||||||
db:
|
db:
|
||||||
imageName: "postgres:12"
|
imageName: "postgres:15"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.test.context.junit.jupiter.DisabledIf
|
import org.springframework.test.context.junit.jupiter.DisabledIf
|
||||||
|
|
||||||
@Profile(value = ["test", "test-slow"])
|
@Profile(value = ["test", "test-slow"])
|
||||||
@DisabledIf("\${tests.slow.disabled:false}")
|
|
||||||
@Target(allowedTargets = [AnnotationTarget.FUNCTION, AnnotationTarget.CLASS])
|
@Target(allowedTargets = [AnnotationTarget.FUNCTION, AnnotationTarget.CLASS])
|
||||||
|
@DisabledIf(
|
||||||
|
expression = """
|
||||||
|
#{systemProperties['tests.slow.enabled'] != null
|
||||||
|
? systemProperties['tests.slow.enabled'].toLowerCase().contains('false')
|
||||||
|
: false}
|
||||||
|
"""
|
||||||
|
)
|
||||||
annotation class SlowTest
|
annotation class SlowTest
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
@file:Suppress("unused")
|
||||||
|
package ru.touchin.common.exceptions
|
||||||
|
|
||||||
|
open class CommonFormException(description: String?) : CommonException(description)
|
||||||
|
|
@ -2,4 +2,10 @@ package ru.touchin.auth.core.scope.dto
|
||||||
|
|
||||||
data class Scope(
|
data class Scope(
|
||||||
val name: String
|
val name: String
|
||||||
)
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val user = Scope("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,41 @@ package ru.touchin.auth.core.tokens.access.config
|
||||||
|
|
||||||
import com.auth0.jwt.algorithms.Algorithm
|
import com.auth0.jwt.algorithms.Algorithm
|
||||||
import org.springframework.beans.factory.annotation.Qualifier
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.ComponentScan
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import ru.touchin.auth.core.tokens.access.properties.AccessTokenProperties
|
import ru.touchin.auth.core.tokens.access.properties.AccessTokenProperties
|
||||||
import ru.touchin.auth.security.jwt.configurations.JwtConfiguration
|
import ru.touchin.auth.security.jwt.configurations.JwtConfiguration
|
||||||
import ru.touchin.auth.security.jwt.utils.JwtUtils.getKeySpec
|
import ru.touchin.auth.security.jwt.utils.JwtUtils.getKeySpec
|
||||||
|
import java.io.File
|
||||||
import java.security.KeyFactory
|
import java.security.KeyFactory
|
||||||
import java.security.interfaces.RSAPrivateKey
|
import java.security.interfaces.RSAPrivateKey
|
||||||
import java.security.interfaces.RSAPublicKey
|
import java.security.interfaces.RSAPublicKey
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@Import(JwtConfiguration::class)
|
@Import(JwtConfiguration::class)
|
||||||
|
@ComponentScan("ru.touchin.auth.core.tokens.access")
|
||||||
|
@ConfigurationPropertiesScan("ru.touchin.auth.core.tokens.access")
|
||||||
class AccessTokenBeanConfig(private val accessTokenProperties: AccessTokenProperties) {
|
class AccessTokenBeanConfig(private val accessTokenProperties: AccessTokenProperties) {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun accessTokenSigningAlgorithm(
|
fun accessTokenSigningAlgorithm(
|
||||||
@Qualifier("accessTokenPublicKey")
|
@Qualifier("accessTokenPublicKey")
|
||||||
accessTokenPublicKey: RSAPublicKey
|
accessTokenPublicKey: RSAPublicKey,
|
||||||
|
@Qualifier("accessTokenPrivateKey")
|
||||||
|
accessTokenPrivateKey: RSAPrivateKey,
|
||||||
): Algorithm {
|
): Algorithm {
|
||||||
return Algorithm.RSA256(
|
return Algorithm.RSA256(
|
||||||
accessTokenPublicKey,
|
accessTokenPublicKey,
|
||||||
accessTokenPrivateKey()
|
accessTokenPrivateKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean("accessTokenPrivateKey")
|
@Bean("accessTokenPrivateKey")
|
||||||
fun accessTokenPrivateKey(): RSAPrivateKey {
|
fun accessTokenPrivateKey(): RSAPrivateKey {
|
||||||
val keySpecPKCS8 = getKeySpec(accessTokenProperties.keyPair.private, ::PKCS8EncodedKeySpec)
|
val privateKey = this.javaClass.classLoader.getResource(accessTokenProperties.keyPair.private)!!.readText()
|
||||||
|
val keySpecPKCS8 = getKeySpec(privateKey, ::PKCS8EncodedKeySpec)
|
||||||
|
|
||||||
return keyFactory.generatePrivate(keySpecPKCS8) as RSAPrivateKey
|
return keyFactory.generatePrivate(keySpecPKCS8) as RSAPrivateKey
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.util.DefaultUriBuilderFactory
|
import org.springframework.web.util.DefaultUriBuilderFactory
|
||||||
import ru.touchin.auth.core.tokens.access.properties.AccessTokenProperties
|
|
||||||
import ru.touchin.auth.security.oauth2.metadata.properties.OAuth2MetadataProperties
|
import ru.touchin.auth.security.oauth2.metadata.properties.OAuth2MetadataProperties
|
||||||
import ru.touchin.auth.security.oauth2.metadata.response.OAuth2MetadataResponse
|
import ru.touchin.auth.security.oauth2.metadata.response.OAuth2MetadataResponse
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
@ -15,13 +14,12 @@ import java.net.URI
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/.well-known/oauth-authorization-server")
|
@RequestMapping("/.well-known/oauth-authorization-server")
|
||||||
class OAuth2MetadataController(
|
class OAuth2MetadataController(
|
||||||
private val accessTokenProperties: AccessTokenProperties,
|
|
||||||
private val oauth2MetadataProperties: OAuth2MetadataProperties,
|
private val oauth2MetadataProperties: OAuth2MetadataProperties,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun metadata(): OAuth2MetadataResponse {
|
fun metadata(): OAuth2MetadataResponse {
|
||||||
val issuer = accessTokenProperties.issuer.let(URI::create)
|
val issuer = oauth2MetadataProperties.issuer.let(URI::create)
|
||||||
|
|
||||||
return OAuth2MetadataResponse(
|
return OAuth2MetadataResponse(
|
||||||
issuer = issuer,
|
issuer = issuer,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConstructorBinding
|
||||||
@ConstructorBinding
|
@ConstructorBinding
|
||||||
@ConfigurationProperties(prefix = "oauth2-metadata")
|
@ConfigurationProperties(prefix = "oauth2-metadata")
|
||||||
data class OAuth2MetadataProperties(
|
data class OAuth2MetadataProperties(
|
||||||
|
val issuer: String,
|
||||||
val tokenEndpoint: String?,
|
val tokenEndpoint: String?,
|
||||||
val responseTypesSupported: List<String> = emptyList(),
|
val responseTypesSupported: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.ComponentScan
|
import org.springframework.context.annotation.ComponentScan
|
||||||
import ru.touchin.auth.security.jwt.properties.AccessTokenPublicProperties
|
import ru.touchin.auth.security.jwt.properties.AccessTokenPublicProperties
|
||||||
import ru.touchin.auth.security.jwt.utils.JwtUtils.getKeySpec
|
import ru.touchin.auth.security.jwt.utils.JwtUtils.getKeySpec
|
||||||
|
import java.io.File
|
||||||
import java.security.KeyFactory
|
import java.security.KeyFactory
|
||||||
import java.security.interfaces.RSAPublicKey
|
import java.security.interfaces.RSAPublicKey
|
||||||
import java.security.spec.X509EncodedKeySpec
|
import java.security.spec.X509EncodedKeySpec
|
||||||
|
|
@ -20,7 +21,8 @@ class JwtConfiguration {
|
||||||
fun accessTokenPublicKey(
|
fun accessTokenPublicKey(
|
||||||
accessTokenPublicProperties: AccessTokenPublicProperties,
|
accessTokenPublicProperties: AccessTokenPublicProperties,
|
||||||
): RSAPublicKey {
|
): RSAPublicKey {
|
||||||
val keySpecX509 = getKeySpec(accessTokenPublicProperties.keyPair.public, ::X509EncodedKeySpec)
|
val publicKey = this.javaClass.classLoader.getResource(accessTokenPublicProperties.keyPair.public)!!.readText()
|
||||||
|
val keySpecX509 = getKeySpec(publicKey, ::X509EncodedKeySpec)
|
||||||
|
|
||||||
return keyFactory.generatePublic(keySpecX509) as RSAPublicKey
|
return keyFactory.generatePublic(keySpecX509) as RSAPublicKey
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package ru.touchin.auth.security.jwt.http.configurators
|
||||||
|
|
||||||
import org.springframework.core.annotation.Order
|
import org.springframework.core.annotation.Order
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
|
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import ru.touchin.common.spring.Ordered
|
import ru.touchin.common.spring.Ordered
|
||||||
import ru.touchin.common.spring.security.http.configurators.HttpSecurityConfigurator
|
import ru.touchin.common.spring.security.http.configurators.HttpSecurityConfigurator
|
||||||
|
|
@ -11,9 +12,10 @@ import ru.touchin.common.spring.security.http.configurators.HttpSecurityConfigur
|
||||||
class JwtHttpSecurityConfigurator : HttpSecurityConfigurator {
|
class JwtHttpSecurityConfigurator : HttpSecurityConfigurator {
|
||||||
|
|
||||||
override fun configure(http: HttpSecurity) {
|
override fun configure(http: HttpSecurity) {
|
||||||
http.oauth2ResourceServer {
|
val resolver = DefaultBearerTokenResolver()
|
||||||
it.jwt()
|
resolver.setAllowUriQueryParameter(true)
|
||||||
}
|
|
||||||
|
http.oauth2ResourceServer().bearerTokenResolver(resolver).jwt()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,5 @@ include("geoip-core")
|
||||||
include("user-agent")
|
include("user-agent")
|
||||||
include("smart-migration")
|
include("smart-migration")
|
||||||
include("telegram-bot-spring")
|
include("telegram-bot-spring")
|
||||||
|
include("spreadsheets")
|
||||||
|
include("spreadsheets-google")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
plugins {
|
||||||
|
id("kotlin")
|
||||||
|
id("kotlin-spring")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(project(":spreadsheets"))
|
||||||
|
|
||||||
|
implementation(project(":common"))
|
||||||
|
|
||||||
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||||
|
|
||||||
|
implementation("org.springframework.boot:spring-boot")
|
||||||
|
|
||||||
|
implementation("com.google.api-client:google-api-client")
|
||||||
|
implementation("com.google.apis:google-api-services-sheets")
|
||||||
|
implementation("com.google.auth:google-auth-library-oauth2-http")
|
||||||
|
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package ru.touchin.spreadsheets
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import ru.touchin.spreadsheets.google.configurations.GoogleSpreadSheetsConfiguration
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
@Import(value = [GoogleSpreadSheetsConfiguration::class])
|
||||||
|
annotation class EnableGoogleSpreadSheets
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package ru.touchin.spreadsheets.google.configurations
|
||||||
|
|
||||||
|
import com.google.api.client.http.javanet.NetHttpTransport
|
||||||
|
import com.google.api.client.json.gson.GsonFactory
|
||||||
|
import com.google.api.services.sheets.v4.Sheets
|
||||||
|
import com.google.api.services.sheets.v4.SheetsScopes
|
||||||
|
import com.google.auth.http.HttpCredentialsAdapter
|
||||||
|
import com.google.auth.oauth2.GoogleCredentials
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.ComponentScan
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import ru.touchin.spreadsheets.google.exceptions.GoogleCredentialsMissingException
|
||||||
|
import ru.touchin.spreadsheets.google.properties.GoogleSpreadsheetsProperties
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ComponentScan("ru.touchin.spreadsheets.google")
|
||||||
|
@ConfigurationPropertiesScan("ru.touchin.spreadsheets.google")
|
||||||
|
class GoogleSpreadSheetsConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun sheets(googleSheetsProperties: GoogleSpreadsheetsProperties): Sheets {
|
||||||
|
val transport = NetHttpTransport.Builder().build()
|
||||||
|
val jsonFactory = GsonFactory.getDefaultInstance()
|
||||||
|
val initializer = HttpCredentialsAdapter(
|
||||||
|
GoogleCredentials.fromStream(getCredentialsStream(googleSheetsProperties))
|
||||||
|
.createScoped(SheetsScopes.SPREADSHEETS)
|
||||||
|
.let { credentials ->
|
||||||
|
googleSheetsProperties.delegate
|
||||||
|
?.let { user ->
|
||||||
|
credentials.createDelegated(user)
|
||||||
|
}
|
||||||
|
?: credentials
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Sheets.Builder(transport, jsonFactory, initializer)
|
||||||
|
.setApplicationName(googleSheetsProperties.appName)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCredentialsStream(googleSheetsProperties: GoogleSpreadsheetsProperties): InputStream {
|
||||||
|
return googleSheetsProperties.credentialsPath
|
||||||
|
?.takeIf(String::isNotBlank)
|
||||||
|
?.let { File(it).inputStream() }
|
||||||
|
?: googleSheetsProperties.credentials?.byteInputStream()
|
||||||
|
?: throw GoogleCredentialsMissingException()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package ru.touchin.spreadsheets.google.enums
|
||||||
|
|
||||||
|
enum class Dimension(val value: String) {
|
||||||
|
Rows("ROWS"),
|
||||||
|
Columns("COLUMNS");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package ru.touchin.spreadsheets.google.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see <a href="https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption">Documentation</a>
|
||||||
|
*/
|
||||||
|
enum class ValueInputOption(val code: String) {
|
||||||
|
|
||||||
|
RAW("RAW"),
|
||||||
|
USER_ENTERED("USER_ENTERED"),
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package ru.touchin.spreadsheets.google.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see <a href="https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption">Documentation</a>
|
||||||
|
*/
|
||||||
|
enum class ValueRenderOption(val code: String) {
|
||||||
|
|
||||||
|
FORMATTED_VALUE("FORMATTED_VALUE"),
|
||||||
|
UNFORMATTED_VALUE("UNFORMATTED_VALUE"),
|
||||||
|
FORMULA("FORMULA"),
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package ru.touchin.spreadsheets.google.exceptions
|
||||||
|
|
||||||
|
import ru.touchin.common.exceptions.CommonException
|
||||||
|
|
||||||
|
class GoogleCredentialsMissingException : CommonException("Google credentials are not set")
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package ru.touchin.spreadsheets.google.exceptions
|
||||||
|
|
||||||
|
import ru.touchin.common.exceptions.CommonException
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SpreadsheetSource
|
||||||
|
|
||||||
|
class GoogleSheetMissingException private constructor(
|
||||||
|
val source: SpreadsheetSource,
|
||||||
|
message: String,
|
||||||
|
): CommonException(message) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(source: SpreadsheetSource) = GoogleSheetMissingException(
|
||||||
|
source = source,
|
||||||
|
message = "Missing sheet '${source.sheetId}' in spreadsheet '${source.spreadsheetId}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package ru.touchin.spreadsheets.google.exceptions
|
||||||
|
|
||||||
|
import ru.touchin.common.exceptions.CommonException
|
||||||
|
|
||||||
|
class InvalidGoogleSpreadsheetsSourceException(
|
||||||
|
url: String,
|
||||||
|
) : CommonException("Unable to parse source $url")
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package ru.touchin.spreadsheets.google.properties
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
import org.springframework.boot.context.properties.ConstructorBinding
|
||||||
|
|
||||||
|
@ConstructorBinding
|
||||||
|
@ConfigurationProperties(prefix = "spreadsheets.google")
|
||||||
|
data class GoogleSpreadsheetsProperties(
|
||||||
|
val appName: String,
|
||||||
|
val credentials: String? = null,
|
||||||
|
val credentialsPath: String? = null,
|
||||||
|
val delegate: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package ru.touchin.spreadsheets.google.services
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import ru.touchin.spreadsheets.google.exceptions.InvalidGoogleSpreadsheetsSourceException
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetsUrlService
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SpreadsheetSource
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class GoogleSheetsUrlServiceImpl : SpreadsheetsUrlService {
|
||||||
|
|
||||||
|
override fun parse(url: URL): SpreadsheetSource {
|
||||||
|
return parse(url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parse(url: String): SpreadsheetSource {
|
||||||
|
val groups = spreadSheetUrlRegex.find(url)?.groups
|
||||||
|
?: throw InvalidGoogleSpreadsheetsSourceException(url)
|
||||||
|
|
||||||
|
val spreadSheetId = groups["spreadsheetId"]?.value
|
||||||
|
?: throw InvalidGoogleSpreadsheetsSourceException(url)
|
||||||
|
|
||||||
|
val sheetId = groups["sheetId"]?.value?.toInt()
|
||||||
|
?: 0
|
||||||
|
|
||||||
|
return SpreadsheetSource(spreadsheetId = spreadSheetId, sheetId = sheetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
val spreadSheetUrlRegex = Regex(
|
||||||
|
"https://docs\\.google\\.com/spreadsheets/d/(?<spreadsheetId>[\\da-zA-Z-_]+)/edit(?:#gid=(?<sheetId>\\d+))?",
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package ru.touchin.spreadsheets.google.services
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.ObjectFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetBuilder
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetBuilderFactory
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class GoogleSpreadsheetBuilderFactoryImpl(
|
||||||
|
private val spreadsheetBuilderFactory: ObjectFactory<SpreadsheetBuilder>,
|
||||||
|
) : SpreadsheetBuilderFactory {
|
||||||
|
|
||||||
|
override fun create(): SpreadsheetBuilder {
|
||||||
|
return spreadsheetBuilderFactory.`object`
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package ru.touchin.spreadsheets.google.services
|
||||||
|
|
||||||
|
import com.google.api.services.sheets.v4.Sheets
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetBuilder
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetService
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetsUrlService
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class GoogleSpreadsheetBuilderImpl(
|
||||||
|
private val spreadsheetsUrlService: SpreadsheetsUrlService,
|
||||||
|
private val sheets: Sheets,
|
||||||
|
) : SpreadsheetBuilder {
|
||||||
|
|
||||||
|
private var sheetId: Int? = null
|
||||||
|
|
||||||
|
private var autoInit: Boolean = true
|
||||||
|
|
||||||
|
override fun setSheetId(sheetId: Int): SpreadsheetBuilder = this.also {
|
||||||
|
it.sheetId = sheetId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAutoInit(autoInit: Boolean): SpreadsheetBuilder = this.also {
|
||||||
|
it.autoInit = autoInit
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun build(url: URL): SpreadsheetService {
|
||||||
|
val source = spreadsheetsUrlService.parse(url)
|
||||||
|
val sheetId = this.sheetId ?: source.sheetId
|
||||||
|
|
||||||
|
return GoogleSpreadsheetServiceImpl(source.copy(sheetId = sheetId), sheets)
|
||||||
|
.also {
|
||||||
|
if (autoInit) {
|
||||||
|
it.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
package ru.touchin.spreadsheets.google.services
|
||||||
|
|
||||||
|
import com.google.api.services.sheets.v4.Sheets
|
||||||
|
import com.google.api.services.sheets.v4.model.BatchGetValuesByDataFilterRequest
|
||||||
|
import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest
|
||||||
|
import com.google.api.services.sheets.v4.model.CellData
|
||||||
|
import com.google.api.services.sheets.v4.model.DataFilter
|
||||||
|
import com.google.api.services.sheets.v4.model.DimensionRange
|
||||||
|
import com.google.api.services.sheets.v4.model.ExtendedValue
|
||||||
|
import com.google.api.services.sheets.v4.model.GridCoordinate
|
||||||
|
import com.google.api.services.sheets.v4.model.GridRange
|
||||||
|
import com.google.api.services.sheets.v4.model.InsertDimensionRequest
|
||||||
|
import com.google.api.services.sheets.v4.model.Request
|
||||||
|
import com.google.api.services.sheets.v4.model.RowData
|
||||||
|
import com.google.api.services.sheets.v4.model.UpdateCellsRequest
|
||||||
|
import ru.touchin.spreadsheets.google.enums.Dimension
|
||||||
|
import ru.touchin.spreadsheets.google.enums.ValueRenderOption
|
||||||
|
import ru.touchin.spreadsheets.google.exceptions.GoogleSheetMissingException
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetService
|
||||||
|
import ru.touchin.spreadsheets.services.dto.CellType
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetCell
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetHeader
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetRange
|
||||||
|
import ru.touchin.spreadsheets.services.dto.Spreadsheet
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SpreadsheetSource
|
||||||
|
import ru.touchin.spreadsheets.services.dto.cells.SheetStringCell
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
internal class GoogleSpreadsheetServiceImpl(
|
||||||
|
private val source: SpreadsheetSource,
|
||||||
|
private val sheets: Sheets,
|
||||||
|
) : SpreadsheetService {
|
||||||
|
|
||||||
|
private lateinit var spreadsheet: Spreadsheet
|
||||||
|
|
||||||
|
override fun init(): Spreadsheet {
|
||||||
|
val spreadsheet = sheets.spreadsheets()
|
||||||
|
.get(source.spreadsheetId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
val sheet = spreadsheet.sheets
|
||||||
|
.find { it.properties.sheetId == source.sheetId }
|
||||||
|
?: throw GoogleSheetMissingException.create(source)
|
||||||
|
|
||||||
|
val gridProperties = sheet.properties.gridProperties
|
||||||
|
|
||||||
|
return Spreadsheet(
|
||||||
|
rowCount = gridProperties.rowCount,
|
||||||
|
columnCount = gridProperties.columnCount,
|
||||||
|
locale = spreadsheet.properties.locale,
|
||||||
|
).also {
|
||||||
|
this.spreadsheet = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSheet(): Spreadsheet {
|
||||||
|
return spreadsheet.copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getHeader(): SheetHeader {
|
||||||
|
val values = getValues(source.headerRange)
|
||||||
|
.map { rows ->
|
||||||
|
rows.map { SheetStringCell(it.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return SheetHeader(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createHeader(header: SheetHeader) {
|
||||||
|
updateValues(source.headerRange, header.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getContentColumn(column: Int): List<Any> {
|
||||||
|
return getValues(
|
||||||
|
getContentRange().copy(
|
||||||
|
startColumnIndex = column,
|
||||||
|
endColumnIndex = column + 1,
|
||||||
|
),
|
||||||
|
dimension = Dimension.Columns,
|
||||||
|
)
|
||||||
|
.firstOrNull()
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRows(range: SheetRange, values: List<List<SheetCell>>) {
|
||||||
|
return updateValues(range, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateContentRows(startRowIndex: Int, values: List<List<SheetCell>>) {
|
||||||
|
updateRows(
|
||||||
|
range = SheetRange(
|
||||||
|
startColumnIndex = 0,
|
||||||
|
startRowIndex = startRowIndex + (source.headerRange.endRowIndex ?: 0),
|
||||||
|
),
|
||||||
|
values = values,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateValues(range: SheetRange, values: List<List<SheetCell>>) {
|
||||||
|
val maxColumnIndex = getMaxIndex(values, Dimension.Columns) + range.startColumnIndex
|
||||||
|
val maxRowIndex = getMaxIndex(values, Dimension.Rows) + range.startRowIndex
|
||||||
|
|
||||||
|
val requests = mutableListOf<Request>()
|
||||||
|
|
||||||
|
if (maxColumnIndex > spreadsheet.columnCount) {
|
||||||
|
requests.add(
|
||||||
|
createInsertRequest(
|
||||||
|
insertCount = maxColumnIndex - spreadsheet.columnCount,
|
||||||
|
dimension = Dimension.Columns,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxRowIndex > spreadsheet.rowCount) {
|
||||||
|
requests.add(
|
||||||
|
createInsertRequest(
|
||||||
|
insertCount = maxRowIndex - spreadsheet.rowCount,
|
||||||
|
dimension = Dimension.Rows,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.add(
|
||||||
|
createUpdateCellsRequest(range, values)
|
||||||
|
)
|
||||||
|
|
||||||
|
sheets.spreadsheets()
|
||||||
|
.batchUpdate(
|
||||||
|
source.spreadsheetId,
|
||||||
|
BatchUpdateSpreadsheetRequest().apply {
|
||||||
|
this.requests = requests
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
spreadsheet = spreadsheet.copy(
|
||||||
|
columnCount = max(spreadsheet.columnCount, maxColumnIndex),
|
||||||
|
rowCount = max(spreadsheet.rowCount, maxRowIndex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createUpdateCellsRequest(range: SheetRange, values: List<List<SheetCell>>): Request {
|
||||||
|
return Request().setUpdateCells(
|
||||||
|
UpdateCellsRequest().apply {
|
||||||
|
fields = "*"
|
||||||
|
start = GridCoordinate().apply {
|
||||||
|
sheetId = source.sheetId
|
||||||
|
columnIndex = range.startColumnIndex
|
||||||
|
rowIndex = range.startRowIndex
|
||||||
|
}
|
||||||
|
rows = values.map { rows ->
|
||||||
|
RowData().setValues(
|
||||||
|
rows.map { row ->
|
||||||
|
row.toCellData()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getContentRange(): SheetRange {
|
||||||
|
return SheetRange(
|
||||||
|
startColumnIndex = 0,
|
||||||
|
startRowIndex = source.headerRange.endRowIndex ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SheetCell.toCellData(): CellData {
|
||||||
|
val value = ExtendedValue()
|
||||||
|
|
||||||
|
when(type) {
|
||||||
|
CellType.String -> value.stringValue = this.value.toString()
|
||||||
|
CellType.Null -> value.stringValue = null
|
||||||
|
CellType.Date -> value.stringValue = this.value.toString()
|
||||||
|
CellType.DateTime -> value.stringValue = this.value.toString()
|
||||||
|
CellType.Price -> value.numberValue = (this.value as BigDecimal).toDouble()
|
||||||
|
CellType.Number -> value.numberValue = (this.value as Number).toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
return CellData().also { it.userEnteredValue = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createInsertRequest(insertCount: Int, dimension: Dimension): Request {
|
||||||
|
val startIndex = when(dimension) {
|
||||||
|
Dimension.Rows -> spreadsheet.rowCount - 1
|
||||||
|
Dimension.Columns -> spreadsheet.columnCount - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return Request().setInsertDimension(
|
||||||
|
InsertDimensionRequest()
|
||||||
|
.setRange(
|
||||||
|
DimensionRange()
|
||||||
|
.setSheetId(source.sheetId)
|
||||||
|
.setDimension(dimension.value)
|
||||||
|
.setStartIndex(startIndex)
|
||||||
|
.setEndIndex(startIndex + insertCount)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMaxIndex(values: List<List<SheetCell>>, dimension: Dimension): Int {
|
||||||
|
return when (dimension) {
|
||||||
|
Dimension.Rows -> values.size
|
||||||
|
Dimension.Columns -> values.maxOf { it.size }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getValues(range: SheetRange, dimension: Dimension = Dimension.Rows): List<List<Any>> {
|
||||||
|
val values = sheets.spreadsheets()
|
||||||
|
.values()
|
||||||
|
.batchGetByDataFilter(
|
||||||
|
source.spreadsheetId,
|
||||||
|
BatchGetValuesByDataFilterRequest().apply {
|
||||||
|
majorDimension = dimension.value
|
||||||
|
valueRenderOption = ValueRenderOption.FORMATTED_VALUE.code
|
||||||
|
dataFilters = listOf(
|
||||||
|
DataFilter().apply {
|
||||||
|
gridRange = range.toGridRange()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
.valueRanges
|
||||||
|
.first()
|
||||||
|
.valueRange
|
||||||
|
.getValues()
|
||||||
|
|
||||||
|
return values?.toList() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SheetRange.toGridRange(): GridRange {
|
||||||
|
return GridRange().also {
|
||||||
|
it.sheetId = source.sheetId
|
||||||
|
it.startColumnIndex = startColumnIndex
|
||||||
|
it.startRowIndex = startRowIndex
|
||||||
|
it.endColumnIndex = endColumnIndex
|
||||||
|
it.endRowIndex = endRowIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package ru.touchin.spreadsheets.google.utils
|
||||||
|
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
object GoogleSpreadSheetsFormulaParserUtil {
|
||||||
|
|
||||||
|
private const val HYPERLINK_REGEX_LINK_INDEX: Int = 1
|
||||||
|
private const val RUS_LOCALE = "ru_RU"
|
||||||
|
private const val DEFAULT_DIVIDER = ","
|
||||||
|
private const val RUS_DIVIDER = ";"
|
||||||
|
|
||||||
|
fun isFormulaHyperlink(jiraIssueLink: String, locale: String): Boolean {
|
||||||
|
return getHyperLinkRegexByLocale(locale).matches(jiraIssueLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun asFormulaHyperlink(url: URL, label: String, locale: String): String {
|
||||||
|
val divider = getFormulaeDividerByLocale(locale)
|
||||||
|
return "=HYPERLINK(\"$url\"$divider\"$label\")"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromFormulaHyperlink(hyperlink: String, locale: String): URL {
|
||||||
|
return getHyperLinkRegexByLocale(locale).matchEntire(hyperlink)
|
||||||
|
?.groupValues
|
||||||
|
?.get(HYPERLINK_REGEX_LINK_INDEX)
|
||||||
|
?.trim { it == '"' }
|
||||||
|
?.let { URL(it) }
|
||||||
|
?: throw IllegalStateException("Could not parse hyperlink \"$hyperlink\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getHyperLinkRegexByLocale(locale: String): Regex {
|
||||||
|
val divider = getFormulaeDividerByLocale(locale)
|
||||||
|
return Regex("=HYPERLINK\\((.*)$divider(.*)\\)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFormulaeDividerByLocale(locale: String): String {
|
||||||
|
return if (locale == RUS_LOCALE) {
|
||||||
|
RUS_DIVIDER
|
||||||
|
} else {
|
||||||
|
DEFAULT_DIVIDER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package ru.touchin.sheets.google
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringBootConfiguration
|
||||||
|
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.context.annotation.ComponentScan
|
||||||
|
|
||||||
|
@ComponentScan
|
||||||
|
@TestConfiguration
|
||||||
|
@SpringBootConfiguration
|
||||||
|
@EnableAutoConfiguration
|
||||||
|
class GoogleSheetsTestApplication
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package ru.touchin.sheets.google.services
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import ru.touchin.spreadsheets.google.exceptions.InvalidGoogleSpreadsheetsSourceException
|
||||||
|
import ru.touchin.spreadsheets.google.services.GoogleSheetsUrlServiceImpl
|
||||||
|
import ru.touchin.spreadsheets.services.SpreadsheetsUrlService
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SpreadsheetSource
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
classes = [
|
||||||
|
GoogleSheetsUrlServiceImpl::class,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
internal class GoogleSheetsUrlServiceImplTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var sheetsUrlService: SpreadsheetsUrlService
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldParseOk() {
|
||||||
|
val expectedSpreadsheetSource = SpreadsheetSource(
|
||||||
|
spreadsheetId = "1KgQTBmFq-yddGotA5PUd5SRkg-J3dtdULpRNz7uctAE",
|
||||||
|
sheetId = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
val url = "https://docs.google.com/spreadsheets/d/${expectedSpreadsheetSource.spreadsheetId}/edit#gid=${expectedSpreadsheetSource.sheetId}"
|
||||||
|
|
||||||
|
val text1 = """
|
||||||
|
Test
|
||||||
|
Test $url
|
||||||
|
ssss
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val actualSpreadsheetSource1 = sheetsUrlService.parse(text1)
|
||||||
|
|
||||||
|
assertEquals(expectedSpreadsheetSource, actualSpreadsheetSource1)
|
||||||
|
|
||||||
|
val text2 = """
|
||||||
|
Test ($url)
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val actualSpreadsheetSource2 = sheetsUrlService.parse(text2)
|
||||||
|
|
||||||
|
assertEquals(expectedSpreadsheetSource, actualSpreadsheetSource2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldParseOk2() {
|
||||||
|
val expectedSpreadsheetSource = SpreadsheetSource(
|
||||||
|
spreadsheetId = "1KgQTBmFq-yddGotA5PUd5SRkg-J3dtdULpRNz7uctAE",
|
||||||
|
sheetId = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
val text = """
|
||||||
|
Test
|
||||||
|
Test https://docs.google.com/spreadsheets/d/${expectedSpreadsheetSource.spreadsheetId}/edit Test
|
||||||
|
Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val actualSpreadsheetSource1 = sheetsUrlService.parse(text)
|
||||||
|
|
||||||
|
assertEquals(expectedSpreadsheetSource, actualSpreadsheetSource1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldThrowException() {
|
||||||
|
val text1 = """
|
||||||
|
Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
assertThrows(InvalidGoogleSpreadsheetsSourceException::class.java) {
|
||||||
|
sheetsUrlService.parse(text1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val text2 = """
|
||||||
|
Test (https://dcs.google.com/spreadsheets/d/1KgQTBmFq-yddGotA5PUd5SRkg-J3dtdULpRNz7uctAE/edit#gid=0)
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
assertThrows(InvalidGoogleSpreadsheetsSourceException::class.java) {
|
||||||
|
sheetsUrlService.parse(text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
val text3 = """
|
||||||
|
Test (https://dcs.google.com/spreadsheets/d/1KgQTBmFq-yddGotA5PUd5SRkg-J
|
||||||
|
3dtdULpRNz7uctAE/edit#gid=0)
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
assertThrows(InvalidGoogleSpreadsheetsSourceException::class.java) {
|
||||||
|
sheetsUrlService.parse(text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package ru.touchin.sheets.google.utils
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import ru.touchin.spreadsheets.google.utils.GoogleSpreadSheetsFormulaParserUtil
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class GoogleSheetsFormulaParserUtilTest {
|
||||||
|
|
||||||
|
private val url = URL("https://example.com")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isFormulaHyperlink_trueIfCorrectSyntax() {
|
||||||
|
val text = "=HYPERLINK(someurl;label)"
|
||||||
|
|
||||||
|
val result = GoogleSpreadSheetsFormulaParserUtil.isFormulaHyperlink(text, "ru_RU")
|
||||||
|
|
||||||
|
Assertions.assertTrue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isFormulaHyperlink_falseIfUrl() {
|
||||||
|
val result = GoogleSpreadSheetsFormulaParserUtil.isFormulaHyperlink(url.toString(), "ru_RU")
|
||||||
|
|
||||||
|
Assertions.assertFalse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun asFormulaHyperlink_correct() {
|
||||||
|
val label = "link"
|
||||||
|
|
||||||
|
val expected = "=HYPERLINK(\"$url\";\"$label\")"
|
||||||
|
val actual = GoogleSpreadSheetsFormulaParserUtil.asFormulaHyperlink(url, label, "ru_RU")
|
||||||
|
|
||||||
|
Assertions.assertEquals(
|
||||||
|
expected,
|
||||||
|
actual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromFormulaHyperlink_correct() {
|
||||||
|
val expected = url.toString()
|
||||||
|
val actual = GoogleSpreadSheetsFormulaParserUtil.fromFormulaHyperlink("=HYPERLINK(\"$url\";\"label\")", "ru_RU").toString()
|
||||||
|
|
||||||
|
|
||||||
|
Assertions.assertEquals(
|
||||||
|
expected,
|
||||||
|
actual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
plugins {
|
||||||
|
id("kotlin")
|
||||||
|
id("kotlin-spring")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package ru.touchin.spreadsheets.services
|
||||||
|
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
interface SpreadsheetBuilder {
|
||||||
|
|
||||||
|
fun setSheetId(sheetId: Int): SpreadsheetBuilder
|
||||||
|
fun setAutoInit(autoInit: Boolean): SpreadsheetBuilder
|
||||||
|
|
||||||
|
fun build(url: URL): SpreadsheetService
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package ru.touchin.spreadsheets.services
|
||||||
|
|
||||||
|
interface SpreadsheetBuilderFactory {
|
||||||
|
|
||||||
|
fun create(): SpreadsheetBuilder
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package ru.touchin.spreadsheets.services
|
||||||
|
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetCell
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetHeader
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetRange
|
||||||
|
import ru.touchin.spreadsheets.services.dto.Spreadsheet
|
||||||
|
|
||||||
|
interface SpreadsheetService {
|
||||||
|
|
||||||
|
fun init(): Spreadsheet
|
||||||
|
|
||||||
|
fun getSheet(): Spreadsheet
|
||||||
|
|
||||||
|
fun getHeader(): SheetHeader
|
||||||
|
|
||||||
|
fun createHeader(header: SheetHeader)
|
||||||
|
|
||||||
|
fun getContentColumn(column: Int): List<Any>
|
||||||
|
|
||||||
|
fun updateRows(range: SheetRange, values: List<List<SheetCell>>)
|
||||||
|
|
||||||
|
fun updateContentRows(startRowIndex: Int, values: List<List<SheetCell>>)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package ru.touchin.spreadsheets.services
|
||||||
|
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SpreadsheetSource
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
interface SpreadsheetsUrlService {
|
||||||
|
|
||||||
|
fun parse(url: URL): SpreadsheetSource
|
||||||
|
|
||||||
|
fun parse(url: String): SpreadsheetSource
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto
|
||||||
|
|
||||||
|
enum class CellType {
|
||||||
|
Null,
|
||||||
|
String,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Price,
|
||||||
|
Number,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto
|
||||||
|
|
||||||
|
interface SheetCell {
|
||||||
|
val value: Any
|
||||||
|
val type: CellType
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto
|
||||||
|
|
||||||
|
data class SheetHeader(
|
||||||
|
val values: List<List<SheetCell>>
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun hasHeader() = values.isNotEmpty()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto
|
||||||
|
|
||||||
|
data class SheetRange(
|
||||||
|
val startColumnIndex: Int,
|
||||||
|
val startRowIndex: Int,
|
||||||
|
val endColumnIndex: Int? = null,
|
||||||
|
val endRowIndex: Int? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto
|
||||||
|
|
||||||
|
data class Spreadsheet(
|
||||||
|
val rowCount: Int,
|
||||||
|
val columnCount: Int,
|
||||||
|
val locale: String,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto
|
||||||
|
|
||||||
|
data class SpreadsheetSource(
|
||||||
|
val spreadsheetId: String,
|
||||||
|
val sheetId: Int,
|
||||||
|
val headerRange: SheetRange = DEFAULT_HEADER_RANGE,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT_HEADER_RANGE = SheetRange(
|
||||||
|
startColumnIndex = 0,
|
||||||
|
startRowIndex = 0,
|
||||||
|
endRowIndex = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto.cells
|
||||||
|
|
||||||
|
import ru.touchin.spreadsheets.services.dto.CellType
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetCell
|
||||||
|
|
||||||
|
class SheetNullCell() : SheetCell {
|
||||||
|
|
||||||
|
override val value = 0
|
||||||
|
|
||||||
|
override val type = CellType.Null
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as SheetNullCell
|
||||||
|
|
||||||
|
if (value != other.value) return false
|
||||||
|
if (type != other.type) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = value
|
||||||
|
result = 31 * result + type.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto.cells
|
||||||
|
|
||||||
|
import ru.touchin.spreadsheets.services.dto.CellType
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetCell
|
||||||
|
|
||||||
|
data class SheetNumberCell(override val value: Number) : SheetCell {
|
||||||
|
|
||||||
|
override val type = CellType.Number
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package ru.touchin.spreadsheets.services.dto.cells
|
||||||
|
|
||||||
|
import ru.touchin.spreadsheets.services.dto.CellType
|
||||||
|
import ru.touchin.spreadsheets.services.dto.SheetCell
|
||||||
|
|
||||||
|
data class SheetStringCell(override val value: String) : SheetCell {
|
||||||
|
|
||||||
|
override val type = CellType.String
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api 'org.telegram:telegrambots-spring-boot-starter:6.3.0'
|
api 'org.telegram:telegrambots-spring-boot-starter'
|
||||||
|
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue