full reborn
This commit is contained in:
parent
0ac6b0a4df
commit
6a6a197041
10
.github/workflows/autopublish.yml
vendored
10
.github/workflows/autopublish.yml
vendored
@ -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
201
LICENSE
@ -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
14
README.md
Normal file
@ -0,0 +1,14 @@
|
||||
## Структура проекта
|
||||
|
||||
* Features - набор **законченных** фич проекта. Считается, что любая фича, находящаяся в мастере может быть добавлена в
|
||||
клиент и использована в нем. Исключением является `common` - это набор вещей, используемых везде. В подпунктах представлены
|
||||
части, на которые *обычно* разделяется фича
|
||||
* Common - общая для фичи часть. Тут, как правило, хранятся конвенции путей для сетевых соединений, общие типы и пр.
|
||||
* Server - часть, включаемая в сервер для подключения фичи. Обычно содержит работу с бд, определение модулей сервера и пр.
|
||||
* Client - часть с клиентским кодом. В большинстве своём включает работу с сервером, MVVM часть (View при этом должны
|
||||
находиться в платформенной части, если их нельзя вынести в сommon часть клиента)
|
||||
* Client - итоговый клиент. На момент написания этой доки (`Пн окт 25 12:56:41 +06 2021`) предполагается два варианта:
|
||||
* Мультиплатформенный проект со сборкой каждого таргета. Скорее всего, не будет использован в силу сложности настройки
|
||||
части клиентов (например, андроид)
|
||||
* Мультимодульный проект
|
||||
* Server - пока что JVM-only модуль, включающий все необходимые для сервера фичи
|
@ -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"
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
<manifest package="dev.inmo.postssystem.business_cases.post_creating.client"/>
|
@ -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
|
||||
}
|
||||
}
|
@ -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?
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package dev.inmo.postssystem.business_cases.post_creating.server
|
||||
|
||||
const val postCreatingRootRoute = "postCreating"
|
||||
|
||||
const val postCreatingCreatePostRoute = "createPost"
|
@ -1 +0,0 @@
|
||||
<manifest package="dev.inmo.postssystem.business_cases.post_creating.common"/>
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
41
client/build.gradle
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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) })
|
||||
}
|
||||
}
|
105
client/src/commonMain/kotlin/dev/inmo/postssystem/client/DI.kt
Normal file
105
client/src/commonMain/kotlin/dev/inmo/postssystem/client/DI.kt
Normal 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()) }
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
@ -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?
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
92
client/src/jsMain/kotlin/dev/inmo/postssystem/client/JSDI.kt
Normal file
92
client/src/jsMain/kotlin/dev/inmo/postssystem/client/JSDI.kt
Normal 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>())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
14
client/src/jsMain/kotlin/dev/inmo/postssystem/client/Main.kt
Normal file
14
client/src/jsMain/kotlin/dev/inmo/postssystem/client/Main.kt
Normal 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())
|
||||
})
|
||||
}
|
@ -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_"
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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")!!
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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"
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
34
client/src/jsMain/resources/index.html
Normal file
34
client/src/jsMain/resources/index.html
Normal 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>
|
10
client/src/jsMain/resources/js/material.min.js
vendored
Normal file
10
client/src/jsMain/resources/js/material.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
client/src/jsMain/resources/styles/containers.css
Normal file
5
client/src/jsMain/resources/styles/containers.css
Normal file
@ -0,0 +1,5 @@
|
||||
.vertical_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
8
client/src/jsMain/resources/styles/material.min.css
vendored
Normal file
8
client/src/jsMain/resources/styles/material.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
client/src/jsMain/resources/styles/visibility.css
Normal file
3
client/src/jsMain/resources/styles/visibility.css
Normal file
@ -0,0 +1,3 @@
|
||||
.gone {
|
||||
display: none;
|
||||
}
|
35
client/src/jvmMain/kotlin/dev/inmo/postssystem/client/CMD.kt
Normal file
35
client/src/jvmMain/kotlin/dev/inmo/postssystem/client/CMD.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
1
client/src/main/AndroidManifest.xml
Normal file
1
client/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
||||
<manifest package="dev.inmo.postssystem.client"/>
|
@ -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)
|
@ -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()
|
@ -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
|
||||
)
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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
|
||||
)
|
@ -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())
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
@ -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) }
|
||||
)
|
@ -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
|
@ -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)
|
@ -1,3 +0,0 @@
|
||||
package dev.inmo.postssystem.core.post.repo
|
||||
|
||||
interface PostsRepo : ReadPostsRepo, WritePostsRepo
|
@ -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
|
||||
)
|
@ -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
|
||||
}
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -1 +0,0 @@
|
||||
<manifest package="dev.inmo.postssystem.core.api"/>
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
test_sqlite_version=3.28.0
|
@ -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)
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
|
@ -1 +0,0 @@
|
||||
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
@ -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()
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
<manifest package="dev.inmo.postssystem.core.ktor.client"/>
|
@ -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"
|
@ -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"
|
@ -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())
|
@ -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
|
||||
)
|
@ -1 +0,0 @@
|
||||
<manifest package="dev.inmo.postssystem.core.ktor.common"/>
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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'
|
||||
}
|
||||
}
|
68
defaultAndroidSettings.gradle
Normal file
68
defaultAndroidSettings.gradle
Normal 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()
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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()
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
1
features/auth/client/src/main/AndroidManifest.xml
Normal file
1
features/auth/client/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
||||
<manifest package="dev.inmo.postssystem.features.auth.client"/>
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -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?
|
||||
}
|
@ -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
|
||||
)
|
@ -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"
|
1
features/auth/common/src/main/AndroidManifest.xml
Normal file
1
features/auth/common/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
||||
<manifest package="dev.inmo.postssystem.features.auth.common"/>
|
17
features/auth/server/build.gradle
Normal file
17
features/auth/server/build.gradle
Normal 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
Loading…
Reference in New Issue
Block a user