full reborn
This commit is contained in:
.github/workflows
LICENSEREADME.mdbuild.gradlebusiness_cases/post_creating
client
common
src
commonMain
kotlin
dev
inmo
postssystem
business_cases
post_creating
main
server
client
build.gradle
src
commonMain
kotlin
dev
inmo
jsMain
kotlin
dev
resources
jvmMain
kotlin
dev
inmo
postssystem
client
main
core
api
src
commonMain
kotlin
dev
inmo
postssystem
core
commonTest
kotlin
dev
inmo
postssystem
core
jvmMain
kotlin
dev
inmo
postssystem
core
content
api
business
content_adapters
binary
main
exposed
build.gradlegradle.properties
src
jvmMain
kotlin
dev
inmo
postssystem
core
exposed
jvmTest
kotlin
dev
inmo
postssystem
ktor
client
common
src
commonMain
kotlin
dev
inmo
postssystem
main
server
features
auth
client
common
server
common
client
common
build.gradle
src
commonMain
kotlin
dev
inmo
postssystem
features
common
jvmMain
kotlin
dev
inmo
postssystem
features
common
common
main
server
files
client
build.gradle
src
common
build.gradle
src
commonMain
kotlin
dev
inmo
postssystem
features
jvmMain
kotlin
dev
inmo
postssystem
features
main
server
roles
client
common
manager
client
common
server
server
status
template
client
common
server
users
client
common
build.gradle
src
commonMain
kotlin
dev
inmo
postssystem
features
users
jvmMain
kotlin
dev
inmo
postssystem
features
users
common
main
server
gradle/wrapper
gradlewgradlew.batmimes_generator
mppAndroidProject.gradlemppJavaProject.gradlemppJsProject.gradlemppProjectWithSerialization.gradlepubconf.kpsbpublish.gradlepublish.kpsbpublishing
api
src
commonMain
kotlin
main
exposed
ktor
client
src
commonMain
kotlin
com
insanusmokrassar
postssystem
main
common
src
commonMain
kotlin
com
insanusmokrassar
main
server
server
settings.gradle
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
|
||||
}
|
18
client/src/commonMain/kotlin/dev/inmo/postssystem/client/settings/auth/AuthSettings.kt
Normal file
18
client/src/commonMain/kotlin/dev/inmo/postssystem/client/settings/auth/AuthSettings.kt
Normal file
@ -0,0 +1,18 @@
|
||||
package dev.inmo.postssystem.client.settings.auth
|
||||
|
||||
import dev.inmo.postssystem.features.auth.client.ui.AuthUIError
|
||||
import dev.inmo.postssystem.features.auth.common.AuthCreds
|
||||
import dev.inmo.postssystem.features.roles.common.UserRole
|
||||
import dev.inmo.postssystem.features.users.common.User
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koin.core.module.Module
|
||||
|
||||
interface AuthSettings {
|
||||
val authorizedDIModule: StateFlow<Module?>
|
||||
val user: StateFlow<User?>
|
||||
val userRoles: StateFlow<List<UserRole>>
|
||||
val loadingJob: Job
|
||||
|
||||
suspend fun auth(serverUrl: String, creds: AuthCreds): AuthUIError?
|
||||
}
|
118
client/src/commonMain/kotlin/dev/inmo/postssystem/client/settings/auth/DefaultAuthSettings.kt
Normal file
118
client/src/commonMain/kotlin/dev/inmo/postssystem/client/settings/auth/DefaultAuthSettings.kt
Normal file
@ -0,0 +1,118 @@
|
||||
package dev.inmo.postssystem.client.settings.auth
|
||||
|
||||
import dev.inmo.postssystem.client.DBDropper
|
||||
import dev.inmo.postssystem.client.getAuthorizedFeaturesDIModule
|
||||
import dev.inmo.postssystem.features.auth.client.AuthUnavailableException
|
||||
import dev.inmo.postssystem.features.auth.client.ui.*
|
||||
import dev.inmo.postssystem.features.auth.common.*
|
||||
import dev.inmo.postssystem.features.roles.common.UserRole
|
||||
import dev.inmo.postssystem.features.roles.common.UsersRolesStorage
|
||||
import dev.inmo.postssystem.features.status.client.StatusFeatureClient
|
||||
import dev.inmo.postssystem.features.users.common.User
|
||||
import dev.inmo.micro_utils.common.Either
|
||||
import dev.inmo.micro_utils.common.either
|
||||
import dev.inmo.micro_utils.coroutines.plus
|
||||
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
|
||||
import dev.inmo.micro_utils.repos.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koin.core.Koin
|
||||
import org.koin.core.module.Module
|
||||
|
||||
data class DefaultAuthSettings(
|
||||
private val repo: KeyValueRepo<String, Any>,
|
||||
private val scope: CoroutineScope,
|
||||
private val koin: Koin,
|
||||
private val dbDropper: DBDropper
|
||||
) : AuthSettings {
|
||||
private val _authorizedDIModule = MutableStateFlow<Module?>(null)
|
||||
override val authorizedDIModule: StateFlow<Module?> = _authorizedDIModule.asStateFlow()
|
||||
private val _user = MutableStateFlow<User?>(null)
|
||||
override val user: StateFlow<User?> = _user.asStateFlow()
|
||||
private val _userRoles = MutableStateFlow<List<UserRole>>(emptyList())
|
||||
override val userRoles: StateFlow<List<UserRole>> = _userRoles.asStateFlow()
|
||||
|
||||
private suspend fun getCurrentServerURL() = repo.get(SERVER_URL_FIELD) as? String
|
||||
private suspend fun getCurrentUsername() = repo.get(USERNAME_FIELD) as? String
|
||||
private suspend fun getCurrentToken() = repo.get(TOKEN_FIELD) as? AuthTokenInfo
|
||||
|
||||
override val loadingJob: Job = scope.launch {
|
||||
val serverUrl = getCurrentServerURL() ?: return@launch
|
||||
val token = getCurrentToken() ?: return@launch
|
||||
updateModule(serverUrl, token.either())
|
||||
}
|
||||
|
||||
val rolesUpdatingJob = (user + authorizedDIModule).subscribeSafelyWithoutExceptions(scope) {
|
||||
val user = user.value
|
||||
|
||||
if (user == null || authorizedDIModule.value == null) {
|
||||
_userRoles.value = emptyList()
|
||||
} else {
|
||||
_userRoles.value = koin.get<UsersRolesStorage<UserRole>>().getRoles(user.id)
|
||||
}
|
||||
println(user)
|
||||
println(userRoles.value)
|
||||
}
|
||||
|
||||
override suspend fun auth(serverUrl: String, creds: AuthCreds): AuthUIError? {
|
||||
return runCatching {
|
||||
if (getCurrentServerURL() != serverUrl || getCurrentUsername() != creds.username.string) {
|
||||
dbDropper()
|
||||
}
|
||||
repo.set(SERVER_URL_FIELD, serverUrl)
|
||||
repo.set(USERNAME_FIELD, creds.username.string)
|
||||
repo.unset(TOKEN_FIELD)
|
||||
updateModule(serverUrl, creds.either())
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun updateModule(
|
||||
serverUrl: String,
|
||||
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
|
||||
): AuthUIError? {
|
||||
val currentModule = authorizedDIModule.value
|
||||
val newModule = getAuthorizedFeaturesDIModule(
|
||||
serverUrl,
|
||||
initialAuthKey,
|
||||
{
|
||||
repo.set(TOKEN_FIELD, it)
|
||||
},
|
||||
{
|
||||
_user.value = it
|
||||
}
|
||||
) {
|
||||
repo.unset(SERVER_URL_FIELD, USERNAME_FIELD, TOKEN_FIELD)
|
||||
_authorizedDIModule.value = null
|
||||
_user.value = null
|
||||
throw AuthUnavailableException
|
||||
}
|
||||
currentModule ?.let { koin.unloadModules(listOf(currentModule)) }
|
||||
koin.loadModules(listOf(newModule))
|
||||
val statusFeature = koin.get<StatusFeatureClient>()
|
||||
|
||||
val serverAvailable = statusFeature.checkServerStatus()
|
||||
val authCorrect = serverAvailable && runCatching {
|
||||
statusFeature.checkServerStatusWithAuth()
|
||||
}.getOrElse { false }
|
||||
if (!serverAvailable && !authCorrect) {
|
||||
koin.unloadModules(listOf(newModule))
|
||||
currentModule ?.let { koin.loadModules(listOf(currentModule)) }
|
||||
}
|
||||
return when {
|
||||
!serverAvailable -> ServerUnavailableAuthUIError
|
||||
!authCorrect -> AuthIncorrectAuthUIError
|
||||
else -> {
|
||||
_authorizedDIModule.value = newModule
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SERVER_URL_FIELD = "AuthServerURL"
|
||||
private const val USERNAME_FIELD = "AuthUsername"
|
||||
private const val TOKEN_FIELD = "AuthToken"
|
||||
}
|
||||
}
|
@ -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>
|
117
core/api/src/commonMain/kotlin/dev/inmo/postssystem/core/content/api/business/BusinessContentRepo.kt
117
core/api/src/commonMain/kotlin/dev/inmo/postssystem/core/content/api/business/BusinessContentRepo.kt
@ -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)
|
86
core/exposed/src/jvmTest/kotlin/dev/inmo/postssystem/core/exposed/ExposedContentAPICommonTests.kt
86
core/exposed/src/jvmTest/kotlin/dev/inmo/postssystem/core/exposed/ExposedContentAPICommonTests.kt
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
55
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientAuthFeature.kt
Normal file
55
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientAuthFeature.kt
Normal file
@ -0,0 +1,55 @@
|
||||
package dev.inmo.postssystem.features.auth.client
|
||||
|
||||
import dev.inmo.postssystem.features.auth.common.*
|
||||
import dev.inmo.postssystem.features.users.common.User
|
||||
import dev.inmo.micro_utils.ktor.client.UnifiedRequester
|
||||
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import kotlinx.serialization.builtins.nullable
|
||||
|
||||
class ClientAuthFeature(
|
||||
private val requester: UnifiedRequester,
|
||||
baseUrl: String
|
||||
) : AuthFeature {
|
||||
private val rootUrl = buildStandardUrl(baseUrl.dropLastWhile { it == '/' }, authRootPathPart)
|
||||
private val fullAuthPath = buildStandardUrl(
|
||||
rootUrl,
|
||||
authAuthPathPart
|
||||
)
|
||||
private val fullRefreshPath = buildStandardUrl(
|
||||
rootUrl,
|
||||
authRefreshPathPart
|
||||
)
|
||||
private val fullGetMePath = buildStandardUrl(
|
||||
rootUrl,
|
||||
authGetMePathPart
|
||||
)
|
||||
|
||||
constructor(client: HttpClient, rootUrl: String): this(
|
||||
UnifiedRequester(client),
|
||||
rootUrl
|
||||
)
|
||||
|
||||
override suspend fun auth(creds: AuthCreds): AuthTokenInfo? = requester.unipost(
|
||||
fullAuthPath,
|
||||
AuthCreds.serializer() to creds,
|
||||
AuthTokenInfo.serializer().nullable
|
||||
)
|
||||
|
||||
override suspend fun refresh(refresh: RefreshToken): AuthTokenInfo? = requester.unipost(
|
||||
fullRefreshPath,
|
||||
RefreshToken.serializer() to refresh,
|
||||
AuthTokenInfo.serializer().nullable
|
||||
)
|
||||
|
||||
override suspend fun getMe(authToken: AuthToken): User? = requester.unipost(
|
||||
fullGetMePath,
|
||||
AuthToken.serializer() to authToken,
|
||||
User.serializer().nullable
|
||||
)
|
||||
|
||||
fun isAuthRequest(builder: HttpRequestBuilder): Boolean = builder.url.buildString().let {
|
||||
it == fullAuthPath || it == fullRefreshPath
|
||||
}
|
||||
}
|
112
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientCookiesConfigurator.kt
Normal file
112
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientCookiesConfigurator.kt
Normal file
@ -0,0 +1,112 @@
|
||||
package dev.inmo.postssystem.features.auth.client
|
||||
|
||||
import dev.inmo.postssystem.features.auth.common.*
|
||||
import dev.inmo.postssystem.features.users.common.User
|
||||
import dev.inmo.micro_utils.common.*
|
||||
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.features.cookies.*
|
||||
import io.ktor.client.features.expectSuccess
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.HttpReceivePipeline
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
object AuthUnavailableException : Exception()
|
||||
|
||||
fun HttpClientConfig<*>.installClientAuthenticator(
|
||||
baseUrl: String,
|
||||
scope: CoroutineScope,
|
||||
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
|
||||
onAuthKeyUpdated: suspend (AuthTokenInfo) -> Unit,
|
||||
onUserRetrieved: suspend (User?) -> Unit,
|
||||
onAuthKeyInvalidated: suspend () -> Unit
|
||||
) {
|
||||
// install(Logging) {
|
||||
// logger = Logger.DEFAULT
|
||||
// level = LogLevel.HEADERS
|
||||
// }
|
||||
install(HttpCookies) {
|
||||
// Will keep an in-memory map with all the cookies from previous requests.
|
||||
storage = AcceptAllCookiesStorage()
|
||||
}
|
||||
|
||||
val authMutex = Mutex()
|
||||
var currentRefreshToken: RefreshToken? = null
|
||||
initialAuthKey.onFirst {
|
||||
currentRefreshToken = it as? RefreshToken
|
||||
}.onSecond {
|
||||
currentRefreshToken = it.refresh
|
||||
}
|
||||
val creds = initialAuthKey.t1 as? AuthCreds
|
||||
var userRefreshJob: Job? = null
|
||||
|
||||
install("Auth Token Refresher") {
|
||||
val clientAuthFeature = ClientAuthFeature(this, baseUrl)
|
||||
fun refreshUser(newTokenInfo: AuthTokenInfo) {
|
||||
userRefreshJob ?.cancel()
|
||||
userRefreshJob = scope.launchSafelyWithoutExceptions {
|
||||
onUserRetrieved(clientAuthFeature.getMe(newTokenInfo.token))
|
||||
}
|
||||
}
|
||||
initialAuthKey.onSecond { refreshUser(it) }
|
||||
|
||||
suspend fun refreshToken() {
|
||||
val capturedRefresh = currentRefreshToken
|
||||
|
||||
runCatching {
|
||||
when {
|
||||
capturedRefresh == null && creds == null -> throw AuthUnavailableException
|
||||
capturedRefresh != null -> {
|
||||
currentRefreshToken = null
|
||||
val newTokenInfo = clientAuthFeature.refresh(capturedRefresh)
|
||||
currentRefreshToken = newTokenInfo ?.refresh
|
||||
if (newTokenInfo == null) {
|
||||
refreshToken()
|
||||
} else {
|
||||
onAuthKeyUpdated(newTokenInfo)
|
||||
refreshUser(newTokenInfo)
|
||||
}
|
||||
}
|
||||
creds != null -> {
|
||||
val newAuthTokenInfo = clientAuthFeature.auth(creds)
|
||||
|
||||
if (newAuthTokenInfo != null) {
|
||||
onAuthKeyUpdated(newAuthTokenInfo)
|
||||
refreshUser(newAuthTokenInfo)
|
||||
currentRefreshToken = newAuthTokenInfo.refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
onAuthKeyInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
sendPipeline.intercept(HttpSendPipeline.State) {
|
||||
if (!context.url.buildString().startsWith(baseUrl) || clientAuthFeature.isAuthRequest(context)) {
|
||||
return@intercept
|
||||
}
|
||||
context.expectSuccess = false
|
||||
if (authMutex.isLocked) {
|
||||
authMutex.withLock { /* do nothing, just wait while mutex will be freed */ }
|
||||
}
|
||||
}
|
||||
|
||||
receivePipeline.intercept(HttpReceivePipeline.Before) {
|
||||
if (
|
||||
context.request.url.toString().startsWith(baseUrl)
|
||||
&& context.response.status == HttpStatusCode.Unauthorized
|
||||
) {
|
||||
authMutex.withLock { refreshToken() }
|
||||
val newResponse = context.client ?.request<HttpResponse>{
|
||||
takeFrom(context.request)
|
||||
} ?: return@intercept
|
||||
proceedWith(newResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIModel.kt
Normal file
8
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIModel.kt
Normal file
@ -0,0 +1,8 @@
|
||||
package dev.inmo.postssystem.features.auth.client.ui
|
||||
|
||||
import dev.inmo.postssystem.features.auth.common.AuthCreds
|
||||
import dev.inmo.postssystem.features.common.common.UIModel
|
||||
|
||||
interface AuthUIModel : UIModel<AuthUIState> {
|
||||
suspend fun initAuth(serverUrl: String, creds: AuthCreds)
|
||||
}
|
20
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIState.kt
Normal file
20
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIState.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package dev.inmo.postssystem.features.auth.client.ui
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class AuthUIError
|
||||
@Serializable
|
||||
object ServerUnavailableAuthUIError : AuthUIError()
|
||||
@Serializable
|
||||
object AuthIncorrectAuthUIError : AuthUIError()
|
||||
|
||||
@Serializable
|
||||
sealed class AuthUIState
|
||||
@Serializable
|
||||
data class InitAuthUIState(val showError: AuthUIError? = null) : AuthUIState()
|
||||
val DefaultInitAuthUIState = InitAuthUIState()
|
||||
@Serializable
|
||||
object LoadingAuthUIState : AuthUIState()
|
||||
@Serializable
|
||||
object AuthorizedAuthUIState : AuthUIState()
|
34
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIViewModel.kt
Normal file
34
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIViewModel.kt
Normal file
@ -0,0 +1,34 @@
|
||||
package dev.inmo.postssystem.features.auth.client.ui
|
||||
|
||||
import dev.inmo.postssystem.features.auth.common.AuthCreds
|
||||
import dev.inmo.postssystem.features.common.common.UIViewModel
|
||||
import dev.inmo.postssystem.features.users.common.Username
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class AuthUIViewModel(
|
||||
private val model: AuthUIModel
|
||||
) : UIViewModel<AuthUIState> {
|
||||
override val currentState: StateFlow<AuthUIState>
|
||||
get() = model.currentState
|
||||
|
||||
private fun checkIncomingData(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
password: String
|
||||
): Boolean {
|
||||
return serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||
}
|
||||
|
||||
suspend fun initAuth(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
password: String
|
||||
) {
|
||||
if (checkIncomingData(serverUrl, username, password)) {
|
||||
model.initAuth(
|
||||
serverUrl.takeIf { it.startsWith("http") } ?: "http://$serverUrl",
|
||||
AuthCreds(Username(username), password)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
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")
|
||||
}
|
||||
}
|
||||
}
|
9
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthFeature.kt
Normal file
9
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthFeature.kt
Normal file
@ -0,0 +1,9 @@
|
||||
package dev.inmo.postssystem.features.auth.common
|
||||
|
||||
import dev.inmo.postssystem.features.users.common.User
|
||||
|
||||
interface AuthFeature {
|
||||
suspend fun auth(creds: AuthCreds): AuthTokenInfo?
|
||||
suspend fun refresh(refresh: RefreshToken): AuthTokenInfo?
|
||||
suspend fun getMe(authToken: AuthToken): User?
|
||||
}
|
36
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthModels.kt
Normal file
36
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthModels.kt
Normal file
@ -0,0 +1,36 @@
|
||||
package dev.inmo.postssystem.features.auth.common
|
||||
|
||||
import com.benasher44.uuid.uuid4
|
||||
import dev.inmo.postssystem.features.users.common.Username
|
||||
import kotlinx.serialization.*
|
||||
import kotlin.jvm.JvmInline
|
||||
|
||||
sealed interface AuthKey
|
||||
|
||||
@Serializable
|
||||
@SerialName("authcreds")
|
||||
data class AuthCreds(
|
||||
val username: Username,
|
||||
val password: String
|
||||
): AuthKey
|
||||
|
||||
|
||||
@Serializable
|
||||
@SerialName("token")
|
||||
@JvmInline
|
||||
value class AuthToken(val string: String = uuid4().toString()): AuthKey {
|
||||
override fun toString(): String = string
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("refresh")
|
||||
@JvmInline
|
||||
value class RefreshToken(val string: String = uuid4().toString()): AuthKey {
|
||||
override fun toString(): String = string
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AuthTokenInfo(
|
||||
val token: AuthToken,
|
||||
val refresh: RefreshToken
|
||||
)
|
8
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/Constants.kt
Normal file
8
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/Constants.kt
Normal file
@ -0,0 +1,8 @@
|
||||
package dev.inmo.postssystem.features.auth.common
|
||||
|
||||
const val tokenSessionKey = "token"
|
||||
|
||||
const val authRootPathPart = "auth"
|
||||
const val authAuthPathPart = "auth"
|
||||
const val authRefreshPathPart = "refresh"
|
||||
const val authGetMePathPart = "getMe"
|
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
Reference in New Issue
Block a user