Compare commits

...

9 Commits

Author SHA1 Message Date
Alexander Buntakov b05b8f4c4a [security-resource-server] change token resolver 2023-04-27 22:37:45 +03:00
Alexander Buntakov e0e959a1ba fix auditable fields 2023-04-03 00:41:42 +03:00
Alexander Buntakov d24fa43b31 [jpa-test] increase postgres version 12 -> 15 2023-03-13 01:25:59 +03:00
Alexander Buntakov b07d975aff [exceptions] add CommonFormException 2023-03-11 19:10:35 +03:00
Alexander Buntakov 96689c6ba5 [security] replace key value to file 2023-03-08 13:44:24 +03:00
Alexander Buntakov 57ba9f36d9 add password encoder 2023-03-06 00:02:28 +03:00
Alexander Buntakov 5e7ff8ca5a add google sheets 2023-02-18 12:02:32 +03:00
Alexander Buntakov 0769a2afad add slow test annotation 2023-02-09 00:48:31 +03:00
Alexander Buntakov e86e88c27b fix ReposoitoryTest 2023-01-23 23:03:15 +03:00
48 changed files with 934 additions and 24 deletions

View File

@ -29,21 +29,13 @@ allprojects {
mavenCentral()
}
println("Enabling IDEA plugin in project ${project.name}...")
apply(plugin = "idea")
}
subprojects {
println("Enabling Kotlin JVM plugin in project ${project.name}...")
apply(plugin = "org.jetbrains.kotlin.jvm")
println("Enabling Kotlin Spring plugin in project ${project.name}...")
apply(plugin = "org.jetbrains.kotlin.plugin.spring")
println("Enabling Spring Boot Dependency Management in project ${project.name}...")
apply(plugin = "io.spring.dependency-management")
println("Enabling Detekt support in project ${project.name}...")
apply(plugin = "io.gitlab.arturbosch.detekt")
detekt {
@ -81,6 +73,7 @@ subprojects {
dependency("org.junit.jupiter:junit-jupiter-engine:5.4.2")
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("org.mockito:mockito-inline:3.11.0")
@ -97,6 +90,9 @@ subprojects {
dependency("software.amazon.awssdk:s3:2.10.11")
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")
}

View File

@ -6,6 +6,7 @@ import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.io.Serializable
import java.time.ZonedDateTime
import javax.persistence.Column
import javax.persistence.EntityListeners
import javax.persistence.MappedSuperclass
@ -14,6 +15,7 @@ import javax.persistence.MappedSuperclass
abstract class BaseEntity : Serializable {
@CreatedDate
@Column(updatable = false)
lateinit var createdAt: ZonedDateTime
@LastModifiedDate

View File

@ -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)
}

View File

@ -4,10 +4,18 @@ package ru.touchin.common.spring.test.jpa.repository
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.DisabledIf
import ru.touchin.common.spring.test.annotations.SlowTest
@ActiveProfiles("test", "test-slow")
@SlowTest
@DataJpaTest
@Import(RepositoryTestConfiguration::class)
@DisabledIf(
expression = """
#{systemProperties['tests.slow.enabled'] != null
? systemProperties['tests.slow.enabled'].toLowerCase().contains('false')
: false}
"""
)
annotation class RepositoryTest

View File

@ -4,6 +4,7 @@ package ru.touchin.common.spring.test.jpa.repository
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
@ -18,6 +19,7 @@ import javax.sql.DataSource
@TestConfiguration
@EnableJpaAuditingExtra
@ComponentScan
@ConditionalOnProperty(name = ["tests.slow.enabled"], matchIfMissing = true)
class RepositoryTestConfiguration {
// запуск и остановка контейнера по lifecycle-событиями компонента (1)

View File

@ -15,4 +15,4 @@ spring:
tests:
slow:
db:
imageName: "postgres:12"
imageName: "postgres:15"

View File

@ -5,6 +5,12 @@ import org.springframework.context.annotation.Profile
import org.springframework.test.context.junit.jupiter.DisabledIf
@Profile(value = ["test", "test-slow"])
@DisabledIf("\${tests.slow.disabled:false}")
@Target(allowedTargets = [AnnotationTarget.FUNCTION, AnnotationTarget.CLASS])
@DisabledIf(
expression = """
#{systemProperties['tests.slow.enabled'] != null
? systemProperties['tests.slow.enabled'].toLowerCase().contains('false')
: false}
"""
)
annotation class SlowTest

View File

@ -0,0 +1,4 @@
@file:Suppress("unused")
package ru.touchin.common.exceptions
open class CommonFormException(description: String?) : CommonException(description)

View File

@ -2,4 +2,10 @@ package ru.touchin.auth.core.scope.dto
data class Scope(
val name: String
)
) {
companion object {
val user = Scope("user")
}
}

View File

@ -2,35 +2,41 @@ package ru.touchin.auth.core.tokens.access.config
import com.auth0.jwt.algorithms.Algorithm
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.Configuration
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Import
import ru.touchin.auth.core.tokens.access.properties.AccessTokenProperties
import ru.touchin.auth.security.jwt.configurations.JwtConfiguration
import ru.touchin.auth.security.jwt.utils.JwtUtils.getKeySpec
import java.io.File
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
@Configuration
@Import(JwtConfiguration::class)
@ComponentScan("ru.touchin.auth.core.tokens.access")
@ConfigurationPropertiesScan("ru.touchin.auth.core.tokens.access")
class AccessTokenBeanConfig(private val accessTokenProperties: AccessTokenProperties) {
@Bean
fun accessTokenSigningAlgorithm(
@Qualifier("accessTokenPublicKey")
accessTokenPublicKey: RSAPublicKey
accessTokenPublicKey: RSAPublicKey,
@Qualifier("accessTokenPrivateKey")
accessTokenPrivateKey: RSAPrivateKey,
): Algorithm {
return Algorithm.RSA256(
accessTokenPublicKey,
accessTokenPrivateKey()
accessTokenPrivateKey,
)
}
@Bean("accessTokenPrivateKey")
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
}

View File

@ -4,7 +4,6 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
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.response.OAuth2MetadataResponse
import java.net.URI
@ -15,13 +14,12 @@ import java.net.URI
@RestController
@RequestMapping("/.well-known/oauth-authorization-server")
class OAuth2MetadataController(
private val accessTokenProperties: AccessTokenProperties,
private val oauth2MetadataProperties: OAuth2MetadataProperties,
) {
@GetMapping
fun metadata(): OAuth2MetadataResponse {
val issuer = accessTokenProperties.issuer.let(URI::create)
val issuer = oauth2MetadataProperties.issuer.let(URI::create)
return OAuth2MetadataResponse(
issuer = issuer,

View File

@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConstructorBinding
@ConstructorBinding
@ConfigurationProperties(prefix = "oauth2-metadata")
data class OAuth2MetadataProperties(
val issuer: String,
val tokenEndpoint: String?,
val responseTypesSupported: List<String> = emptyList(),
)

View File

@ -5,6 +5,7 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import ru.touchin.auth.security.jwt.properties.AccessTokenPublicProperties
import ru.touchin.auth.security.jwt.utils.JwtUtils.getKeySpec
import java.io.File
import java.security.KeyFactory
import java.security.interfaces.RSAPublicKey
import java.security.spec.X509EncodedKeySpec
@ -20,7 +21,8 @@ class JwtConfiguration {
fun accessTokenPublicKey(
accessTokenPublicProperties: AccessTokenPublicProperties,
): 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
}

View File

@ -2,6 +2,7 @@ package ru.touchin.auth.security.jwt.http.configurators
import org.springframework.core.annotation.Order
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver
import org.springframework.stereotype.Component
import ru.touchin.common.spring.Ordered
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 {
override fun configure(http: HttpSecurity) {
http.oauth2ResourceServer {
it.jwt()
}
val resolver = DefaultBearerTokenResolver()
resolver.setAllowUriQueryParameter(true)
http.oauth2ResourceServer().bearerTokenResolver(resolver).jwt()
}
}

View File

@ -62,3 +62,5 @@ include("geoip-core")
include("user-agent")
include("smart-migration")
include("telegram-bot-spring")
include("spreadsheets")
include("spreadsheets-google")

View File

@ -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")
}

View File

@ -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

View File

@ -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()
}
}

View File

@ -0,0 +1,6 @@
package ru.touchin.spreadsheets.google.enums
enum class Dimension(val value: String) {
Rows("ROWS"),
Columns("COLUMNS");
}

View File

@ -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"),
}

View File

@ -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"),
}

View File

@ -0,0 +1,5 @@
package ru.touchin.spreadsheets.google.exceptions
import ru.touchin.common.exceptions.CommonException
class GoogleCredentialsMissingException : CommonException("Google credentials are not set")

View File

@ -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}",
)
}
}

View File

@ -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")

View File

@ -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,
)

View File

@ -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+))?",
)
}
}

View File

@ -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`
}
}

View File

@ -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()
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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
)
}
}

View File

@ -0,0 +1,8 @@
plugins {
id("kotlin")
id("kotlin-spring")
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}

View File

@ -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
}

View File

@ -0,0 +1,7 @@
package ru.touchin.spreadsheets.services
interface SpreadsheetBuilderFactory {
fun create(): SpreadsheetBuilder
}

View File

@ -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>>)
}

View File

@ -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
}

View File

@ -0,0 +1,10 @@
package ru.touchin.spreadsheets.services.dto
enum class CellType {
Null,
String,
Date,
DateTime,
Price,
Number,
}

View File

@ -0,0 +1,6 @@
package ru.touchin.spreadsheets.services.dto
interface SheetCell {
val value: Any
val type: CellType
}

View File

@ -0,0 +1,9 @@
package ru.touchin.spreadsheets.services.dto
data class SheetHeader(
val values: List<List<SheetCell>>
) {
fun hasHeader() = values.isNotEmpty()
}

View File

@ -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,
)

View File

@ -0,0 +1,7 @@
package ru.touchin.spreadsheets.services.dto
data class Spreadsheet(
val rowCount: Int,
val columnCount: Int,
val locale: String,
)

View File

@ -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,
)
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -4,7 +4,7 @@ plugins {
}
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 'com.fasterxml.jackson.core:jackson-databind'