full reborn

This commit is contained in:
InsanusMokrassar 2021-11-24 13:52:27 +06:00
parent 0ac6b0a4df
commit 6a6a197041
246 changed files with 4327 additions and 6952 deletions

View File

@ -8,6 +8,15 @@ jobs:
- uses: actions/setup-java@v1
with:
java-version: 1.8
- name: Fix android 31.0.0 dx
continue-on-error: true
run: cd /usr/local/lib/android/sdk/build-tools/31.0.0/ && mv d8 dx && cd lib && mv d8.jar dx.jar
- name: Rewrite version
run: |
branch="`echo "${{ github.ref }}" | grep -o "[^/]*$"`"
cat gradle.properties | sed -e "s/^version=\([0-9\.]*\)/version=\1-branch_$branch-build${{ github.run_number }}/" > gradle.properties.tmp
rm gradle.properties
mv gradle.properties.tmp gradle.properties
- name: prebuild
run: ./gradlew clean build
- name: Publish package
@ -15,4 +24,3 @@ jobs:
env:
GITHUBPACKAGES_USER: ${{ secrets.GITHUBPACKAGES_USER }}
GITHUBPACKAGES_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
additional_version: "-build${{ github.run_number }}"

201
LICENSE
View File

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

14
README.md Normal file
View File

@ -0,0 +1,14 @@
## Структура проекта
* Features - набор **законченных** фич проекта. Считается, что любая фича, находящаяся в мастере может быть добавлена в
клиент и использована в нем. Исключением является `common` - это набор вещей, используемых везде. В подпунктах представлены
части, на которые *обычно* разделяется фича
* Common - общая для фичи часть. Тут, как правило, хранятся конвенции путей для сетевых соединений, общие типы и пр.
* Server - часть, включаемая в сервер для подключения фичи. Обычно содержит работу с бд, определение модулей сервера и пр.
* Client - часть с клиентским кодом. В большинстве своём включает работу с сервером, MVVM часть (View при этом должны
находиться в платформенной части, если их нельзя вынести в сommon часть клиента)
* Client - итоговый клиент. На момент написания этой доки (`Пн окт 25 12:56:41 +06 2021`) предполагается два варианта:
* Мультиплатформенный проект со сборкой каждого таргета. Скорее всего, не будет использован в силу сложности настройки
части клиентов (например, андроид)
* Мультимодульный проект
* Server - пока что JVM-only модуль, включающий все необходимые для сервера фичи

View File

@ -1,6 +1,5 @@
buildscript {
repositories {
jcenter()
google()
mavenCentral()
mavenLocal()
@ -8,11 +7,10 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.2'
classpath 'com.android.tools.build:gradle:4.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath "com.getkeepsafe.dexcount:dexcount-gradle-plugin:$dexcount_version"
classpath "com.github.breadmoirai:github-release:$github_release_plugin_version"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:$dokka_version"
}
}
@ -20,12 +18,10 @@ buildscript {
allprojects {
repositories {
mavenLocal()
jcenter()
mavenCentral()
google()
maven { url "https://kotlin.bintray.com/kotlinx" }
}
}
apply from: "./extensions.gradle"
apply from: "./github_release.gradle"
// apply from: "./github_release.gradle"

View File

@ -1,20 +0,0 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
id "com.android.library"
}
apply from: "$mppProjectWithSerializationPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
implementation kotlin('stdlib')
api project(":postssystem.business_cases.post_creating.common")
api project(":postssystem.core.ktor.client")
}
}
}
}

View File

@ -1,26 +0,0 @@
package dev.inmo.postssystem.business_cases.post_creating.client
import dev.inmo.micro_utils.ktor.client.*
import dev.inmo.postssystem.business_cases.post_creating.server.*
import dev.inmo.postssystem.core.content.Content
import dev.inmo.postssystem.core.post.RegisteredPost
import dev.inmo.postssystem.core.publishing.TriggerId
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
import io.ktor.client.HttpClient
import kotlinx.serialization.builtins.nullable
class PostCreatingClientCase(
private val baseUrl: String,
private val unifiedRequester: UnifiedRequester,
private val rootRoute: String? = postCreatingRootRoute
) : PostCreatingCase {
private val realBaseUrl = rootRoute ?.let { "$baseUrl/$rootRoute" } ?: baseUrl
override suspend fun createPost(
postContent: List<Content>,
triggerId: TriggerId?
): RegisteredPost? = unifiedRequester.unipost(
buildStandardUrl(realBaseUrl, postCreatingCreatePostRoute),
BodyPair(PostCreatingCreatePostModel.serializer(), PostCreatingCreatePostModel(postContent, triggerId)),
RegisteredPost.serializer().nullable
)
}

View File

@ -1 +0,0 @@
<manifest package="dev.inmo.postssystem.business_cases.post_creating.client"/>

View File

@ -1,26 +0,0 @@
package dev.inmo.postssystem.business_cases.post_creating.server
import dev.inmo.micro_utils.repos.set
import dev.inmo.postssystem.core.content.Content
import dev.inmo.postssystem.core.content.api.ContentRepo
import dev.inmo.postssystem.core.post.*
import dev.inmo.postssystem.core.post.repo.PostsRepo
import dev.inmo.postssystem.core.publishing.*
import dev.inmo.postssystem.core.publishing.repos.WriteTriggersToPostsRepo
class BusinessPostCreatingCase(
private val postsRepo: PostsRepo,
private val contentRepo: ContentRepo,
private val postsTriggersToPostsRepo: WriteTriggersToPostsRepo
) : PostCreatingCase {
override suspend fun createPost(postContent: List<Content>, triggerId: TriggerId?): RegisteredPost? {
val content = contentRepo.create(postContent)
val post = postsRepo.createPost(SimplePost(content.map { it.id })) ?: return null
triggerId ?.let {
postsTriggersToPostsRepo.set(post.id, triggerId)
}
return post
}
}

View File

@ -1,19 +0,0 @@
package dev.inmo.postssystem.business_cases.post_creating.server
import dev.inmo.postssystem.core.content.Content
import dev.inmo.postssystem.core.post.RegisteredPost
import dev.inmo.postssystem.core.publishing.TriggerId
import kotlinx.serialization.Serializable
@Serializable
data class PostCreatingCreatePostModel(
val postContent: List<Content>,
val triggerId: TriggerId?
)
interface PostCreatingCase {
suspend fun createPost(
postContent: List<Content>,
triggerId: TriggerId? = null
): RegisteredPost?
}

View File

@ -1,5 +0,0 @@
package dev.inmo.postssystem.business_cases.post_creating.server
const val postCreatingRootRoute = "postCreating"
const val postCreatingCreatePostRoute = "createPost"

View File

@ -1 +0,0 @@
<manifest package="dev.inmo.postssystem.business_cases.post_creating.common"/>

View File

@ -1,24 +0,0 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
}
apply from: "$mppJavaProjectPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api project(":postssystem.business_cases.post_creating.common")
api project(":postssystem.core.ktor.server")
}
}
jvmTest {
dependencies {
implementation "org.xerial:sqlite-jdbc:$test_sqlite_version"
implementation "org.jetbrains.kotlin:kotlin-test"
implementation "org.jetbrains.kotlin:kotlin-test-junit"
}
}
}
}

View File

@ -1,33 +0,0 @@
package dev.inmo.postssystem.business_cases.post_creating.server
import dev.inmo.micro_utils.ktor.server.*
import dev.inmo.postssystem.core.post.RegisteredPost
import io.ktor.application.call
import io.ktor.routing.*
import kotlinx.serialization.builtins.nullable
private inline fun Route.configurePostCreatingRoutes(
origin: PostCreatingCase,
unifiedRouter: UnifiedRouter
) {
post(postCreatingCreatePostRoute) {
unifiedRouter.apply {
val model = uniload(PostCreatingCreatePostModel.serializer())
unianswer(
RegisteredPost.serializer().nullable,
origin.createPost(model.postContent, model.triggerId)
)
}
}
}
fun Route.configurePostCreatingRoutes(
origin: PostCreatingCase,
unifiedRouter: UnifiedRouter,
subroute: String? = postCreatingRootRoute
) {
subroute ?.also {
route(subroute) { configurePostCreatingRoutes(origin, unifiedRouter) }
} ?: configurePostCreatingRoutes(origin, unifiedRouter)
}

41
client/build.gradle Normal file
View File

@ -0,0 +1,41 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
id "com.android.library"
}
apply from: "$mppProjectWithSerializationPresetPath"
kotlin {
js(IR) {
binaries.executable()
}
sourceSets {
commonMain {
dependencies {
api project(":postssystem.features.common.client")
api project(":postssystem.features.status.client")
api project(":postssystem.features.files.client")
api project(":postssystem.features.users.client")
api project(":postssystem.features.auth.client")
api project(":postssystem.features.roles.client")
api project(":postssystem.features.roles.manager.client")
api "dev.inmo:micro_utils.fsm.common:$microutils_version"
api "dev.inmo:micro_utils.fsm.repos.common:$microutils_version"
api "dev.inmo:micro_utils.crypto:$microutils_version"
}
}
jvmMain {
dependencies {
api "io.ktor:ktor-client-apache:$ktor_version"
}
}
jsMain {
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-html:$kotlinx_html_version"
}
}
}
}

View File

@ -0,0 +1,12 @@
package dev.inmo.postssystem.client
import dev.inmo.micro_utils.pagination.utils.getAllByWithNextPaging
import dev.inmo.micro_utils.repos.KeyValueRepo
class DBDropper(
private val repo: KeyValueRepo<String, Any>
) {
suspend operator fun invoke() {
repo.unset(repo.getAllByWithNextPaging { keys(it) })
}
}

View File

@ -0,0 +1,105 @@
package dev.inmo.postssystem.client
import dev.inmo.postssystem.client.ui.fsm.*
import dev.inmo.postssystem.features.auth.client.installClientAuthenticator
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.files.client.ClientFilesStorage
import dev.inmo.postssystem.features.files.common.storage.FilesStorage
import dev.inmo.postssystem.features.roles.common.UserRole
import dev.inmo.postssystem.features.roles.common.UsersRolesStorage
import dev.inmo.postssystem.features.roles.client.ClientUsersRolesStorage
import dev.inmo.postssystem.features.roles.manager.common.RolesManagerRoleSerializer
import dev.inmo.postssystem.features.users.client.UsersStorageKtorClient
import dev.inmo.postssystem.features.users.common.ReadUsersStorage
import dev.inmo.postssystem.features.users.common.User
import dev.inmo.micro_utils.common.Either
import dev.inmo.micro_utils.coroutines.LinkedSupervisorScope
import dev.inmo.micro_utils.fsm.common.StatesMachine
import dev.inmo.micro_utils.fsm.common.dsl.FSMBuilder
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManagerRepo
import dev.inmo.micro_utils.ktor.client.UnifiedRequester
import dev.inmo.micro_utils.ktor.common.standardKtorSerialFormat
import dev.inmo.micro_utils.repos.KeyValueRepo
import io.ktor.client.HttpClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.BinaryFormat
import kotlinx.serialization.StringFormat
import kotlinx.serialization.json.Json
import org.koin.core.Koin
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.core.qualifier.*
import org.koin.core.scope.Scope
import org.koin.dsl.module
val UIScopeQualifier = StringQualifier("CoroutineScopeUI")
val SettingsQualifier = StringQualifier("Settings")
val UserRolesQualifier = StringQualifier("UserRoles")
private val DBDropperQualifier = StringQualifier("DBDropper")
private val FSMHandlersBuilderQualifier = StringQualifier("FSMHandlersBuilder")
val defaultSerialFormat = Json {
ignoreUnknownKeys = true
}
/**
* Entrypoint for getting [org.koin.core.Koin] DI for the client
*
* @param repoFactory Factory for creating of [DefaultStatesManagerRepo] for [dev.inmo.postssystem.client.ui.fsm.UIFSM]
*/
fun baseKoin(
defaultScope: CoroutineScope,
settingsFactory: Scope.() -> KeyValueRepo<String, Any>,
repoFactory: Scope.() -> DefaultStatesManagerRepo<UIFSMState>,
handlersSetter: Pair<Scope, FSMBuilder<UIFSMState>>.() -> Unit
): Koin = startKoin {
modules(
module {
single<StringFormat> { defaultSerialFormat }
single(SettingsQualifier) { settingsFactory() }
single<DBDropper>(DBDropperQualifier) { DBDropper(get(SettingsQualifier)) }
single(FSMHandlersBuilderQualifier) { handlersSetter }
single { repoFactory() }
single { defaultScope }
single(UIScopeQualifier) { get<CoroutineScope>().LinkedSupervisorScope(Dispatchers.Main) }
single<StatesMachine<UIFSMState>>(UIFSMQualifier) { UIFSM(get()) { (this@single to this@UIFSM).apply(get(
FSMHandlersBuilderQualifier
)) } }
}
)
}.koin.apply {
loadModules(
listOf(
module { single<Koin> { this@apply } }
)
)
RolesManagerRoleSerializer // Just to activate it in JS client
}
fun getAuthorizedFeaturesDIModule(
serverUrl: String,
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
onAuthKeyUpdated: suspend (AuthTokenInfo) -> Unit,
onUserRetrieved: suspend (User?) -> Unit,
onAuthKeyInvalidated: suspend () -> Unit
): Module {
val serverUrlQualifier = StringQualifier("serverUrl")
val credsQualifier = StringQualifier("creds")
return module {
single(createdAtStart = true) {
HttpClient {
installClientAuthenticator(serverUrl, get(), get(credsQualifier), onAuthKeyUpdated, onUserRetrieved, onAuthKeyInvalidated)
}
}
single(credsQualifier) { initialAuthKey }
single(serverUrlQualifier) { serverUrl }
single<BinaryFormat> { standardKtorSerialFormat }
single { UnifiedRequester(get(), get()) }
single<FilesStorage> { ClientFilesStorage(get(serverUrlQualifier), get(), get()) }
single<ReadUsersStorage> { UsersStorageKtorClient(get(serverUrlQualifier), get()) }
single<UsersRolesStorage<UserRole>> { ClientUsersRolesStorage(get(serverUrlQualifier), get(), UserRole.serializer()) }
}
}

View File

@ -0,0 +1,8 @@
package dev.inmo.postssystem.client.settings
import dev.inmo.postssystem.client.settings.auth.AuthSettings
data class DefaultSettings(
override val authSettings: AuthSettings
) : Settings

View File

@ -0,0 +1,12 @@
package dev.inmo.postssystem.client.settings
import dev.inmo.postssystem.client.settings.auth.AuthSettings
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.module.Module
interface Settings {
val authSettings: AuthSettings
val authorizedDIModule: StateFlow<Module?>
get() = authSettings.authorizedDIModule
}

View File

@ -0,0 +1,18 @@
package dev.inmo.postssystem.client.settings.auth
import dev.inmo.postssystem.features.auth.client.ui.AuthUIError
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.roles.common.UserRole
import dev.inmo.postssystem.features.users.common.User
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.module.Module
interface AuthSettings {
val authorizedDIModule: StateFlow<Module?>
val user: StateFlow<User?>
val userRoles: StateFlow<List<UserRole>>
val loadingJob: Job
suspend fun auth(serverUrl: String, creds: AuthCreds): AuthUIError?
}

View File

@ -0,0 +1,118 @@
package dev.inmo.postssystem.client.settings.auth
import dev.inmo.postssystem.client.DBDropper
import dev.inmo.postssystem.client.getAuthorizedFeaturesDIModule
import dev.inmo.postssystem.features.auth.client.AuthUnavailableException
import dev.inmo.postssystem.features.auth.client.ui.*
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.roles.common.UserRole
import dev.inmo.postssystem.features.roles.common.UsersRolesStorage
import dev.inmo.postssystem.features.status.client.StatusFeatureClient
import dev.inmo.postssystem.features.users.common.User
import dev.inmo.micro_utils.common.Either
import dev.inmo.micro_utils.common.either
import dev.inmo.micro_utils.coroutines.plus
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
import dev.inmo.micro_utils.repos.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.koin.core.Koin
import org.koin.core.module.Module
data class DefaultAuthSettings(
private val repo: KeyValueRepo<String, Any>,
private val scope: CoroutineScope,
private val koin: Koin,
private val dbDropper: DBDropper
) : AuthSettings {
private val _authorizedDIModule = MutableStateFlow<Module?>(null)
override val authorizedDIModule: StateFlow<Module?> = _authorizedDIModule.asStateFlow()
private val _user = MutableStateFlow<User?>(null)
override val user: StateFlow<User?> = _user.asStateFlow()
private val _userRoles = MutableStateFlow<List<UserRole>>(emptyList())
override val userRoles: StateFlow<List<UserRole>> = _userRoles.asStateFlow()
private suspend fun getCurrentServerURL() = repo.get(SERVER_URL_FIELD) as? String
private suspend fun getCurrentUsername() = repo.get(USERNAME_FIELD) as? String
private suspend fun getCurrentToken() = repo.get(TOKEN_FIELD) as? AuthTokenInfo
override val loadingJob: Job = scope.launch {
val serverUrl = getCurrentServerURL() ?: return@launch
val token = getCurrentToken() ?: return@launch
updateModule(serverUrl, token.either())
}
val rolesUpdatingJob = (user + authorizedDIModule).subscribeSafelyWithoutExceptions(scope) {
val user = user.value
if (user == null || authorizedDIModule.value == null) {
_userRoles.value = emptyList()
} else {
_userRoles.value = koin.get<UsersRolesStorage<UserRole>>().getRoles(user.id)
}
println(user)
println(userRoles.value)
}
override suspend fun auth(serverUrl: String, creds: AuthCreds): AuthUIError? {
return runCatching {
if (getCurrentServerURL() != serverUrl || getCurrentUsername() != creds.username.string) {
dbDropper()
}
repo.set(SERVER_URL_FIELD, serverUrl)
repo.set(USERNAME_FIELD, creds.username.string)
repo.unset(TOKEN_FIELD)
updateModule(serverUrl, creds.either())
}.onFailure {
it.printStackTrace()
}.getOrThrow()
}
private suspend fun updateModule(
serverUrl: String,
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
): AuthUIError? {
val currentModule = authorizedDIModule.value
val newModule = getAuthorizedFeaturesDIModule(
serverUrl,
initialAuthKey,
{
repo.set(TOKEN_FIELD, it)
},
{
_user.value = it
}
) {
repo.unset(SERVER_URL_FIELD, USERNAME_FIELD, TOKEN_FIELD)
_authorizedDIModule.value = null
_user.value = null
throw AuthUnavailableException
}
currentModule ?.let { koin.unloadModules(listOf(currentModule)) }
koin.loadModules(listOf(newModule))
val statusFeature = koin.get<StatusFeatureClient>()
val serverAvailable = statusFeature.checkServerStatus()
val authCorrect = serverAvailable && runCatching {
statusFeature.checkServerStatusWithAuth()
}.getOrElse { false }
if (!serverAvailable && !authCorrect) {
koin.unloadModules(listOf(newModule))
currentModule ?.let { koin.loadModules(listOf(currentModule)) }
}
return when {
!serverAvailable -> ServerUnavailableAuthUIError
!authCorrect -> AuthIncorrectAuthUIError
else -> {
_authorizedDIModule.value = newModule
null
}
}
}
companion object {
private const val SERVER_URL_FIELD = "AuthServerURL"
private const val USERNAME_FIELD = "AuthUsername"
private const val TOKEN_FIELD = "AuthToken"
}
}

View File

@ -0,0 +1,36 @@
package dev.inmo.postssystem.client.ui
import dev.inmo.postssystem.client.settings.auth.AuthSettings
import dev.inmo.postssystem.features.auth.client.ui.*
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.common.common.AbstractUIModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class DefaultAuthUIModel(
private val scope: CoroutineScope,
private val authSettings: AuthSettings
) : AbstractUIModel<AuthUIState>(LoadingAuthUIState), AuthUIModel {
init {
scope.launch {
_currentState.value = LoadingAuthUIState
authSettings.loadingJob.join()
if (authSettings.authorizedDIModule.value == null) {
_currentState.value = DefaultInitAuthUIState
} else {
_currentState.value = AuthorizedAuthUIState
}
}
}
override suspend fun initAuth(serverUrl: String, creds: AuthCreds) {
_currentState.value = LoadingAuthUIState
val authError = authSettings.auth(serverUrl, creds)
if (authError == null) {
_currentState.value = AuthorizedAuthUIState
} else {
_currentState.value = InitAuthUIState(authError)
}
}
}

View File

@ -0,0 +1,17 @@
package dev.inmo.postssystem.client.ui.fsm
import dev.inmo.micro_utils.fsm.common.dsl.FSMBuilder
import dev.inmo.micro_utils.fsm.common.dsl.buildFSM
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManager
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManagerRepo
import org.koin.core.qualifier.StringQualifier
val UIFSMQualifier = StringQualifier("UIFSM")
fun UIFSM(
repo: DefaultStatesManagerRepo<UIFSMState>,
handlersSetter: FSMBuilder<UIFSMState>.() -> Unit
) = buildFSM<UIFSMState> {
statesManager = DefaultStatesManager(repo)
handlersSetter()
}

View File

@ -0,0 +1,27 @@
package dev.inmo.postssystem.client.ui.fsm
import dev.inmo.postssystem.features.auth.client.AuthUnavailableException
import dev.inmo.micro_utils.fsm.common.*
interface UIFSMHandler<T : UIFSMState> : StatesHandler<T, UIFSMState> {
suspend fun StatesMachine<in UIFSMState>.safeHandleState(state: T): UIFSMState?
override suspend fun StatesMachine<in UIFSMState>.handleState(state: T): UIFSMState? {
return runCatching {
safeHandleState(state).also(::println)
}.getOrElse {
errorToNextStep(state, it) ?.let { return it } ?: throw it
}.also(::println)
}
suspend fun errorToNextStep(
currentState: T,
e: Throwable
): UIFSMState? = when (e) {
is AuthUnavailableException -> if (currentState is AuthUIFSMState) {
currentState
} else {
AuthUIFSMState(currentState)
}
else -> null
}
}

View File

@ -0,0 +1,24 @@
package dev.inmo.postssystem.client.ui.fsm
import dev.inmo.micro_utils.fsm.common.State
import dev.inmo.micro_utils.serialization.typed_serializer.TypedSerializer
import kotlinx.serialization.*
@Serializable(UIFSMStateSerializer::class)
sealed interface UIFSMState : State {
val from: UIFSMState?
get() = null
override val context: String
get() = "main"
}
object UIFSMStateSerializer : KSerializer<UIFSMState> by TypedSerializer(
"auth" to AuthUIFSMState.serializer(),
)
@Serializable
data class AuthUIFSMState(
override val from: UIFSMState? = null,
override val context: String = "main"
) : UIFSMState
val DefaultAuthUIFSMState = AuthUIFSMState()

View File

@ -0,0 +1,92 @@
package dev.inmo.postssystem.client
import dev.inmo.postssystem.client.fsm.ui.*
import dev.inmo.postssystem.client.ui.*
import dev.inmo.postssystem.client.ui.fsm.*
import dev.inmo.postssystem.client.ui.fsm.UIFSMStateSerializer
import dev.inmo.postssystem.features.auth.client.ui.AuthUIModel
import dev.inmo.postssystem.features.auth.client.ui.AuthUIViewModel
import dev.inmo.postssystem.features.auth.common.AuthTokenInfo
import dev.inmo.micro_utils.coroutines.ContextSafelyExceptionHandler
import dev.inmo.micro_utils.repos.mappers.withMapper
import dev.inmo.micro_utils.serialization.typed_serializer.TypedSerializer
import kotlinx.browser.document
import kotlinx.browser.localStorage
import kotlinx.coroutines.*
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.serializer
import org.koin.core.Koin
import org.koin.core.context.loadKoinModules
import org.koin.dsl.module
import org.w3c.dom.HTMLElement
val defaultTypedSerializer = TypedSerializer<Any>(
"AuthTokenInfo" to AuthTokenInfo.serializer(),
"String" to String.serializer(),
"Int" to Int.serializer(),
"Long" to Long.serializer(),
"Short" to Short.serializer(),
"Byte" to Byte.serializer(),
"Float" to Float.serializer(),
"Double" to Double.serializer(),
"UIFSMState" to UIFSMStateSerializer
)
fun baseKoin(): Koin {
val anyToString: suspend Any.() -> String = {
defaultSerialFormat.encodeToString(
defaultTypedSerializer,
this
)
}
return baseKoin(
CoroutineScope(
Dispatchers.Default +
ContextSafelyExceptionHandler { it.printStackTrace() } +
CoroutineExceptionHandler { _, it -> it.printStackTrace() }
),
{
CookiesKeyValueRepo.withMapper<String, Any, String, String>(
{ this },
{
runCatching {
anyToString()
}.getOrElse {
if (it is NoSuchElementException) {
val name = this::class.simpleName!!
defaultTypedSerializer.include(name, serializer())
anyToString()
} else {
throw it
}
}
},
{ this },
{
defaultSerialFormat.decodeFromString(
defaultTypedSerializer,
this
)
}
)
},
{
OneStateUIFSMStatesRepo(get(), localStorage)
}
) {
first.apply {
second.apply {
loadKoinModules(
module {
factory { document.getElementById("main") as HTMLElement }
factory<AuthUIModel> { DefaultAuthUIModel(get(), get()) }
factory { AuthUIViewModel(get()) }
factory { AuthView(get(), get(UIScopeQualifier)) }
}
)
strictlyOn<AuthUIFSMState>(get<AuthView>())
}
}
}
}

View File

@ -0,0 +1,14 @@
package dev.inmo.postssystem.client
import dev.inmo.postssystem.client.ui.fsm.UIFSMQualifier
import dev.inmo.postssystem.client.ui.fsm.UIFSMState
import dev.inmo.micro_utils.fsm.common.StatesMachine
import kotlinx.browser.window
fun main() {
window.addEventListener("load", {
val koin = baseKoin()
val uiStatesMachine = koin.get<StatesMachine<UIFSMState>>(UIFSMQualifier)
uiStatesMachine.start(koin.get())
})
}

View File

@ -0,0 +1,63 @@
package dev.inmo.postssystem.client
import dev.inmo.postssystem.client.ui.fsm.*
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManagerRepo
import kotlinx.serialization.StringFormat
import org.w3c.dom.*
class OneStateUIFSMStatesRepo(
private val serialFormat: StringFormat,
private val storage: Storage,
private val initialState: UIFSMState = DefaultAuthUIFSMState
) : DefaultStatesManagerRepo<UIFSMState> {
private val String.storageKey
get() = "${FSMStateSettingsFieldPrefix}$this"
private val String.UIFSMState
get() = runCatching {
serialFormat.decodeFromString(UIFSMStateSerializer, this)
}.onFailure { it.printStackTrace() }.getOrNull()
init {
if (states().isEmpty()) {
setState(initialState)
}
}
private fun setState(state: UIFSMState) {
storage[state.context.storageKey] = serialFormat.encodeToString(UIFSMStateSerializer, state)
}
override suspend fun getContextState(context: Any): UIFSMState? {
return when (context) {
is String -> storage[context.storageKey] ?.UIFSMState ?: return DefaultAuthUIFSMState
else -> null
}
}
override suspend fun contains(context: Any): Boolean = when (context) {
is String -> storage.get(context) ?.UIFSMState != null
else -> super.contains(context)
}
private fun states(): List<UIFSMState> = storage.iterator().asSequence().mapNotNull { (k, v) ->
if (k.startsWith(FSMStateSettingsFieldPrefix)) {
v.UIFSMState
} else {
null
}
}.toList()
override suspend fun getStates(): List<UIFSMState> = states()
override suspend fun removeState(state: UIFSMState) {
storage.removeItem((state.context as? String) ?.storageKey ?: return)
}
override suspend fun set(state: UIFSMState) {
setState(state)
}
companion object {
private const val FSMStateSettingsFieldPrefix = "UIFSMState_"
}
}

View File

@ -0,0 +1,82 @@
package dev.inmo.postssystem.client
import dev.inmo.micro_utils.pagination.Pagination
import dev.inmo.micro_utils.pagination.PaginationResult
import dev.inmo.micro_utils.pagination.utils.paginate
import dev.inmo.micro_utils.pagination.utils.reverse
import dev.inmo.micro_utils.repos.KeyValueRepo
import kotlinx.browser.localStorage
import kotlinx.coroutines.flow.*
import org.w3c.dom.get
import org.w3c.dom.set
object CookiesKeyValueRepo : KeyValueRepo<String, String> {
private val _onNewValue = MutableSharedFlow<Pair<String, String>>()
private val _onValueRemoved = MutableSharedFlow<String>()
override val onNewValue: Flow<Pair<String, String>> = _onNewValue.asSharedFlow()
override val onValueRemoved: Flow<String> = _onValueRemoved.asSharedFlow()
override suspend fun contains(key: String): Boolean = localStorage.iterator().asSequence().any { it.first == key }
override suspend fun count(): Long = localStorage.length.toLong()
override suspend fun get(k: String): String? = localStorage[k]
override suspend fun keys(
v: String,
pagination: Pagination,
reversed: Boolean
): PaginationResult<String> = localStorage.iterator().asSequence().mapNotNull {
if (it.second == v) it.first else null
}.toList().let {
it.paginate(
if (reversed) {
pagination.reverse(it.count())
} else {
pagination
}
)
}
override suspend fun keys(
pagination: Pagination,
reversed: Boolean
): PaginationResult<String> = localStorage.iterator().asSequence().map { it.first }.toList().let {
it.paginate(
if (reversed) {
pagination.reverse(it.count())
} else {
pagination
}
)
}
override suspend fun values(
pagination: Pagination,
reversed: Boolean
): PaginationResult<String> = localStorage.iterator().asSequence().map { it.second }.toList().let {
it.paginate(
if (reversed) {
pagination.reverse(it.count())
} else {
pagination
}
)
}
override suspend fun set(toSet: Map<String, String>) {
toSet.forEach { (k, v) ->
localStorage[k] = v
_onNewValue.emit(k to v)
}
}
override suspend fun unset(toUnset: List<String>) {
toUnset.forEach {
localStorage[it] ?.let { _ ->
localStorage.removeItem(it)
_onValueRemoved.emit(it)
}
}
}
}

View File

@ -0,0 +1,19 @@
package dev.inmo.postssystem.client
import org.w3c.dom.Storage
import org.w3c.dom.get
class StorageIterator(private val storage: Storage) : Iterator<Pair<String, String>> {
private var index = 0
override fun hasNext(): Boolean = index < storage.length
override fun next(): Pair<String, String> {
val k = storage.key(index) ?: error("Key for index $index was not found")
val v = storage[k] ?: error("Key for index $index was not found")
index++
return k to v
}
}
operator fun Storage.iterator() = StorageIterator(this)

View File

@ -0,0 +1,120 @@
package dev.inmo.postssystem.client.fsm.ui
import dev.inmo.postssystem.client.ui.fsm.*
import dev.inmo.postssystem.client.utils.HTMLViewContainer
import dev.inmo.postssystem.features.auth.client.ui.*
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
import dev.inmo.micro_utils.fsm.common.StatesMachine
import kotlinx.browser.document
import kotlinx.coroutines.*
import kotlinx.dom.clear
import kotlinx.html.*
import kotlinx.html.dom.append
import kotlinx.html.js.form
import kotlinx.html.js.onClickFunction
import org.w3c.dom.*
class AuthView(
private val viewModel: AuthUIViewModel,
private val uiScope: CoroutineScope
) : JSView<AuthUIFSMState>() {
private val usernameInput
get() = document.getElementById("authUsername") as? HTMLInputElement
private val passwordInput
get() = document.getElementById("authPassword") as? HTMLInputElement
private val authButton
get() = document.getElementById("authButton")
private val errorBadge
get() = document.getElementById("errorBadge") as? HTMLElement
private val progressBarDiv
get() = document.getElementById("progressBar") as? HTMLDivElement
override suspend fun StatesMachine<in UIFSMState>.safeHandleState(
htmlElement: HTMLElement,
container: HTMLViewContainer,
state: AuthUIFSMState
): UIFSMState? {
val completion = CompletableDeferred<UIFSMState?>()
htmlElement.clear()
htmlElement.append {
form(classes = "vertical_container") {
div(classes = "mdl-textfield mdl-js-textfield mdl-textfield--floating-label") {
input(type = InputType.text, classes = "mdl-textfield__input") {
id = "authUsername"
}
label(classes = "mdl-textfield__label") {
+"Имя пользователя"
}
}
div(classes = "mdl-textfield mdl-js-textfield mdl-textfield--floating-label") {
input(type = InputType.password, classes = "mdl-textfield__input") {
id = "authPassword"
}
label(classes = "mdl-textfield__label") {
+"Пароль"
}
}
div(classes = "mdl-progress mdl-js-progress") {
id = "progressBar"
}
span(classes = "material-icons mdl-badge mdl-badge--overlap gone") {
id = "errorBadge"
attributes["data-badge"] = "!"
}
button(classes = "mdl-button mdl-js-button mdl-button--raised") {
+"Авторизоваться"
id = "authButton"
onClickFunction = {
it.preventDefault()
val serverUrl = document.location ?.run { "$hostname:$port" }
val username = usernameInput ?.value
val password = passwordInput ?.value
if (serverUrl != null && username != null && password != null) {
uiScope.launchSafelyWithoutExceptions { viewModel.initAuth(serverUrl, username, password) }
}
}
}
}
}
val viewJob = viewModel.currentState.subscribeSafelyWithoutExceptions(uiScope) {
when (it) {
is InitAuthUIState -> {
usernameInput ?.removeAttribute("disabled")
passwordInput ?.removeAttribute("disabled")
authButton ?.removeAttribute("disabled")
errorBadge ?.apply {
when (it.showError) {
ServerUnavailableAuthUIError -> {
classList.remove("gone")
innerText = "Сервер недоступен"
}
AuthIncorrectAuthUIError -> {
classList.remove("gone")
innerText = "Данные некорректны"
}
null -> classList.add("gone")
}
}
progressBarDiv ?.classList ?.add("gone")
}
LoadingAuthUIState -> {
usernameInput ?.setAttribute("disabled", "")
passwordInput ?.setAttribute("disabled", "")
authButton ?.setAttribute("disabled", "")
errorBadge ?.classList ?.add("gone")
progressBarDiv ?.classList ?.remove("gone")
}
AuthorizedAuthUIState -> {
htmlElement.clear()
completion.complete(state.from)
}
}
}
return completion.await().also {
viewJob.cancel()
}
}
}

View File

@ -0,0 +1,7 @@
package dev.inmo.postssystem.client.fsm.ui
import kotlinx.browser.document
import org.w3c.dom.Element
val mainContainer: Element
get() = document.getElementById("main")!!

View File

@ -0,0 +1,21 @@
package dev.inmo.postssystem.client.fsm.ui
import dev.inmo.postssystem.client.ui.fsm.UIFSMHandler
import dev.inmo.postssystem.client.ui.fsm.UIFSMState
import dev.inmo.postssystem.client.utils.HTMLViewContainer
import dev.inmo.micro_utils.fsm.common.StatesMachine
import org.w3c.dom.HTMLElement
abstract class JSView<T : UIFSMState> : UIFSMHandler<T> {
open suspend fun StatesMachine<in UIFSMState>.safeHandleState(
htmlElement: HTMLElement,
container: HTMLViewContainer,
state: T
): UIFSMState? = null
override suspend fun StatesMachine<in UIFSMState>.safeHandleState(state: T): UIFSMState? {
return HTMLViewContainer.from(state.context) ?.let {
safeHandleState(it.htmlElement ?: return null, it, state)
}
}
}

View File

@ -0,0 +1,20 @@
package dev.inmo.postssystem.client.fsm.ui.defaults
import dev.inmo.postssystem.client.ui.fsm.UIFSMState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.html.TagConsumer
import kotlinx.html.js.button
import kotlinx.html.js.onClickFunction
import org.w3c.dom.HTMLElement
fun TagConsumer<HTMLElement>.addBackButton(
completableDeferred: CompletableDeferred<UIFSMState>,
stateToBack: UIFSMState
) {
button {
+"Назад"
onClickFunction = {
completableDeferred.complete(stateToBack)
}
}
}

View File

@ -0,0 +1,64 @@
package dev.inmo.postssystem.client.utils
import com.benasher44.uuid.uuid4
import kotlinx.browser.document
import kotlinx.html.*
import kotlinx.html.dom.append
import kotlinx.html.js.*
import org.w3c.dom.*
object DialogHelper {
fun createOneFieldDialog(
title: String,
hint: String,
doneButtonText: String,
closeButtonText: String,
onClose: () -> Unit,
onSubmit: (String) -> Unit
): HTMLDialogElement {
lateinit var dialogElement: HTMLDialogElement
(document.getElementsByTagName("body").item(0) as? HTMLBodyElement) ?.append {
dialogElement = dialog("mdl-dialog") {
h4("mdl-dialog__title") {
+title
}
val id = "form_${uuid4()}_text"
div(classes = "mdl-dialog__content") {
form("#") {
div("mdl-textfield mdl-js-textfield mdl-textfield--floating-label") {
input(InputType.text, classes = "mdl-textfield__input") {
this.id = id
}
label(classes = "mdl-textfield__label") {
+hint
attributes["for"] = id
}
}
}
}
div(classes = "mdl-dialog__actions mdl-dialog__actions--full-width") {
button(classes = "mdl-button", type = ButtonType.button) {
+doneButtonText
onClickFunction = {
it.preventDefault()
val input = document.getElementById(id) as? HTMLInputElement
input ?.value ?.let {
onSubmit(it)
}
}
}
button(classes = "mdl-button", type = ButtonType.button) {
+closeButtonText
onClickFunction = {
it.preventDefault()
onClose()
}
}
}
}
}
return dialogElement
}
}

View File

@ -0,0 +1,18 @@
package dev.inmo.postssystem.client.utils
import dev.inmo.postssystem.features.files.common.FullFileInfo
import dev.inmo.micro_utils.common.toArrayBuffer
import kotlinx.browser.document
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.url.URL
import org.w3c.files.Blob
fun triggerDownloadFile(fullFileInfo: FullFileInfo) {
val hiddenElement = document.createElement("a") as HTMLAnchorElement
val url = URL.createObjectURL(Blob(arrayOf(fullFileInfo.byteArrayAllocator().toArrayBuffer())))
hiddenElement.href = url
hiddenElement.target = "_blank"
hiddenElement.download = fullFileInfo.name.name
hiddenElement.click()
}

View File

@ -0,0 +1,134 @@
package dev.inmo.postssystem.client.utils
import kotlinx.browser.document
import kotlinx.dom.clear
import kotlinx.html.dom.append
import kotlinx.html.id
import kotlinx.html.js.*
import org.w3c.dom.HTMLElement
import org.w3c.dom.events.Event
object HTMLViewsConstants {
val mainContainer: MainHTMLViewContainer = MainHTMLViewContainer
val processesContainer: DrawerHTMLViewContainer = DrawerHTMLViewContainer
val toolsContainerId: ToolsHTMLViewContainer = ToolsHTMLViewContainer
}
sealed interface HTMLViewContainer {
val htmlElement: HTMLElement?
get() = document.getElementById(id) as? HTMLElement
val id: String
fun setIsLoading() {
htmlElement ?.apply {
clear()
append {
div("mdl-spinner mdl-js-spinner is-active")
}
}
}
companion object {
fun from(elementId: String) = when (elementId) {
MainHTMLViewContainer.id -> MainHTMLViewContainer
DrawerHTMLViewContainer.id -> DrawerHTMLViewContainer
ToolsHTMLViewContainer.id -> ToolsHTMLViewContainer
else -> null
}
}
}
object MainHTMLViewContainer : HTMLViewContainer {
override val id: String
get() = "main"
}
object DrawerHTMLViewContainer : HTMLViewContainer {
data class DrawerAddButtonInfo(
val text: String,
val onAddButtonClick: (Event) -> Unit,
)
override val id: String
get() = "drawer"
private val titleElement: HTMLElement?
get() = (document.getElementById("drawerTitle") ?:let {
htmlElement ?.append {
span("mdl-layout-title") {
id = "drawerTitle"
}
} ?.first()
}) as? HTMLElement
var title: String?
get() = titleElement ?.textContent
set(value) {
if (value == null) {
titleElement ?.classList ?.add("gone")
} else {
val element = titleElement ?: return
element.textContent = value
element.classList.remove("gone")
}
}
private val contentElement
get() = (document.getElementById("drawerContent") ?:let {
htmlElement ?.append {
nav("mdl-navigation") {
id = "drawerContent"
}
} ?.first()
}) as? HTMLElement
fun <T> setListContent(
title: String?,
data: Iterable<T>,
getText: (T) -> String?,
addButtonInfo: DrawerAddButtonInfo? = null,
onClick: (T) -> Unit
) {
this.title = title
val contentElement = contentElement ?: return
contentElement.clear()
contentElement.append {
fun hideDrawer() {
// Emulate clicking for hiding of drawer
(document.getElementsByClassName("mdl-layout__obfuscator").item(0) as? HTMLElement) ?.click()
}
data.forEach {
val elementTitle = getText(it) ?: return@forEach
div("mdl-navigation__link") {
+elementTitle
onClickFunction = { _ ->
onClick(it)
hideDrawer()
}
}
}
if (addButtonInfo != null) {
button(classes = "mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--accent") {
+addButtonInfo.text
onClickFunction = {
addButtonInfo.onAddButtonClick(it)
hideDrawer()
}
}
}
}
}
override fun setIsLoading() {
contentElement ?.apply {
clear()
append {
div("mdl-spinner mdl-js-spinner is-active")
}
}
}
}
object ToolsHTMLViewContainer : HTMLViewContainer {
override val id: String
get() = "tools"
}

View File

@ -0,0 +1,41 @@
package dev.inmo.postssystem.client.utils
import dev.inmo.postssystem.features.files.common.FullFileInfo
import dev.inmo.micro_utils.common.*
import dev.inmo.micro_utils.mime_types.KnownMimeTypes
import dev.inmo.micro_utils.mime_types.findBuiltinMimeType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.khronos.webgl.ArrayBuffer
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.Event
import org.w3c.files.FileReader
import org.w3c.files.get
fun uploadFileCallbackForHTMLInputChange(
output: MutableStateFlow<FullFileInfo?>,
scope: CoroutineScope
): (Event) -> Unit = {
(it.target as? HTMLInputElement) ?.apply {
files ?.also { files ->
files[0] ?.also { file ->
scope.launch {
val reader: FileReader = FileReader()
reader.onload = {
val bytes = ((it.target.asDynamic()).result as ArrayBuffer).toByteArray()
output.value = FullFileInfo(
FileName(file.name),
findBuiltinMimeType(file.type) ?: KnownMimeTypes.Any,
bytes.asAllocator
)
Unit
}
reader.readAsArrayBuffer(file)
}
}
}
}
}

View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>PostsSystem</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" type="text/css">
<link rel="stylesheet" href="styles/material.min.css" type="text/css">
<link rel="stylesheet" href="styles/containers.css" type="text/css">
<link rel="stylesheet" href="styles/visibility.css" type="text/css">
</head>
<body>
<!-- Always shows a header, even in smaller screens. -->
<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
<header class="mdl-layout__header">
<div class="mdl-layout__header-row">
<!-- Title -->
<span class="mdl-layout-title">Posts System</span>
<!-- Add spacer, to align navigation to the right -->
<div class="mdl-layout-spacer"></div>
<!-- Navigation. We hide it in small screens. -->
<nav id="tools" class="mdl-navigation mdl-layout--large-screen-only"></nav>
</div>
</header>
<main class="mdl-layout__content">
<div id="main" class="page-content"></div>
</main>
</div>
<script type="application/javascript" defer src="js/material.min.js"></script>
<script type="application/javascript" src="postssystem.client.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,5 @@
.vertical_container {
display: flex;
flex-direction: column;
align-items: center;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
.gone {
display: none;
}

View File

@ -0,0 +1,35 @@
package dev.inmo.postssystem.client
import dev.inmo.postssystem.features.users.common.ReadUsersStorage
import dev.inmo.micro_utils.repos.pagination.getAll
import org.koin.core.context.startKoin
fun readLine(suggestionText: String): String {
while (true) {
println(suggestionText)
readLine() ?.let { return it }
}
}
suspend fun main(args: Array<String>) {
val serverUrl = readLine("Server url:")
val login = readLine("Username:")
val password = readLine("Password:")
val koin = startKoin {
// modules(getAuthorizedFeaturesDIModule(serverUrl, AuthCreds(Username(login), password)))
}.koin
while (true) {
val chosen = readLine(
"""
Choose action:
1. Show server users
""".trimIndent()
).toIntOrNull()
when (chosen) {
1 -> println(koin.get<ReadUsersStorage>().getAll { getByPagination(it) })
else -> println("Sorry, I didn't understand")
}
}
}

View File

@ -0,0 +1 @@
<manifest package="dev.inmo.postssystem.client"/>

View File

@ -1,7 +0,0 @@
package dev.inmo.postssystem.core
import com.soywiz.klock.DateTime
typealias UnixMillis = Double
val MIN_DATE = DateTime(Double.MIN_VALUE)
val MAX_DATE = DateTime(Double.MAX_VALUE)

View File

@ -1,9 +0,0 @@
package dev.inmo.postssystem.core
import com.benasher44.uuid.uuid4
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.post.PostId
fun generateId() = uuid4().toString()
fun generatePostId(): PostId = generateId()
fun generateContentId(): ContentId = generateId()

View File

@ -1,41 +0,0 @@
package dev.inmo.postssystem.core.content
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.*
typealias ContentId = String
/**
* Content which is planned to be registered in database
*/
interface Content
/**
* That is a content which in fact just a link to another content. It would be useful in case when user wish to reuse
* some content
*/
@Serializable
data class OtherContentLinkContent(
val otherId: ContentId
) : Content
fun SerializersModuleBuilder.includeContentsSerializers(
block: PolymorphicModuleBuilder<Content>.() -> Unit
) {
polymorphic(Content::class) {
subclass(OtherContentLinkContent::class, OtherContentLinkContent.serializer())
block()
}
}
/**
* Content which is already registered in database. Using its [id] you can retrieve all known
* [dev.inmo.postssystem.core.post.RegisteredPost]s by using
* [dev.inmo.postssystem.core.post.repo.ReadPostsRepo.getPostsByContent]
*/
@Serializable
data class RegisteredContent(
val id: ContentId,
val content: Content
)

View File

@ -1,6 +0,0 @@
package dev.inmo.postssystem.core.content.api
import dev.inmo.micro_utils.repos.StandardCRUDRepo
import dev.inmo.postssystem.core.content.*
interface ContentRepo : ReadContentRepo, WriteContentRepo, StandardCRUDRepo<RegisteredContent, ContentId, Content>

View File

@ -1,13 +0,0 @@
package dev.inmo.postssystem.core.content.api
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.content.RegisteredContent
import dev.inmo.micro_utils.pagination.Pagination
import dev.inmo.micro_utils.pagination.PaginationResult
import dev.inmo.micro_utils.repos.ReadStandardCRUDRepo
import dev.inmo.micro_utils.repos.pagination.getAll
/**
* Simple read API by different properties of [dev.inmo.postssystem.core.content.Content].
*/
interface ReadContentRepo : ReadStandardCRUDRepo<RegisteredContent, ContentId>

View File

@ -1,7 +0,0 @@
package dev.inmo.postssystem.core.content.api
import dev.inmo.micro_utils.repos.WriteStandardCRUDRepo
import dev.inmo.postssystem.core.content.*
import kotlinx.coroutines.flow.Flow
interface WriteContentRepo : WriteStandardCRUDRepo<RegisteredContent, ContentId, Content>

View File

@ -1,117 +0,0 @@
package dev.inmo.postssystem.core.content.api.business
import dev.inmo.micro_utils.pagination.*
import dev.inmo.micro_utils.repos.UpdatedValuePair
import dev.inmo.postssystem.core.content.*
import dev.inmo.postssystem.core.content.api.*
import dev.inmo.postssystem.core.generateContentId
import kotlinx.coroutines.flow.*
interface BusinessContentRepoContentAdapter {
val type: AdapterType
suspend fun storeContent(contentId: ContentId, content: Content): Boolean
suspend fun getContent(contentId: ContentId): Content?
suspend fun removeContent(contentId: ContentId)
}
class BusinessReadContentRepo(
adapters: List<BusinessContentRepoContentAdapter>,
private val helperRepo: BusinessContentRepoReadHelper,
) : ReadContentRepo {
private val adaptersMap: Map<String, BusinessContentRepoContentAdapter> = adapters.map {
it.type to it
}.toMap()
override suspend fun contains(id: ContentId): Boolean = helperRepo.contains(id)
override suspend fun count(): Long = helperRepo.count()
override suspend fun getById(id: ContentId): RegisteredContent? = helperRepo.getType(id) ?.let {
adaptersMap[it] ?.getContent(id) ?.let { content ->
RegisteredContent(id, content)
}
}
override suspend fun getByPagination(
pagination: Pagination
): PaginationResult<RegisteredContent> = helperRepo.getKeysByPagination(
pagination
).let {
it.results.mapNotNull {
getById(it)
}.createPaginationResult(
it,
count()
)
}
}
class BusinessWriteContentRepo(
private val adapters: List<BusinessContentRepoContentAdapter>,
private val helperRepo: BusinessContentRepoHelper
) : WriteContentRepo {
private val adaptersMap = adapters.map { it.type to it }.toMap()
private val _deletedObjectsIdsFlow = MutableSharedFlow<ContentId>()
override val deletedObjectsIdsFlow: Flow<ContentId> = _deletedObjectsIdsFlow.asSharedFlow()
private val _newObjectsFlow = MutableSharedFlow<RegisteredContent>()
override val newObjectsFlow: Flow<RegisteredContent> = _newObjectsFlow.asSharedFlow()
private val _updatedObjectsFlow = MutableSharedFlow<RegisteredContent>()
override val updatedObjectsFlow: Flow<RegisteredContent> = _updatedObjectsFlow.asSharedFlow()
override suspend fun create(values: List<Content>): List<RegisteredContent> {
return values.mapNotNull { content ->
if (content is OtherContentLinkContent) {
adapters.forEach {
val existsContent = it.getContent(content.otherId)
if (existsContent != null) {
return@mapNotNull RegisteredContent(
content.otherId,
existsContent
)
}
}
}
val contentId = generateContentId()
val adapter = adapters.firstOrNull { it.storeContent(contentId, content) } ?: return@mapNotNull null
if (!helperRepo.saveType(contentId, adapter.type)) {
adapter.removeContent(contentId)
}
RegisteredContent(contentId, content).also { _newObjectsFlow.emit(it) }
}
}
override suspend fun deleteById(ids: List<ContentId>) {
ids.forEach { contentId ->
adaptersMap[helperRepo.getType(contentId)] ?.removeContent(contentId) ?: adapters.forEach {
it.removeContent(contentId)
}
helperRepo.deleteContentId(contentId)
_deletedObjectsIdsFlow.emit(contentId)
}
}
override suspend fun update(id: ContentId, value: Content): RegisteredContent? {
adaptersMap[helperRepo.getType(id)] ?.removeContent(id) ?: adapters.forEach {
it.removeContent(id)
}
adapters.firstOrNull { it.storeContent(id, value) } ?: return null
return RegisteredContent(id, value).also { _updatedObjectsFlow.emit(it) }
}
override suspend fun update(values: List<UpdatedValuePair<ContentId, Content>>): List<RegisteredContent> {
return values.mapNotNull {
update(it.first, it.second)
}
}
}
class BusinessContentRepo(
adapters: List<BusinessContentRepoContentAdapter>,
helperRepo: BusinessContentRepoHelper
) : ContentRepo, ReadContentRepo by BusinessReadContentRepo(
adapters,
helperRepo
), WriteContentRepo by BusinessWriteContentRepo(
adapters,
helperRepo
)

View File

@ -1,59 +0,0 @@
package dev.inmo.postssystem.core.content.api.business
import dev.inmo.micro_utils.pagination.Pagination
import dev.inmo.micro_utils.pagination.PaginationResult
import dev.inmo.micro_utils.repos.*
import dev.inmo.postssystem.core.content.ContentId
typealias AdapterType = String
interface BusinessContentRepoReadHelper : Repo {
suspend fun getKeysByPagination(pagination: Pagination): PaginationResult<ContentId>
suspend fun contains(contentId: ContentId): Boolean
suspend fun getType(contentId: ContentId): AdapterType?
suspend fun count(): Long
}
interface BusinessContentRepoWriteHelper : Repo {
suspend fun deleteContentId(contentId: ContentId)
suspend fun saveType(contentId: ContentId, type: AdapterType): Boolean
}
interface BusinessContentRepoHelper : BusinessContentRepoReadHelper, BusinessContentRepoWriteHelper
class KeyValueBusinessContentRepoReadHelper(
private val keyValueRepo: ReadStandardKeyValueRepo<ContentId, AdapterType>
) : BusinessContentRepoReadHelper {
override suspend fun getKeysByPagination(pagination: Pagination): PaginationResult<ContentId> = keyValueRepo.keys(pagination)
override suspend fun contains(contentId: ContentId): Boolean = keyValueRepo.contains(contentId)
override suspend fun getType(contentId: ContentId): AdapterType? = keyValueRepo.get(contentId)
override suspend fun count(): Long = keyValueRepo.count()
}
class KeyValueBusinessContentRepoWriteHelper(
private val keyValueRepo: WriteStandardKeyValueRepo<ContentId, AdapterType>
) : BusinessContentRepoWriteHelper {
override suspend fun deleteContentId(contentId: ContentId) { keyValueRepo.unset(contentId) }
override suspend fun saveType(contentId: ContentId, type: AdapterType): Boolean {
keyValueRepo.set(contentId, type)
return true
}
}
class KeyValueBusinessContentRepoHelper(
private val keyValueRepo: StandardKeyValueRepo<ContentId, AdapterType>
) : BusinessContentRepoHelper, BusinessContentRepoReadHelper by KeyValueBusinessContentRepoReadHelper(
keyValueRepo
), BusinessContentRepoWriteHelper by KeyValueBusinessContentRepoWriteHelper(
keyValueRepo
)
fun StandardKeyValueRepo<ContentId, AdapterType>.asBusinessContentRepo(
adapters: List<BusinessContentRepoContentAdapter>
) = BusinessContentRepo(
adapters,
KeyValueBusinessContentRepoHelper(this)
)
fun StandardKeyValueRepo<ContentId, AdapterType>.asBusinessContentRepo(
vararg adapters: BusinessContentRepoContentAdapter
) = asBusinessContentRepo(adapters.toList())

View File

@ -1,29 +0,0 @@
package dev.inmo.postssystem.core.content.api.business.content_adapters
import dev.inmo.micro_utils.repos.*
import dev.inmo.postssystem.core.content.Content
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.content.api.business.AdapterType
import dev.inmo.postssystem.core.content.api.business.BusinessContentRepoContentAdapter
class KeyValueBusinessContentRepoAdapter<T>(
override val type: AdapterType,
private val keyValueRepo: StandardKeyValueRepo<ContentId, T>,
private val contentToData: suspend (Content) -> T?,
private val dataToContent: suspend (T) -> Content
) : BusinessContentRepoContentAdapter {
override suspend fun storeContent(contentId: ContentId, content: Content): Boolean {
keyValueRepo.set(contentId, contentToData(content) ?: return false)
return true
}
override suspend fun getContent(contentId: ContentId): Content? {
return dataToContent(
keyValueRepo.get(contentId) ?: return null
)
}
override suspend fun removeContent(contentId: ContentId) {
keyValueRepo.unset(contentId)
}
}

View File

@ -1,54 +0,0 @@
package dev.inmo.postssystem.core.content.api.business.content_adapters.binary
import dev.inmo.micro_utils.repos.*
import dev.inmo.postssystem.core.content.Content
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.content.api.business.AdapterType
import dev.inmo.postssystem.core.content.api.business.BusinessContentRepoContentAdapter
import dev.inmo.postssystem.core.content.api.business.content_adapters.KeyValueBusinessContentRepoAdapter
import kotlinx.serialization.json.Json
private val format = Json { ignoreUnknownKeys = true }
class BinaryBusinessContentRepoContentAdapter(
private val dataStore: KeyValueRepo<ContentId, String>,
private val filesStore: KeyValueRepo<ContentId, ByteArray>,
private val removeOnAbsentInOneOfStores: Boolean = false
) : BusinessContentRepoContentAdapter {
override val type: AdapterType
get() = "binary"
override suspend fun storeContent(contentId: ContentId, content: Content): Boolean {
(content as? BinaryContent) ?.also {
filesStore.set(contentId, it.dataAllocator())
dataStore.set(
contentId,
format.encodeToString(BinaryContent.serializer(), it.copy { ByteArray(0) })
)
} ?: return false
return true
}
override suspend fun getContent(contentId: ContentId): Content? {
return filesStore.get(contentId) ?.let {
val serializedData = dataStore.get(contentId)
if (serializedData != null) {
format.decodeFromString(BinaryContent.serializer(), serializedData).copy {
it
}
} else {
null
}
} ?: null.also {
if (removeOnAbsentInOneOfStores) {
filesStore.unset(contentId)
dataStore.unset(contentId)
}
}
}
override suspend fun removeContent(contentId: ContentId) {
filesStore.unset(contentId)
dataStore.unset(contentId)
}
}

View File

@ -1,19 +0,0 @@
package dev.inmo.postssystem.core.content.api.business.content_adapters.binary
import dev.inmo.micro_utils.common.ByteArrayAllocator
import dev.inmo.micro_utils.common.ByteArrayAllocatorSerializer
import dev.inmo.micro_utils.mime_types.KnownMimeTypes
import dev.inmo.micro_utils.mime_types.MimeType
import dev.inmo.postssystem.core.content.Content
import kotlinx.serialization.Serializable
@Serializable
data class BinaryContent(
val mimeType: MimeType,
val originalFileName: String,
@Serializable(ByteArrayAllocatorSerializer::class)
val dataAllocator: ByteArrayAllocator
) : Content
val BinaryContent.isImage: Boolean
get() = mimeType is KnownMimeTypes.Image

View File

@ -1,15 +0,0 @@
package dev.inmo.postssystem.core.content.api.business.content_adapters.text
import dev.inmo.micro_utils.repos.*
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.content.api.business.BusinessContentRepoContentAdapter
import dev.inmo.postssystem.core.content.api.business.content_adapters.KeyValueBusinessContentRepoAdapter
class TextBusinessContentRepoContentAdapter(
private val keyValueRepo: StandardKeyValueRepo<ContentId, String>
) : BusinessContentRepoContentAdapter by KeyValueBusinessContentRepoAdapter(
"regularText",
keyValueRepo,
{ (it as? TextContent) ?.text },
{ TextContent(it) }
)

View File

@ -1,9 +0,0 @@
package dev.inmo.postssystem.core.content.api.business.content_adapters.text
import dev.inmo.postssystem.core.content.Content
import kotlinx.serialization.Serializable
@Serializable
data class TextContent(
val text: String
) : Content

View File

@ -1,61 +0,0 @@
package dev.inmo.postssystem.core.post
import dev.inmo.postssystem.core.UnixMillis
import dev.inmo.postssystem.core.content.ContentId
import com.soywiz.klock.DateTime
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
typealias PostId = String
typealias ContentIds = List<ContentId>
/**
* Base interface for creating of new post. Usually, it is just [SimplePost] instance
*/
@Serializable
sealed class Post {
abstract val content: ContentIds
}
/**
* Root entity of the whole system. Can be retrieved from [dev.inmo.postssystem.core.post.repo.ReadPostsRepo]
* by getting and created in [dev.inmo.postssystem.core.post.repo.WritePostsRepo] by inserting of [Post]
* instance
*/
@Serializable
sealed class RegisteredPost : Post() {
abstract val id: PostId
abstract override val content: ContentIds
abstract val creationDate: DateTime
}
/**
* Base and currently (1st Nov 2019) single realisation of [Post]. There is [SimpleRegisteredPost] which is technically
* is [Post] too, but it is not direct [Post] realisation
*/
@Serializable
data class SimplePost(
override val content: ContentIds
) : Post()
/**
* Base and currently (1st Nov 2019) single realisation of [RegisteredPost]
*/
@Serializable
data class SimpleRegisteredPost(
override val id: PostId,
override val content: ContentIds,
private val creationDateTimeMillis: UnixMillis
) : RegisteredPost() {
@Transient
override val creationDate: DateTime = DateTime(creationDateTimeMillis)
}
@Suppress("FunctionName")
fun RegisteredPost(
id: PostId,
content: ContentIds,
creationDate: DateTime
) = SimpleRegisteredPost(id, content, creationDate.unixMillis)

View File

@ -1,3 +0,0 @@
package dev.inmo.postssystem.core.post.repo
interface PostsRepo : ReadPostsRepo, WritePostsRepo

View File

@ -1,53 +0,0 @@
package dev.inmo.postssystem.core.post.repo
import dev.inmo.postssystem.core.MAX_DATE
import dev.inmo.postssystem.core.MIN_DATE
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.post.PostId
import dev.inmo.postssystem.core.post.RegisteredPost
import com.soywiz.klock.DateTime
import dev.inmo.micro_utils.pagination.*
/**
* Simple read API by different properties
*/
interface ReadPostsRepo {
/**
* @return [Set] of [PostId]s which can be used to get data using [getPostById]
*/
suspend fun getPostsIds(): Set<PostId>
/**
* @return [RegisteredPost] if it is available by [id]
*/
suspend fun getPostById(id: PostId): RegisteredPost?
/**
* @return all [RegisteredPost]s which contains content with specified content [id]
*/
suspend fun getPostsByContent(id: ContentId): List<RegisteredPost>
/**
* @return all [RegisteredPost]s which was registered between [from] and [to]. Range will be used INCLUSIVE, line \[[from], [to]\]
*/
suspend fun getPostsByCreatingDates(
from: DateTime = MIN_DATE,
to: DateTime = MAX_DATE,
pagination: Pagination = FirstPagePagination()
): PaginationResult<RegisteredPost>
/**
* @return all posts by pages basing on their creation date
*/
suspend fun getPostsByPagination(pagination: Pagination): PaginationResult<RegisteredPost>
}
suspend fun ReadPostsRepo.getPostsByCreatingDates(
from: DateTime? = null,
to: DateTime? = null,
pagination: Pagination = FirstPagePagination()
) = getPostsByCreatingDates(
from ?: MIN_DATE,
to ?: MAX_DATE,
pagination
)

View File

@ -1,21 +0,0 @@
package dev.inmo.postssystem.core.post.repo
import dev.inmo.postssystem.core.post.*
import kotlinx.coroutines.flow.Flow
interface WritePostsRepo {
val postCreatedFlow: Flow<RegisteredPost>
val postDeletedFlow: Flow<RegisteredPost>
val postUpdatedFlow: Flow<RegisteredPost>
/**
* For creating of post you need to create all its [dev.inmo.postssystem.core.content.RegisteredContent]
* and (or just) retrieve their [ContentIds] and put it into some [Post] implementation line [SimplePost].
*
* This method SHOULD use [PostId] of [RegisteredPost.id] in case if [RegisteredPost] passed
*/
suspend fun createPost(post: Post): RegisteredPost?
suspend fun deletePost(id: PostId): Boolean
suspend fun updatePostContent(postId: PostId, post: Post): Boolean
}

View File

@ -1,75 +0,0 @@
package dev.inmo.postssystem.core.api
import dev.inmo.micro_utils.common.ByteArrayAllocator
import dev.inmo.micro_utils.mime_types.KnownMimeTypes
import dev.inmo.postssystem.core.content.*
import dev.inmo.postssystem.core.content.api.business.content_adapters.binary.BinaryContent
import dev.inmo.postssystem.core.content.api.business.content_adapters.text.TextContent
import dev.inmo.postssystem.core.generateContentId
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlin.test.Test
import kotlin.test.assertEquals
private val jsonFormat = Json {
serializersModule = SerializersModule {
polymorphic(Content::class) {
subclass(TextContent::class, TextContent.serializer())
subclass(BinaryContent::class, BinaryContent.serializer())
}
}
}
class ContentSerialization {
private val simpleTextTestEntries = 10
private val simpleSpecialTestEntries = 10
@Test
fun test_that_content_correctly_serializing_and_deserializing() {
val contents = (0 until simpleTextTestEntries).map {
TextContent("Example$it")
} + (0 until simpleSpecialTestEntries).map {
BinaryContent(KnownMimeTypes.Any, "$it.example") {
byteArrayOf(it.toByte())
}
}
val registeredContentFakes = contents.map { content ->
RegisteredContent(
generateContentId(),
content
)
}
val stringified = registeredContentFakes.map {
jsonFormat.encodeToString(RegisteredContent.serializer(), it)
}
val parsed = stringified.map {
jsonFormat.decodeFromString(RegisteredContent.serializer(), it)
}
parsed.forEachIndexed { i, registeredContent ->
val content = registeredContent.content
assertEquals(registeredContentFakes[i].id, registeredContent.id)
when (content) {
is TextContent -> assertEquals(registeredContentFakes[i].content, content)
is BinaryContent -> {
val expectedContent = registeredContentFakes[i].content as BinaryContent
val fakeByteArrayAllocator: ByteArrayAllocator = { byteArrayOf() }
assertEquals(
expectedContent.copy(dataAllocator = fakeByteArrayAllocator),
content.copy(dataAllocator = fakeByteArrayAllocator)
)
val expectedData = expectedContent.dataAllocator()
val parsedData = content.dataAllocator()
assertEquals(expectedData.size, parsedData.size)
expectedData.withIndex().forEach { (i, byte) ->
assertEquals(byte, parsedData[i])
}
}
}
}
}
}

View File

@ -1,79 +0,0 @@
package dev.inmo.postssystem.core.content.api.business.content_adapters.binary
import dev.inmo.micro_utils.coroutines.doOutsideOfCoroutine
import dev.inmo.micro_utils.coroutines.safelyWithoutExceptions
import dev.inmo.micro_utils.pagination.*
import dev.inmo.micro_utils.repos.KeyValueRepo
import dev.inmo.micro_utils.repos.set
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.io.File
class FilesStoreRepoAdapter(
private val filesRepo: KeyValueRepo<String, File>,
private val temporalFilesFolder: File
) : KeyValueRepo<String, ByteArray> {
private val File.asByteArray
get() = readBytes()
override val onNewValue: Flow<Pair<String, ByteArray>> = filesRepo.onNewValue.map { (filename, file) ->
filename to file.asByteArray
}
override val onValueRemoved: Flow<String> = filesRepo.onValueRemoved
init {
temporalFilesFolder.mkdirs()
}
override suspend fun contains(key: String): Boolean = filesRepo.contains(key)
override suspend fun count(): Long = filesRepo.count()
override suspend fun get(k: String): ByteArray? = filesRepo.get(k) ?.asByteArray
override suspend fun keys(
v: ByteArray,
pagination: Pagination,
reversed: Boolean
): PaginationResult<String> = emptyPaginationResult()
override suspend fun keys(
pagination: Pagination,
reversed: Boolean
): PaginationResult<String> = filesRepo.keys(pagination, reversed)
override suspend fun set(toSet: Map<String, ByteArray>) {
supervisorScope {
toSet.map { (filename, bytes) ->
launch {
safelyWithoutExceptions {
val file = File(temporalFilesFolder, filename).also {
it.delete()
doOutsideOfCoroutine {
it.createNewFile()
it.writeBytes(bytes)
}
}
filesRepo.set(filename, file)
doOutsideOfCoroutine { file.delete() }
}
}
}
}.joinAll()
}
override suspend fun unset(toUnset: List<String>) = filesRepo.unset(toUnset)
override suspend fun values(
pagination: Pagination,
reversed: Boolean
): PaginationResult<ByteArray> = filesRepo.values(pagination, reversed).let {
PaginationResult(
it.page,
it.pagesNumber,
it.results.map { it.readBytes() },
it.size
)
}
}

View File

@ -1 +0,0 @@
<manifest package="dev.inmo.postssystem.core.api"/>

View File

@ -1,26 +0,0 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
}
apply from: "$mppJavaProjectPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api "dev.inmo:micro_utils.repos.exposed:$microutils_version"
api project(":postssystem.core.api")
}
}
jvmTest {
dependencies {
implementation "org.jetbrains.exposed:exposed-jdbc:$exposed_version"
implementation "org.xerial:sqlite-jdbc:$test_sqlite_version"
implementation "org.jetbrains.kotlin:kotlin-test"
implementation "org.jetbrains.kotlin:kotlin-test-junit"
}
}
}
}

View File

@ -1 +0,0 @@
test_sqlite_version=3.28.0

View File

@ -1,205 +0,0 @@
package dev.inmo.postssystem.core.exposed
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.generatePostId
import dev.inmo.postssystem.core.post.*
import dev.inmo.postssystem.core.post.repo.PostsRepo
import com.soywiz.klock.*
import dev.inmo.micro_utils.pagination.*
import dev.inmo.micro_utils.repos.exposed.ExposedRepo
import dev.inmo.micro_utils.repos.exposed.initTable
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
private class PostsRepoContentRelations(
override val database: Database
) : Table(), ExposedRepo {
private val postIdColumn = text("postId")
private val contentIdColumn = text("contentId")
init {
initTable()
}
fun getPostContents(postId: PostId): List<ContentId> {
return transaction(db = database) {
select { postIdColumn.eq(postId) }.map { it[contentIdColumn] }
}
}
fun getContentPosts(contentId: ContentId): List<PostId> {
return transaction(db = database) {
select { contentIdColumn.eq(contentId) }.map { it[postIdColumn] }
}
}
fun linkPostAndContents(postId: PostId, vararg contentIds: ContentId) {
transaction(db = database) {
val leftToPut = contentIds.toSet() - getPostContents(postId)
leftToPut.forEach { contentId ->
insert {
it[postIdColumn] = postId
it[contentIdColumn] = contentId
}
}
}
}
fun unlinkPostAndContents(postId: PostId, vararg contentIds: ContentId): Boolean {
return transaction(db = database) {
deleteWhere {
postIdColumn.eq(postId).and(contentIdColumn.inList(contentIds.toList()))
} > 0
}
}
}
private val dateTimeFormat = DateFormat("EEE, dd MMM yyyy HH:mm:ss z")
private class PostsRepoDatabaseTable(
private val database: Database,
tableName: String = ""
) : PostsRepo, Table(tableName) {
private val contentsTable = PostsRepoContentRelations(database)
private val idColumn = text("postId")
private val creationDateColumn = text("creationDate").default(
DateTime.now().toString(dateTimeFormat)
)
private val postCreatedBroadcastChannel = BroadcastChannel<RegisteredPost>(Channel.BUFFERED)
override val postCreatedFlow: Flow<RegisteredPost> = postCreatedBroadcastChannel.asFlow()
private val postDeletedBroadcastChannel = BroadcastChannel<RegisteredPost>(Channel.BUFFERED)
override val postDeletedFlow: Flow<RegisteredPost> = postDeletedBroadcastChannel.asFlow()
private val postUpdatedBroadcastChannel = BroadcastChannel<RegisteredPost>(Channel.BUFFERED)
override val postUpdatedFlow: Flow<RegisteredPost> = postUpdatedBroadcastChannel.asFlow()
init {
transaction (db = database) {
SchemaUtils.createMissingTablesAndColumns(this@PostsRepoDatabaseTable)
}
}
private fun ResultRow.toRegisteredPost(): RegisteredPost = get(idColumn).let { id ->
SimpleRegisteredPost(
id,
contentsTable.getPostContents(id),
dateTimeFormat.parse(get(creationDateColumn)).local.unixMillis
)
}
override suspend fun createPost(post: Post): RegisteredPost? {
val id = (post as? RegisteredPost) ?.let { it.id } ?: generatePostId()
return transaction(
db = database
) {
insert {
it[idColumn] = id
}
contentsTable.linkPostAndContents(id, *post.content.toTypedArray())
select { idColumn.eq(id) }.firstOrNull() ?.toRegisteredPost()
} ?.also {
postCreatedBroadcastChannel.send(it)
}
}
override suspend fun deletePost(id: PostId): Boolean {
val post = getPostById(id) ?: return false
return (transaction(
db = database
) {
deleteWhere { idColumn.eq(id) }
} > 0).also {
if (it) {
postDeletedBroadcastChannel.send(post)
contentsTable.unlinkPostAndContents(id, *post.content.toTypedArray())
}
}
}
override suspend fun updatePostContent(postId: PostId, post: Post): Boolean {
return transaction(
db = database
) {
val alreadyLinked = contentsTable.getPostContents(postId)
val toRemove = alreadyLinked - post.content
val toInsert = post.content - alreadyLinked
val updated = (toRemove.isNotEmpty() && contentsTable.unlinkPostAndContents(postId, *toRemove.toTypedArray())) || toInsert.isNotEmpty()
if (toInsert.isNotEmpty()) {
contentsTable.linkPostAndContents(postId, *toInsert.toTypedArray())
}
updated
}.also {
if (it) {
getPostById(postId) ?.also { updatedPost -> postUpdatedBroadcastChannel.send(updatedPost) }
}
}
}
override suspend fun getPostsIds(): Set<PostId> {
return transaction(
db = database
) {
selectAll().map { it[idColumn] }.toSet()
}
}
override suspend fun getPostById(id: PostId): RegisteredPost? {
return transaction(
db = database
) {
select { idColumn.eq(id) }.firstOrNull() ?.toRegisteredPost()
}
}
override suspend fun getPostsByContent(id: ContentId): List<RegisteredPost> {
return transaction(
db = database
) {
val postsIds = contentsTable.getContentPosts(id)
select { idColumn.inList(postsIds) }.map { it.toRegisteredPost() }
}
}
override suspend fun getPostsByCreatingDates(
from: DateTime,
to: DateTime,
pagination: Pagination
): PaginationResult<RegisteredPost> {
return transaction(
db = database
) {
select { creationDateColumn.between(from, to) }.paginate(
pagination
).map {
it.toRegisteredPost()
}.createPaginationResult(
pagination,
selectAll().count()
)
}
}
override suspend fun getPostsByPagination(pagination: Pagination): PaginationResult<RegisteredPost> {
return transaction(
db = database
) {
val posts = selectAll().paginate(pagination).orderBy(creationDateColumn).map {
it.toRegisteredPost()
}
val postsNumber = selectAll().count()
posts.createPaginationResult(pagination, postsNumber)
}
}
}
class ExposedPostsRepo (
database: Database,
tableName: String = ""
) : PostsRepo by PostsRepoDatabaseTable(database, tableName)

View File

@ -1,86 +0,0 @@
package dev.inmo.postssystem.core.exposed
import dev.inmo.micro_utils.repos.create
import dev.inmo.micro_utils.repos.deleteById
import dev.inmo.micro_utils.repos.exposed.keyvalue.ExposedKeyValueRepo
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.content.api.business.AdapterType
import dev.inmo.postssystem.core.content.api.business.asBusinessContentRepo
import dev.inmo.postssystem.core.content.api.business.content_adapters.text.TextBusinessContentRepoContentAdapter
import dev.inmo.postssystem.core.content.api.business.content_adapters.text.TextContent
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.transactionManager
import java.io.File
import java.sql.Connection
import kotlin.test.Test
import kotlin.test.assertEquals
class ExposedContentRepoCommonTests {
private val tempFolder = System.getProperty("java.io.tmpdir")!!
@Test
fun `Test that it is possible to use several different databases at one time`() {
val numberOfDatabases = 8
val databaseFiles = (0 until numberOfDatabases).map {
"$tempFolder/ExposedContentAPICommonTestsDB$it.db"
}
val apis = databaseFiles.map {
File(it).also {
it.delete()
it.deleteOnExit()
}
val database = Database.Companion.connect("jdbc:sqlite:$it", driver = "org.sqlite.JDBC").also {
it.transactionManager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE
}
ExposedKeyValueRepo<ContentId, AdapterType>(
database,
{ text("contentId") },
{ text("adapterType") },
"ContentRepo"
).asBusinessContentRepo(
TextBusinessContentRepoContentAdapter(
ExposedKeyValueRepo<ContentId, String>(
database,
{ text("contentId") },
{ text("text") },
"TextContentRepo"
)
)
)
}
val results = apis.mapIndexed { i, api ->
val expectedContent = TextContent(i.toString())
val contents = runBlocking { api.create(TextContent(i.toString())) }
val idsCount = runBlocking { api.count() }
assertEquals(idsCount, 1)
assert(contents.isNotEmpty())
assertEquals(
expectedContent,
contents.first().content
)
contents.first()
}
results.forEachIndexed { i, content ->
apis.forEachIndexed { j, api ->
assert(
runBlocking {
api.getById(content.id) == (if (i != j) null else content)
}
)
runBlocking {
api.deleteById(content.id)
}
}
}
databaseFiles.forEach {
File(it).delete()
}
}
}

View File

@ -1,64 +0,0 @@
package dev.inmo.postssystem.core.exposed
import dev.inmo.postssystem.core.post.SimplePost
import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.transactionManager
import java.io.File
import java.sql.Connection
import kotlin.test.*
class ExposedPostsRepoCommonTests {
private val tempFolder = System.getProperty("java.io.tmpdir")!!
private val numberOfDatabases = 8
private lateinit var databaseFiles: List<File>
private lateinit var apis: List<ExposedPostsRepo>
@BeforeTest
fun prepare() {
databaseFiles = (0 until numberOfDatabases).map {
File("$tempFolder/ExposedPostsAPICommonTestsDB$it.db")
}
apis = databaseFiles.map {
val database = Database.connect("jdbc:sqlite:${it.absolutePath}").also {
it.transactionManager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE
}
ExposedPostsRepo(
database
)
}
}
@Test
fun `Test that it is possible to use several different databases at one time`() {
val posts = apis.mapIndexed { i, api ->
val content = runBlocking { api.createPost(SimplePost(listOf(i.toString()))) }
assert(content != null)
assert(runBlocking { api.getPostsIds().size == 1 })
content!!
}
posts.forEachIndexed { i, post ->
apis.forEachIndexed { j, api ->
assert(
runBlocking {
api.getPostById(post.id) == (if (i != j) null else post)
}
)
assert(
runBlocking {
api.deletePost(post.id) == (i == j)
}
)
}
}
}
@AfterTest
fun `Close and delete databases`() {
databaseFiles.forEach {
it.delete()
}
}
}

View File

@ -1 +0,0 @@

View File

@ -1 +0,0 @@

View File

@ -1,28 +0,0 @@
package dev.inmo.postssystem.core.ktor.client.post
import dev.inmo.postssystem.core.ktor.postsRootRoute
import dev.inmo.postssystem.core.post.repo.*
import io.ktor.client.HttpClient
import io.ktor.client.features.websocket.WebSockets
class PostsRepoKtorClient private constructor(
readPostsRepo: ReadPostsRepo,
writePostsRepo: WritePostsRepo
) : PostsRepo, ReadPostsRepo by readPostsRepo, WritePostsRepo by writePostsRepo {
constructor(
baseUrl: String,
rootRoute: String? = postsRootRoute,
client: HttpClient = HttpClient {
install(WebSockets)
}
) : this(
ReadPostsRepoKtorClient(
rootRoute ?.let { "${baseUrl}/$rootRoute" } ?: baseUrl,
client
),
WritePostsRepoKtorClient(
rootRoute ?.let { "${baseUrl}/$rootRoute" } ?: baseUrl,
client
)
)
}

View File

@ -1,56 +0,0 @@
package dev.inmo.postssystem.core.ktor.client.post
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.ktor.*
import dev.inmo.postssystem.core.post.PostId
import dev.inmo.postssystem.core.post.RegisteredPost
import dev.inmo.postssystem.core.post.repo.ReadPostsRepo
import com.soywiz.klock.DateTime
import dev.inmo.micro_utils.ktor.client.uniget
import dev.inmo.micro_utils.ktor.common.asFromToUrlPart
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
import dev.inmo.micro_utils.pagination.*
import io.ktor.client.HttpClient
import kotlinx.serialization.builtins.nullable
class ReadPostsRepoKtorClient(
private val baseUrl: String,
private val client: HttpClient = HttpClient()
) : ReadPostsRepo {
override suspend fun getPostsIds(): Set<PostId> = client.uniget(
buildStandardUrl(baseUrl, getPostsIdsRoute),
postIdsSerializer
)
override suspend fun getPostById(id: PostId): RegisteredPost? = client.uniget(
buildStandardUrl(baseUrl, "$getPostByIdRoute/$id"),
RegisteredPost.serializer().nullable
)
override suspend fun getPostsByContent(id: ContentId): List<RegisteredPost> = client.uniget(
buildStandardUrl(baseUrl, "$getPostsByContentRoute/$id"),
registeredPostsListSerializer
)
override suspend fun getPostsByCreatingDates(
from: DateTime,
to: DateTime,
pagination: Pagination
): PaginationResult<RegisteredPost> = client.uniget(
buildStandardUrl(
baseUrl,
getPostsByCreatingDatesRoute,
(from to to).asFromToUrlPart + pagination.asUrlQueryParts
),
registeredPostsPaginationResultSerializer
)
override suspend fun getPostsByPagination(pagination: Pagination): PaginationResult<RegisteredPost> = client.uniget(
buildStandardUrl(
baseUrl,
getPostsByCreatingDatesRoute,
pagination.asUrlQueryParts
),
registeredPostsPaginationResultSerializer
)
}

View File

@ -1,49 +0,0 @@
package dev.inmo.postssystem.core.ktor.client.post
import dev.inmo.postssystem.core.ktor.*
import dev.inmo.postssystem.core.post.*
import dev.inmo.postssystem.core.post.repo.WritePostsRepo
import dev.inmo.micro_utils.ktor.client.*
import io.ktor.client.HttpClient
import io.ktor.client.features.websocket.WebSockets
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
class WritePostsRepoKtorClient (
private val baseUrl: String,
private val client: HttpClient = HttpClient {
install(WebSockets)
}
) : WritePostsRepo {
override val postCreatedFlow: Flow<RegisteredPost> = client.createStandardWebsocketFlow(
"$baseUrl/$postCreatedFlowRoute",
deserializer = RegisteredPost.serializer()
)
override val postDeletedFlow: Flow<RegisteredPost> = client.createStandardWebsocketFlow(
"$baseUrl/$postDeletedFlowRoute",
deserializer = RegisteredPost.serializer()
)
override val postUpdatedFlow: Flow<RegisteredPost> = client.createStandardWebsocketFlow(
"$baseUrl/$postUpdatedFlowRoute",
deserializer = RegisteredPost.serializer()
)
override suspend fun createPost(post: Post): RegisteredPost? = client.unipost(
"$baseUrl/$createPostRoute",
BodyPair(Post.serializer(), post),
RegisteredPost.serializer().nullable
)
override suspend fun deletePost(id: PostId): Boolean = client.unipost(
"$baseUrl/$deletePostRoute",
BodyPair(PostId.serializer(), id),
Boolean.serializer()
)
override suspend fun updatePostContent(postId: PostId, post: Post): Boolean = client.unipost(
"$baseUrl/$updatePostContentRoute",
BodyPair(UpdatePostObject.serializer(), UpdatePostObject(postId, post)),
Boolean.serializer()
)
}

View File

@ -1 +0,0 @@
<manifest package="dev.inmo.postssystem.core.ktor.client"/>

View File

@ -1,13 +0,0 @@
package dev.inmo.postssystem.core.ktor
const val contentRootRoute = "content"
const val getContentsIdsRoute = "getContentsIds"
const val getContentByIdRoute = "getContentById"
const val getContentByPaginationRoute = "getContentByPagination"
const val registerContentRoute = "registerContent"
const val deleteContentRoute = "deleteContent"
const val contentCreatedFlowRoute = "contentCreatedFlow"
const val contentDeletedFlowRoute = "contentDeletedFlow"

View File

@ -1,17 +0,0 @@
package dev.inmo.postssystem.core.ktor
const val postsRootRoute = "post"
const val publishedPostsSubRoute = "published"
const val getPostsIdsRoute = "getPostsIds"
const val getPostByIdRoute = "getPostById"
const val getPostsByContentRoute = "getPostsByContent"
const val getPostsByCreatingDatesRoute = "getPostsByCreatingDates"
const val getPostsByPaginationRoute = "getPostsByPagination"
const val postCreatedFlowRoute = "postCreatedFlow"
const val postDeletedFlowRoute = "postDeletedFlow"
const val postUpdatedFlowRoute = "postUpdatedFlow"
const val createPostRoute = "createPost"
const val deletePostRoute = "deletePost"
const val updatePostContentRoute = "updatePostContent"

View File

@ -1,14 +0,0 @@
package dev.inmo.postssystem.core.ktor
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.content.RegisteredContent
import dev.inmo.postssystem.core.post.PostId
import dev.inmo.postssystem.core.post.RegisteredPost
import dev.inmo.micro_utils.pagination.PaginationResult
import kotlinx.serialization.builtins.*
val contentIdsSerializer = SetSerializer(ContentId.serializer())
val postIdsSerializer = SetSerializer(PostId.serializer())
val registeredPostsListSerializer = ListSerializer(RegisteredPost.serializer())
val registeredPostsPaginationResultSerializer = PaginationResult.serializer(RegisteredPost.serializer())
val registeredContentPaginationResultSerializer = PaginationResult.serializer(RegisteredContent.serializer())

View File

@ -1,11 +0,0 @@
package dev.inmo.postssystem.core.ktor
import dev.inmo.postssystem.core.post.Post
import dev.inmo.postssystem.core.post.PostId
import kotlinx.serialization.Serializable
@Serializable
data class UpdatePostObject(
val postId: PostId,
val post: Post
)

View File

@ -1 +0,0 @@
<manifest package="dev.inmo.postssystem.core.ktor.common"/>

View File

@ -1,26 +0,0 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
}
apply from: "$mppJavaProjectPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api "dev.inmo:micro_utils.pagination.ktor.server:$microutils_version"
api "dev.inmo:micro_utils.ktor.server:$microutils_version"
api project(":postssystem.core.ktor.common")
}
}
jvmTest {
dependencies {
implementation "org.xerial:sqlite-jdbc:$test_sqlite_version"
implementation "org.jetbrains.kotlin:kotlin-test"
implementation "org.jetbrains.kotlin:kotlin-test-junit"
}
}
}
}

View File

@ -1,30 +0,0 @@
package dev.inmo.postssystem.core.ktor.server.post
import dev.inmo.postssystem.core.ktor.postsRootRoute
import dev.inmo.postssystem.core.post.repo.PostsRepo
import dev.inmo.micro_utils.ktor.server.configurators.ApplicationRoutingConfigurator
import io.ktor.routing.Route
import io.ktor.routing.route
private inline fun configurator(proxyTo: PostsRepo): Route.() -> Unit = {
configureReadPostsRepoRoutes(proxyTo)
configureWritePostsRepoRoutes(proxyTo)
}
fun Route.configurePostsRepoRoutes(
proxyTo: PostsRepo,
rootRoute: String? = postsRootRoute
) {
rootRoute ?.also {
route(it, configurator(proxyTo))
} ?: configurator(proxyTo).invoke(this)
}
class PostsRepoRoutingConfigurator(
private val proxyTo: PostsRepo,
private val rootRoute: String? = postsRootRoute
) : ApplicationRoutingConfigurator.Element {
override fun Route.invoke() {
configurePostsRepoRoutes(proxyTo, rootRoute)
}
}

View File

@ -1,70 +0,0 @@
package dev.inmo.postssystem.core.ktor.server.post
import dev.inmo.postssystem.core.MAX_DATE
import dev.inmo.postssystem.core.MIN_DATE
import dev.inmo.postssystem.core.content.ContentId
import dev.inmo.postssystem.core.ktor.*
import dev.inmo.postssystem.core.post.PostId
import dev.inmo.postssystem.core.post.RegisteredPost
import dev.inmo.postssystem.core.post.repo.ReadPostsRepo
import dev.inmo.micro_utils.ktor.server.*
import dev.inmo.micro_utils.ktor.server.configurators.ApplicationRoutingConfigurator
import dev.inmo.micro_utils.pagination.extractPagination
import io.ktor.application.call
import io.ktor.routing.Route
import io.ktor.routing.get
import kotlinx.serialization.builtins.nullable
fun Route.configureReadPostsRepoRoutes(
proxyTo: ReadPostsRepo
) {
get(getPostsIdsRoute) {
call.unianswer(
postIdsSerializer,
proxyTo.getPostsIds()
)
}
get("$getPostByIdRoute/{id}") {
val id: PostId = call.getParameterOrSendError("id") ?: return@get
call.unianswer(
RegisteredPost.serializer().nullable,
proxyTo.getPostById(id)
)
}
get("$getPostsByContentRoute/{id}") {
val id: ContentId = call.getParameterOrSendError("id") ?: return@get
call.unianswer(
registeredPostsListSerializer,
proxyTo.getPostsByContent(id)
)
}
get(getPostsByCreatingDatesRoute) {
val fromToDateTime = call.request.queryParameters.extractFromToDateTime
val pagination = call.request.queryParameters.extractPagination
call.unianswer(
registeredPostsPaginationResultSerializer,
proxyTo.getPostsByCreatingDates(
fromToDateTime.first ?: MIN_DATE,
fromToDateTime.second ?: MAX_DATE,
pagination
)
)
}
get(getPostsByPaginationRoute) {
val pagination = call.request.queryParameters.extractPagination
call.unianswer(
registeredPostsPaginationResultSerializer,
proxyTo.getPostsByPagination(pagination)
)
}
}
class ReadPostsRepoRoutingConfigurator(
private val proxyTo: ReadPostsRepo
) : ApplicationRoutingConfigurator.Element {
override fun Route.invoke() {
configureReadPostsRepoRoutes(proxyTo)
}
}

View File

@ -1,56 +0,0 @@
package dev.inmo.postssystem.core.ktor.server.post
import dev.inmo.postssystem.core.ktor.*
import dev.inmo.postssystem.core.post.*
import dev.inmo.postssystem.core.post.repo.WritePostsRepo
import dev.inmo.micro_utils.ktor.server.*
import dev.inmo.micro_utils.ktor.server.configurators.ApplicationRoutingConfigurator
import io.ktor.application.call
import io.ktor.routing.Route
import io.ktor.routing.post
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
fun Route.configureWritePostsRepoRoutes(
proxyTo: WritePostsRepo
) {
includeWebsocketHandling(postCreatedFlowRoute, proxyTo.postCreatedFlow, RegisteredPost.serializer())
includeWebsocketHandling(postDeletedFlowRoute, proxyTo.postDeletedFlow, RegisteredPost.serializer())
includeWebsocketHandling(postUpdatedFlowRoute, proxyTo.postUpdatedFlow, RegisteredPost.serializer())
post(createPostRoute) {
call.unianswer(
RegisteredPost.serializer().nullable,
proxyTo.createPost(
call.uniload(Post.serializer())
)
)
}
post(deletePostRoute) {
call.unianswer(
Boolean.serializer(),
proxyTo.deletePost(call.uniload(PostId.serializer()))
)
}
post(updatePostContentRoute) {
val updatePostObject = call.uniload(UpdatePostObject.serializer())
call.unianswer(
Boolean.serializer(),
proxyTo.updatePostContent(
updatePostObject.postId,
updatePostObject.post
)
)
}
}
class WritePostsRepoRoutingConfigurator(
private val proxyTo: WritePostsRepo
) : ApplicationRoutingConfigurator.Element {
override fun Route.invoke() {
configureWritePostsRepoRoutes(proxyTo)
}
}

View File

@ -1,38 +0,0 @@
android {
compileSdkVersion "$android_compileSdkVersion".toInteger()
buildToolsVersion "$android_buildToolsVersion"
defaultConfig {
minSdkVersion "$android_minSdkVersion".toInteger()
targetSdkVersion "$android_compileSdkVersion".toInteger()
versionCode "${android_code_version}".toInteger()
versionName "$version"
}
buildTypes {
release {
minifyEnabled false
}
debug {
debuggable true
}
}
packagingOptions {
exclude 'META-INF/kotlinx-serialization-runtime.kotlin_module'
exclude 'META-INF/kotlinx-serialization-cbor.kotlin_module'
exclude 'META-INF/kotlinx-serialization-properties.kotlin_module'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
}

View File

@ -0,0 +1,68 @@
apply plugin: 'com.getkeepsafe.dexcount'
android {
ext {
jvmKotlinFolderFile = {
String sep = File.separator
return new File("${project.projectDir}${sep}src${sep}jvmMain${sep}kotlin")
}
enableIncludingJvmCodeInAndroidPart = {
File jvmKotlinFolder = jvmKotlinFolderFile()
if (jvmKotlinFolder.exists()) {
android.sourceSets.main.java.srcDirs += jvmKotlinFolder.path
}
}
disableIncludingJvmCodeInAndroidPart = {
File jvmKotlinFolder = jvmKotlinFolderFile()
String[] oldDirs = android.sourceSets.main.java.srcDirs
android.sourceSets.main.java.srcDirs = []
for (oldDir in oldDirs) {
if (oldDir != jvmKotlinFolder.path) {
android.sourceSets.main.java.srcDirs += oldDir
}
}
}
}
compileSdkVersion "$android_compileSdkVersion".toInteger()
buildToolsVersion "$android_buildToolsVersion"
defaultConfig {
minSdkVersion "$android_minSdkVersion".toInteger()
targetSdkVersion "$android_compileSdkVersion".toInteger()
versionCode "${android_code_version}".toInteger()
versionName "$version"
}
buildTypes {
release {
minifyEnabled false
}
debug {
debuggable true
}
}
packagingOptions {
exclude 'META-INF/kotlinx-serialization-runtime.kotlin_module'
exclude 'META-INF/kotlinx-serialization-cbor.kotlin_module'
exclude 'META-INF/kotlinx-serialization-properties.kotlin_module'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
sourceSets {
String sep = File.separator
main.java.srcDirs += "src${sep}main${sep}kotlin"
}
enableIncludingJvmCodeInAndroidPart()
}

View File

@ -1,11 +1,25 @@
allprojects {
ext {
mppProjectWithSerializationPresetPath = "${rootProject.projectDir.absolutePath}/mppProjectWithSerialization"
mppJavaProjectPresetPath = "${rootProject.projectDir.absolutePath}/mppJavaProject"
mppAndroidProjectPresetPath = "${rootProject.projectDir.absolutePath}/mppAndroidProject"
projectByName = { String name ->
for (subproject in rootProject.subprojects) {
if (subproject.name == name) {
return subproject
}
}
return null
}
defaultAndroidSettingsPresetPath = "${rootProject.projectDir.absolutePath}/defaultAndroidSettings"
internalProject = { String name ->
projectByName(name)
}
publishGradlePath = "${rootProject.projectDir.absolutePath}/publish.gradle"
mppProjectWithSerializationPresetPath = "${rootProject.projectDir.absolutePath}/mppProjectWithSerialization.gradle"
mppJavaProjectPresetPath = "${rootProject.projectDir.absolutePath}/mppJavaProject.gradle"
mppJsProjectPresetPath = "${rootProject.projectDir.absolutePath}/mppJsProject.gradle"
mppAndroidProjectPresetPath = "${rootProject.projectDir.absolutePath}/mppAndroidProject.gradle"
defaultAndroidSettingsPresetPath = "${rootProject.projectDir.absolutePath}/defaultAndroidSettings.gradle"
publishGradlePath = "${rootProject.projectDir.absolutePath}/publish.gradle"
}
}

View File

@ -10,9 +10,8 @@ kotlin {
sourceSets {
commonMain {
dependencies {
api "dev.inmo:micro_utils.ktor.common:$microutils_version"
api project(":postssystem.publishing.api")
api project(":postssystem.features.common.client")
api project(":postssystem.features.auth.common")
}
}
}

View File

@ -0,0 +1,55 @@
package dev.inmo.postssystem.features.auth.client
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.users.common.User
import dev.inmo.micro_utils.ktor.client.UnifiedRequester
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
import io.ktor.client.HttpClient
import io.ktor.client.request.HttpRequestBuilder
import kotlinx.serialization.builtins.nullable
class ClientAuthFeature(
private val requester: UnifiedRequester,
baseUrl: String
) : AuthFeature {
private val rootUrl = buildStandardUrl(baseUrl.dropLastWhile { it == '/' }, authRootPathPart)
private val fullAuthPath = buildStandardUrl(
rootUrl,
authAuthPathPart
)
private val fullRefreshPath = buildStandardUrl(
rootUrl,
authRefreshPathPart
)
private val fullGetMePath = buildStandardUrl(
rootUrl,
authGetMePathPart
)
constructor(client: HttpClient, rootUrl: String): this(
UnifiedRequester(client),
rootUrl
)
override suspend fun auth(creds: AuthCreds): AuthTokenInfo? = requester.unipost(
fullAuthPath,
AuthCreds.serializer() to creds,
AuthTokenInfo.serializer().nullable
)
override suspend fun refresh(refresh: RefreshToken): AuthTokenInfo? = requester.unipost(
fullRefreshPath,
RefreshToken.serializer() to refresh,
AuthTokenInfo.serializer().nullable
)
override suspend fun getMe(authToken: AuthToken): User? = requester.unipost(
fullGetMePath,
AuthToken.serializer() to authToken,
User.serializer().nullable
)
fun isAuthRequest(builder: HttpRequestBuilder): Boolean = builder.url.buildString().let {
it == fullAuthPath || it == fullRefreshPath
}
}

View File

@ -0,0 +1,112 @@
package dev.inmo.postssystem.features.auth.client
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.users.common.User
import dev.inmo.micro_utils.common.*
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
import io.ktor.client.HttpClientConfig
import io.ktor.client.features.cookies.*
import io.ktor.client.features.expectSuccess
import io.ktor.client.request.*
import io.ktor.client.statement.HttpReceivePipeline
import io.ktor.client.statement.HttpResponse
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object AuthUnavailableException : Exception()
fun HttpClientConfig<*>.installClientAuthenticator(
baseUrl: String,
scope: CoroutineScope,
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
onAuthKeyUpdated: suspend (AuthTokenInfo) -> Unit,
onUserRetrieved: suspend (User?) -> Unit,
onAuthKeyInvalidated: suspend () -> Unit
) {
// install(Logging) {
// logger = Logger.DEFAULT
// level = LogLevel.HEADERS
// }
install(HttpCookies) {
// Will keep an in-memory map with all the cookies from previous requests.
storage = AcceptAllCookiesStorage()
}
val authMutex = Mutex()
var currentRefreshToken: RefreshToken? = null
initialAuthKey.onFirst {
currentRefreshToken = it as? RefreshToken
}.onSecond {
currentRefreshToken = it.refresh
}
val creds = initialAuthKey.t1 as? AuthCreds
var userRefreshJob: Job? = null
install("Auth Token Refresher") {
val clientAuthFeature = ClientAuthFeature(this, baseUrl)
fun refreshUser(newTokenInfo: AuthTokenInfo) {
userRefreshJob ?.cancel()
userRefreshJob = scope.launchSafelyWithoutExceptions {
onUserRetrieved(clientAuthFeature.getMe(newTokenInfo.token))
}
}
initialAuthKey.onSecond { refreshUser(it) }
suspend fun refreshToken() {
val capturedRefresh = currentRefreshToken
runCatching {
when {
capturedRefresh == null && creds == null -> throw AuthUnavailableException
capturedRefresh != null -> {
currentRefreshToken = null
val newTokenInfo = clientAuthFeature.refresh(capturedRefresh)
currentRefreshToken = newTokenInfo ?.refresh
if (newTokenInfo == null) {
refreshToken()
} else {
onAuthKeyUpdated(newTokenInfo)
refreshUser(newTokenInfo)
}
}
creds != null -> {
val newAuthTokenInfo = clientAuthFeature.auth(creds)
if (newAuthTokenInfo != null) {
onAuthKeyUpdated(newAuthTokenInfo)
refreshUser(newAuthTokenInfo)
currentRefreshToken = newAuthTokenInfo.refresh
}
}
}
}.onFailure {
onAuthKeyInvalidated()
}
}
sendPipeline.intercept(HttpSendPipeline.State) {
if (!context.url.buildString().startsWith(baseUrl) || clientAuthFeature.isAuthRequest(context)) {
return@intercept
}
context.expectSuccess = false
if (authMutex.isLocked) {
authMutex.withLock { /* do nothing, just wait while mutex will be freed */ }
}
}
receivePipeline.intercept(HttpReceivePipeline.Before) {
if (
context.request.url.toString().startsWith(baseUrl)
&& context.response.status == HttpStatusCode.Unauthorized
) {
authMutex.withLock { refreshToken() }
val newResponse = context.client ?.request<HttpResponse>{
takeFrom(context.request)
} ?: return@intercept
proceedWith(newResponse)
}
}
}
}

View File

@ -0,0 +1,8 @@
package dev.inmo.postssystem.features.auth.client.ui
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.common.common.UIModel
interface AuthUIModel : UIModel<AuthUIState> {
suspend fun initAuth(serverUrl: String, creds: AuthCreds)
}

View File

@ -0,0 +1,20 @@
package dev.inmo.postssystem.features.auth.client.ui
import kotlinx.serialization.Serializable
@Serializable
sealed class AuthUIError
@Serializable
object ServerUnavailableAuthUIError : AuthUIError()
@Serializable
object AuthIncorrectAuthUIError : AuthUIError()
@Serializable
sealed class AuthUIState
@Serializable
data class InitAuthUIState(val showError: AuthUIError? = null) : AuthUIState()
val DefaultInitAuthUIState = InitAuthUIState()
@Serializable
object LoadingAuthUIState : AuthUIState()
@Serializable
object AuthorizedAuthUIState : AuthUIState()

View File

@ -0,0 +1,34 @@
package dev.inmo.postssystem.features.auth.client.ui
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.common.common.UIViewModel
import dev.inmo.postssystem.features.users.common.Username
import kotlinx.coroutines.flow.StateFlow
class AuthUIViewModel(
private val model: AuthUIModel
) : UIViewModel<AuthUIState> {
override val currentState: StateFlow<AuthUIState>
get() = model.currentState
private fun checkIncomingData(
serverUrl: String,
username: String,
password: String
): Boolean {
return serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
}
suspend fun initAuth(
serverUrl: String,
username: String,
password: String
) {
if (checkIncomingData(serverUrl, username, password)) {
model.initAuth(
serverUrl.takeIf { it.startsWith("http") } ?: "http://$serverUrl",
AuthCreds(Username(username), password)
)
}
}
}

View File

@ -0,0 +1 @@
<manifest package="dev.inmo.postssystem.features.auth.client"/>

View File

@ -10,10 +10,8 @@ kotlin {
sourceSets {
commonMain {
dependencies {
implementation kotlin('stdlib')
api project(":postssystem.core.ktor.common")
api project(":postssystem.publishing.ktor.common")
api project(":postssystem.features.common.common")
api project(":postssystem.features.users.common")
}
}
}

View File

@ -0,0 +1,9 @@
package dev.inmo.postssystem.features.auth.common
import dev.inmo.postssystem.features.users.common.User
interface AuthFeature {
suspend fun auth(creds: AuthCreds): AuthTokenInfo?
suspend fun refresh(refresh: RefreshToken): AuthTokenInfo?
suspend fun getMe(authToken: AuthToken): User?
}

View File

@ -0,0 +1,36 @@
package dev.inmo.postssystem.features.auth.common
import com.benasher44.uuid.uuid4
import dev.inmo.postssystem.features.users.common.Username
import kotlinx.serialization.*
import kotlin.jvm.JvmInline
sealed interface AuthKey
@Serializable
@SerialName("authcreds")
data class AuthCreds(
val username: Username,
val password: String
): AuthKey
@Serializable
@SerialName("token")
@JvmInline
value class AuthToken(val string: String = uuid4().toString()): AuthKey {
override fun toString(): String = string
}
@Serializable
@SerialName("refresh")
@JvmInline
value class RefreshToken(val string: String = uuid4().toString()): AuthKey {
override fun toString(): String = string
}
@Serializable
data class AuthTokenInfo(
val token: AuthToken,
val refresh: RefreshToken
)

View File

@ -0,0 +1,8 @@
package dev.inmo.postssystem.features.auth.common
const val tokenSessionKey = "token"
const val authRootPathPart = "auth"
const val authAuthPathPart = "auth"
const val authRefreshPathPart = "refresh"
const val authGetMePathPart = "getMe"

View File

@ -0,0 +1 @@
<manifest package="dev.inmo.postssystem.features.auth.common"/>

View File

@ -0,0 +1,17 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
}
apply from: "$mppJavaProjectPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api project(":postssystem.features.auth.common")
api project(":postssystem.features.common.server")
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More