full reborn
This commit is contained in:
parent
0ac6b0a4df
commit
6a6a197041
10
.github/workflows/autopublish.yml
vendored
10
.github/workflows/autopublish.yml
vendored
@ -8,6 +8,15 @@ jobs:
|
|||||||
- uses: actions/setup-java@v1
|
- uses: actions/setup-java@v1
|
||||||
with:
|
with:
|
||||||
java-version: 1.8
|
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
|
- name: prebuild
|
||||||
run: ./gradlew clean build
|
run: ./gradlew clean build
|
||||||
- name: Publish package
|
- name: Publish package
|
||||||
@ -15,4 +24,3 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUBPACKAGES_USER: ${{ secrets.GITHUBPACKAGES_USER }}
|
GITHUBPACKAGES_USER: ${{ secrets.GITHUBPACKAGES_USER }}
|
||||||
GITHUBPACKAGES_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
|
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 {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
jcenter()
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
mavenLocal()
|
mavenLocal()
|
||||||
@ -8,11 +7,10 @@ buildscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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-gradle-plugin:$kotlin_version"
|
||||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||||
classpath "com.getkeepsafe.dexcount:dexcount-gradle-plugin:$dexcount_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"
|
classpath "org.jetbrains.dokka:dokka-gradle-plugin:$dokka_version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -20,12 +18,10 @@ buildscript {
|
|||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
mavenLocal()
|
mavenLocal()
|
||||||
jcenter()
|
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
google()
|
google()
|
||||||
maven { url "https://kotlin.bintray.com/kotlinx" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "./extensions.gradle"
|
apply from: "./extensions.gradle"
|
||||||
apply from: "./github_release.gradle"
|
// apply from: "./github_release.gradle"
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "org.jetbrains.kotlin.multiplatform"
|
|
||||||
id "org.jetbrains.kotlin.plugin.serialization"
|
|
||||||
id "com.android.library"
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "$mppProjectWithSerializationPresetPath"
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
sourceSets {
|
|
||||||
commonMain {
|
|
||||||
dependencies {
|
|
||||||
implementation kotlin('stdlib')
|
|
||||||
|
|
||||||
api project(":postssystem.business_cases.post_creating.common")
|
|
||||||
api project(":postssystem.core.ktor.client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
package dev.inmo.postssystem.business_cases.post_creating.client
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.ktor.client.*
|
|
||||||
import dev.inmo.postssystem.business_cases.post_creating.server.*
|
|
||||||
import dev.inmo.postssystem.core.content.Content
|
|
||||||
import dev.inmo.postssystem.core.post.RegisteredPost
|
|
||||||
import dev.inmo.postssystem.core.publishing.TriggerId
|
|
||||||
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import kotlinx.serialization.builtins.nullable
|
|
||||||
|
|
||||||
class PostCreatingClientCase(
|
|
||||||
private val baseUrl: String,
|
|
||||||
private val unifiedRequester: UnifiedRequester,
|
|
||||||
private val rootRoute: String? = postCreatingRootRoute
|
|
||||||
) : PostCreatingCase {
|
|
||||||
private val realBaseUrl = rootRoute ?.let { "$baseUrl/$rootRoute" } ?: baseUrl
|
|
||||||
override suspend fun createPost(
|
|
||||||
postContent: List<Content>,
|
|
||||||
triggerId: TriggerId?
|
|
||||||
): RegisteredPost? = unifiedRequester.unipost(
|
|
||||||
buildStandardUrl(realBaseUrl, postCreatingCreatePostRoute),
|
|
||||||
BodyPair(PostCreatingCreatePostModel.serializer(), PostCreatingCreatePostModel(postContent, triggerId)),
|
|
||||||
RegisteredPost.serializer().nullable
|
|
||||||
)
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
<manifest package="dev.inmo.postssystem.business_cases.post_creating.client"/>
|
|
@ -1,26 +0,0 @@
|
|||||||
package dev.inmo.postssystem.business_cases.post_creating.server
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.repos.set
|
|
||||||
import dev.inmo.postssystem.core.content.Content
|
|
||||||
import dev.inmo.postssystem.core.content.api.ContentRepo
|
|
||||||
import dev.inmo.postssystem.core.post.*
|
|
||||||
import dev.inmo.postssystem.core.post.repo.PostsRepo
|
|
||||||
import dev.inmo.postssystem.core.publishing.*
|
|
||||||
import dev.inmo.postssystem.core.publishing.repos.WriteTriggersToPostsRepo
|
|
||||||
|
|
||||||
class BusinessPostCreatingCase(
|
|
||||||
private val postsRepo: PostsRepo,
|
|
||||||
private val contentRepo: ContentRepo,
|
|
||||||
private val postsTriggersToPostsRepo: WriteTriggersToPostsRepo
|
|
||||||
) : PostCreatingCase {
|
|
||||||
override suspend fun createPost(postContent: List<Content>, triggerId: TriggerId?): RegisteredPost? {
|
|
||||||
val content = contentRepo.create(postContent)
|
|
||||||
val post = postsRepo.createPost(SimplePost(content.map { it.id })) ?: return null
|
|
||||||
|
|
||||||
triggerId ?.let {
|
|
||||||
postsTriggersToPostsRepo.set(post.id, triggerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return post
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
package dev.inmo.postssystem.business_cases.post_creating.server
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.content.Content
|
|
||||||
import dev.inmo.postssystem.core.post.RegisteredPost
|
|
||||||
import dev.inmo.postssystem.core.publishing.TriggerId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PostCreatingCreatePostModel(
|
|
||||||
val postContent: List<Content>,
|
|
||||||
val triggerId: TriggerId?
|
|
||||||
)
|
|
||||||
|
|
||||||
interface PostCreatingCase {
|
|
||||||
suspend fun createPost(
|
|
||||||
postContent: List<Content>,
|
|
||||||
triggerId: TriggerId? = null
|
|
||||||
): RegisteredPost?
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
package dev.inmo.postssystem.business_cases.post_creating.server
|
|
||||||
|
|
||||||
const val postCreatingRootRoute = "postCreating"
|
|
||||||
|
|
||||||
const val postCreatingCreatePostRoute = "createPost"
|
|
@ -1 +0,0 @@
|
|||||||
<manifest package="dev.inmo.postssystem.business_cases.post_creating.common"/>
|
|
@ -1,24 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "org.jetbrains.kotlin.multiplatform"
|
|
||||||
id "org.jetbrains.kotlin.plugin.serialization"
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "$mppJavaProjectPresetPath"
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
sourceSets {
|
|
||||||
commonMain {
|
|
||||||
dependencies {
|
|
||||||
api project(":postssystem.business_cases.post_creating.common")
|
|
||||||
api project(":postssystem.core.ktor.server")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jvmTest {
|
|
||||||
dependencies {
|
|
||||||
implementation "org.xerial:sqlite-jdbc:$test_sqlite_version"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-test"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-test-junit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
package dev.inmo.postssystem.business_cases.post_creating.server
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.ktor.server.*
|
|
||||||
import dev.inmo.postssystem.core.post.RegisteredPost
|
|
||||||
import io.ktor.application.call
|
|
||||||
import io.ktor.routing.*
|
|
||||||
import kotlinx.serialization.builtins.nullable
|
|
||||||
|
|
||||||
private inline fun Route.configurePostCreatingRoutes(
|
|
||||||
origin: PostCreatingCase,
|
|
||||||
unifiedRouter: UnifiedRouter
|
|
||||||
) {
|
|
||||||
post(postCreatingCreatePostRoute) {
|
|
||||||
unifiedRouter.apply {
|
|
||||||
val model = uniload(PostCreatingCreatePostModel.serializer())
|
|
||||||
|
|
||||||
unianswer(
|
|
||||||
RegisteredPost.serializer().nullable,
|
|
||||||
origin.createPost(model.postContent, model.triggerId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Route.configurePostCreatingRoutes(
|
|
||||||
origin: PostCreatingCase,
|
|
||||||
unifiedRouter: UnifiedRouter,
|
|
||||||
subroute: String? = postCreatingRootRoute
|
|
||||||
) {
|
|
||||||
subroute ?.also {
|
|
||||||
route(subroute) { configurePostCreatingRoutes(origin, unifiedRouter) }
|
|
||||||
} ?: configurePostCreatingRoutes(origin, unifiedRouter)
|
|
||||||
}
|
|
41
client/build.gradle
Normal file
41
client/build.gradle
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
plugins {
|
||||||
|
id "org.jetbrains.kotlin.multiplatform"
|
||||||
|
id "org.jetbrains.kotlin.plugin.serialization"
|
||||||
|
id "com.android.library"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$mppProjectWithSerializationPresetPath"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
js(IR) {
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
commonMain {
|
||||||
|
dependencies {
|
||||||
|
api project(":postssystem.features.common.client")
|
||||||
|
api project(":postssystem.features.status.client")
|
||||||
|
api project(":postssystem.features.files.client")
|
||||||
|
api project(":postssystem.features.users.client")
|
||||||
|
api project(":postssystem.features.auth.client")
|
||||||
|
api project(":postssystem.features.roles.client")
|
||||||
|
api project(":postssystem.features.roles.manager.client")
|
||||||
|
api "dev.inmo:micro_utils.fsm.common:$microutils_version"
|
||||||
|
api "dev.inmo:micro_utils.fsm.repos.common:$microutils_version"
|
||||||
|
api "dev.inmo:micro_utils.crypto:$microutils_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmMain {
|
||||||
|
dependencies {
|
||||||
|
api "io.ktor:ktor-client-apache:$ktor_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsMain {
|
||||||
|
dependencies {
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-html:$kotlinx_html_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import dev.inmo.micro_utils.pagination.utils.getAllByWithNextPaging
|
||||||
|
import dev.inmo.micro_utils.repos.KeyValueRepo
|
||||||
|
|
||||||
|
class DBDropper(
|
||||||
|
private val repo: KeyValueRepo<String, Any>
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke() {
|
||||||
|
repo.unset(repo.getAllByWithNextPaging { keys(it) })
|
||||||
|
}
|
||||||
|
}
|
105
client/src/commonMain/kotlin/dev/inmo/postssystem/client/DI.kt
Normal file
105
client/src/commonMain/kotlin/dev/inmo/postssystem/client/DI.kt
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.*
|
||||||
|
import dev.inmo.postssystem.features.auth.client.installClientAuthenticator
|
||||||
|
import dev.inmo.postssystem.features.auth.common.*
|
||||||
|
import dev.inmo.postssystem.features.files.client.ClientFilesStorage
|
||||||
|
import dev.inmo.postssystem.features.files.common.storage.FilesStorage
|
||||||
|
import dev.inmo.postssystem.features.roles.common.UserRole
|
||||||
|
import dev.inmo.postssystem.features.roles.common.UsersRolesStorage
|
||||||
|
import dev.inmo.postssystem.features.roles.client.ClientUsersRolesStorage
|
||||||
|
import dev.inmo.postssystem.features.roles.manager.common.RolesManagerRoleSerializer
|
||||||
|
import dev.inmo.postssystem.features.users.client.UsersStorageKtorClient
|
||||||
|
import dev.inmo.postssystem.features.users.common.ReadUsersStorage
|
||||||
|
import dev.inmo.postssystem.features.users.common.User
|
||||||
|
import dev.inmo.micro_utils.common.Either
|
||||||
|
import dev.inmo.micro_utils.coroutines.LinkedSupervisorScope
|
||||||
|
import dev.inmo.micro_utils.fsm.common.StatesMachine
|
||||||
|
import dev.inmo.micro_utils.fsm.common.dsl.FSMBuilder
|
||||||
|
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManagerRepo
|
||||||
|
import dev.inmo.micro_utils.ktor.client.UnifiedRequester
|
||||||
|
import dev.inmo.micro_utils.ktor.common.standardKtorSerialFormat
|
||||||
|
import dev.inmo.micro_utils.repos.KeyValueRepo
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.serialization.BinaryFormat
|
||||||
|
import kotlinx.serialization.StringFormat
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.Koin
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.qualifier.*
|
||||||
|
import org.koin.core.scope.Scope
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val UIScopeQualifier = StringQualifier("CoroutineScopeUI")
|
||||||
|
val SettingsQualifier = StringQualifier("Settings")
|
||||||
|
val UserRolesQualifier = StringQualifier("UserRoles")
|
||||||
|
private val DBDropperQualifier = StringQualifier("DBDropper")
|
||||||
|
private val FSMHandlersBuilderQualifier = StringQualifier("FSMHandlersBuilder")
|
||||||
|
|
||||||
|
val defaultSerialFormat = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrypoint for getting [org.koin.core.Koin] DI for the client
|
||||||
|
*
|
||||||
|
* @param repoFactory Factory for creating of [DefaultStatesManagerRepo] for [dev.inmo.postssystem.client.ui.fsm.UIFSM]
|
||||||
|
*/
|
||||||
|
fun baseKoin(
|
||||||
|
defaultScope: CoroutineScope,
|
||||||
|
settingsFactory: Scope.() -> KeyValueRepo<String, Any>,
|
||||||
|
repoFactory: Scope.() -> DefaultStatesManagerRepo<UIFSMState>,
|
||||||
|
handlersSetter: Pair<Scope, FSMBuilder<UIFSMState>>.() -> Unit
|
||||||
|
): Koin = startKoin {
|
||||||
|
modules(
|
||||||
|
module {
|
||||||
|
single<StringFormat> { defaultSerialFormat }
|
||||||
|
single(SettingsQualifier) { settingsFactory() }
|
||||||
|
single<DBDropper>(DBDropperQualifier) { DBDropper(get(SettingsQualifier)) }
|
||||||
|
single(FSMHandlersBuilderQualifier) { handlersSetter }
|
||||||
|
single { repoFactory() }
|
||||||
|
single { defaultScope }
|
||||||
|
single(UIScopeQualifier) { get<CoroutineScope>().LinkedSupervisorScope(Dispatchers.Main) }
|
||||||
|
single<StatesMachine<UIFSMState>>(UIFSMQualifier) { UIFSM(get()) { (this@single to this@UIFSM).apply(get(
|
||||||
|
FSMHandlersBuilderQualifier
|
||||||
|
)) } }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.koin.apply {
|
||||||
|
loadModules(
|
||||||
|
listOf(
|
||||||
|
module { single<Koin> { this@apply } }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RolesManagerRoleSerializer // Just to activate it in JS client
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuthorizedFeaturesDIModule(
|
||||||
|
serverUrl: String,
|
||||||
|
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
|
||||||
|
onAuthKeyUpdated: suspend (AuthTokenInfo) -> Unit,
|
||||||
|
onUserRetrieved: suspend (User?) -> Unit,
|
||||||
|
onAuthKeyInvalidated: suspend () -> Unit
|
||||||
|
): Module {
|
||||||
|
val serverUrlQualifier = StringQualifier("serverUrl")
|
||||||
|
val credsQualifier = StringQualifier("creds")
|
||||||
|
|
||||||
|
return module {
|
||||||
|
single(createdAtStart = true) {
|
||||||
|
HttpClient {
|
||||||
|
installClientAuthenticator(serverUrl, get(), get(credsQualifier), onAuthKeyUpdated, onUserRetrieved, onAuthKeyInvalidated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
single(credsQualifier) { initialAuthKey }
|
||||||
|
single(serverUrlQualifier) { serverUrl }
|
||||||
|
single<BinaryFormat> { standardKtorSerialFormat }
|
||||||
|
single { UnifiedRequester(get(), get()) }
|
||||||
|
|
||||||
|
single<FilesStorage> { ClientFilesStorage(get(serverUrlQualifier), get(), get()) }
|
||||||
|
single<ReadUsersStorage> { UsersStorageKtorClient(get(serverUrlQualifier), get()) }
|
||||||
|
single<UsersRolesStorage<UserRole>> { ClientUsersRolesStorage(get(serverUrlQualifier), get(), UserRole.serializer()) }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package dev.inmo.postssystem.client.settings
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.settings.auth.AuthSettings
|
||||||
|
|
||||||
|
|
||||||
|
data class DefaultSettings(
|
||||||
|
override val authSettings: AuthSettings
|
||||||
|
) : Settings
|
@ -0,0 +1,12 @@
|
|||||||
|
package dev.inmo.postssystem.client.settings
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.settings.auth.AuthSettings
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
val authSettings: AuthSettings
|
||||||
|
|
||||||
|
val authorizedDIModule: StateFlow<Module?>
|
||||||
|
get() = authSettings.authorizedDIModule
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package dev.inmo.postssystem.client.settings.auth
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.auth.client.ui.AuthUIError
|
||||||
|
import dev.inmo.postssystem.features.auth.common.AuthCreds
|
||||||
|
import dev.inmo.postssystem.features.roles.common.UserRole
|
||||||
|
import dev.inmo.postssystem.features.users.common.User
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
interface AuthSettings {
|
||||||
|
val authorizedDIModule: StateFlow<Module?>
|
||||||
|
val user: StateFlow<User?>
|
||||||
|
val userRoles: StateFlow<List<UserRole>>
|
||||||
|
val loadingJob: Job
|
||||||
|
|
||||||
|
suspend fun auth(serverUrl: String, creds: AuthCreds): AuthUIError?
|
||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||
|
package dev.inmo.postssystem.client.settings.auth
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.DBDropper
|
||||||
|
import dev.inmo.postssystem.client.getAuthorizedFeaturesDIModule
|
||||||
|
import dev.inmo.postssystem.features.auth.client.AuthUnavailableException
|
||||||
|
import dev.inmo.postssystem.features.auth.client.ui.*
|
||||||
|
import dev.inmo.postssystem.features.auth.common.*
|
||||||
|
import dev.inmo.postssystem.features.roles.common.UserRole
|
||||||
|
import dev.inmo.postssystem.features.roles.common.UsersRolesStorage
|
||||||
|
import dev.inmo.postssystem.features.status.client.StatusFeatureClient
|
||||||
|
import dev.inmo.postssystem.features.users.common.User
|
||||||
|
import dev.inmo.micro_utils.common.Either
|
||||||
|
import dev.inmo.micro_utils.common.either
|
||||||
|
import dev.inmo.micro_utils.coroutines.plus
|
||||||
|
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
|
||||||
|
import dev.inmo.micro_utils.repos.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import org.koin.core.Koin
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
data class DefaultAuthSettings(
|
||||||
|
private val repo: KeyValueRepo<String, Any>,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val koin: Koin,
|
||||||
|
private val dbDropper: DBDropper
|
||||||
|
) : AuthSettings {
|
||||||
|
private val _authorizedDIModule = MutableStateFlow<Module?>(null)
|
||||||
|
override val authorizedDIModule: StateFlow<Module?> = _authorizedDIModule.asStateFlow()
|
||||||
|
private val _user = MutableStateFlow<User?>(null)
|
||||||
|
override val user: StateFlow<User?> = _user.asStateFlow()
|
||||||
|
private val _userRoles = MutableStateFlow<List<UserRole>>(emptyList())
|
||||||
|
override val userRoles: StateFlow<List<UserRole>> = _userRoles.asStateFlow()
|
||||||
|
|
||||||
|
private suspend fun getCurrentServerURL() = repo.get(SERVER_URL_FIELD) as? String
|
||||||
|
private suspend fun getCurrentUsername() = repo.get(USERNAME_FIELD) as? String
|
||||||
|
private suspend fun getCurrentToken() = repo.get(TOKEN_FIELD) as? AuthTokenInfo
|
||||||
|
|
||||||
|
override val loadingJob: Job = scope.launch {
|
||||||
|
val serverUrl = getCurrentServerURL() ?: return@launch
|
||||||
|
val token = getCurrentToken() ?: return@launch
|
||||||
|
updateModule(serverUrl, token.either())
|
||||||
|
}
|
||||||
|
|
||||||
|
val rolesUpdatingJob = (user + authorizedDIModule).subscribeSafelyWithoutExceptions(scope) {
|
||||||
|
val user = user.value
|
||||||
|
|
||||||
|
if (user == null || authorizedDIModule.value == null) {
|
||||||
|
_userRoles.value = emptyList()
|
||||||
|
} else {
|
||||||
|
_userRoles.value = koin.get<UsersRolesStorage<UserRole>>().getRoles(user.id)
|
||||||
|
}
|
||||||
|
println(user)
|
||||||
|
println(userRoles.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun auth(serverUrl: String, creds: AuthCreds): AuthUIError? {
|
||||||
|
return runCatching {
|
||||||
|
if (getCurrentServerURL() != serverUrl || getCurrentUsername() != creds.username.string) {
|
||||||
|
dbDropper()
|
||||||
|
}
|
||||||
|
repo.set(SERVER_URL_FIELD, serverUrl)
|
||||||
|
repo.set(USERNAME_FIELD, creds.username.string)
|
||||||
|
repo.unset(TOKEN_FIELD)
|
||||||
|
updateModule(serverUrl, creds.either())
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}.getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateModule(
|
||||||
|
serverUrl: String,
|
||||||
|
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
|
||||||
|
): AuthUIError? {
|
||||||
|
val currentModule = authorizedDIModule.value
|
||||||
|
val newModule = getAuthorizedFeaturesDIModule(
|
||||||
|
serverUrl,
|
||||||
|
initialAuthKey,
|
||||||
|
{
|
||||||
|
repo.set(TOKEN_FIELD, it)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_user.value = it
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
repo.unset(SERVER_URL_FIELD, USERNAME_FIELD, TOKEN_FIELD)
|
||||||
|
_authorizedDIModule.value = null
|
||||||
|
_user.value = null
|
||||||
|
throw AuthUnavailableException
|
||||||
|
}
|
||||||
|
currentModule ?.let { koin.unloadModules(listOf(currentModule)) }
|
||||||
|
koin.loadModules(listOf(newModule))
|
||||||
|
val statusFeature = koin.get<StatusFeatureClient>()
|
||||||
|
|
||||||
|
val serverAvailable = statusFeature.checkServerStatus()
|
||||||
|
val authCorrect = serverAvailable && runCatching {
|
||||||
|
statusFeature.checkServerStatusWithAuth()
|
||||||
|
}.getOrElse { false }
|
||||||
|
if (!serverAvailable && !authCorrect) {
|
||||||
|
koin.unloadModules(listOf(newModule))
|
||||||
|
currentModule ?.let { koin.loadModules(listOf(currentModule)) }
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
!serverAvailable -> ServerUnavailableAuthUIError
|
||||||
|
!authCorrect -> AuthIncorrectAuthUIError
|
||||||
|
else -> {
|
||||||
|
_authorizedDIModule.value = newModule
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SERVER_URL_FIELD = "AuthServerURL"
|
||||||
|
private const val USERNAME_FIELD = "AuthUsername"
|
||||||
|
private const val TOKEN_FIELD = "AuthToken"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
package dev.inmo.postssystem.client.ui
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.settings.auth.AuthSettings
|
||||||
|
import dev.inmo.postssystem.features.auth.client.ui.*
|
||||||
|
import dev.inmo.postssystem.features.auth.common.AuthCreds
|
||||||
|
import dev.inmo.postssystem.features.common.common.AbstractUIModel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class DefaultAuthUIModel(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val authSettings: AuthSettings
|
||||||
|
) : AbstractUIModel<AuthUIState>(LoadingAuthUIState), AuthUIModel {
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
_currentState.value = LoadingAuthUIState
|
||||||
|
authSettings.loadingJob.join()
|
||||||
|
if (authSettings.authorizedDIModule.value == null) {
|
||||||
|
_currentState.value = DefaultInitAuthUIState
|
||||||
|
} else {
|
||||||
|
_currentState.value = AuthorizedAuthUIState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun initAuth(serverUrl: String, creds: AuthCreds) {
|
||||||
|
_currentState.value = LoadingAuthUIState
|
||||||
|
val authError = authSettings.auth(serverUrl, creds)
|
||||||
|
if (authError == null) {
|
||||||
|
_currentState.value = AuthorizedAuthUIState
|
||||||
|
} else {
|
||||||
|
_currentState.value = InitAuthUIState(authError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package dev.inmo.postssystem.client.ui.fsm
|
||||||
|
|
||||||
|
import dev.inmo.micro_utils.fsm.common.dsl.FSMBuilder
|
||||||
|
import dev.inmo.micro_utils.fsm.common.dsl.buildFSM
|
||||||
|
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManager
|
||||||
|
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManagerRepo
|
||||||
|
import org.koin.core.qualifier.StringQualifier
|
||||||
|
|
||||||
|
val UIFSMQualifier = StringQualifier("UIFSM")
|
||||||
|
|
||||||
|
fun UIFSM(
|
||||||
|
repo: DefaultStatesManagerRepo<UIFSMState>,
|
||||||
|
handlersSetter: FSMBuilder<UIFSMState>.() -> Unit
|
||||||
|
) = buildFSM<UIFSMState> {
|
||||||
|
statesManager = DefaultStatesManager(repo)
|
||||||
|
handlersSetter()
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package dev.inmo.postssystem.client.ui.fsm
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.auth.client.AuthUnavailableException
|
||||||
|
import dev.inmo.micro_utils.fsm.common.*
|
||||||
|
|
||||||
|
interface UIFSMHandler<T : UIFSMState> : StatesHandler<T, UIFSMState> {
|
||||||
|
suspend fun StatesMachine<in UIFSMState>.safeHandleState(state: T): UIFSMState?
|
||||||
|
override suspend fun StatesMachine<in UIFSMState>.handleState(state: T): UIFSMState? {
|
||||||
|
return runCatching {
|
||||||
|
safeHandleState(state).also(::println)
|
||||||
|
}.getOrElse {
|
||||||
|
errorToNextStep(state, it) ?.let { return it } ?: throw it
|
||||||
|
}.also(::println)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun errorToNextStep(
|
||||||
|
currentState: T,
|
||||||
|
e: Throwable
|
||||||
|
): UIFSMState? = when (e) {
|
||||||
|
is AuthUnavailableException -> if (currentState is AuthUIFSMState) {
|
||||||
|
currentState
|
||||||
|
} else {
|
||||||
|
AuthUIFSMState(currentState)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package dev.inmo.postssystem.client.ui.fsm
|
||||||
|
|
||||||
|
import dev.inmo.micro_utils.fsm.common.State
|
||||||
|
import dev.inmo.micro_utils.serialization.typed_serializer.TypedSerializer
|
||||||
|
import kotlinx.serialization.*
|
||||||
|
|
||||||
|
@Serializable(UIFSMStateSerializer::class)
|
||||||
|
sealed interface UIFSMState : State {
|
||||||
|
val from: UIFSMState?
|
||||||
|
get() = null
|
||||||
|
override val context: String
|
||||||
|
get() = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
object UIFSMStateSerializer : KSerializer<UIFSMState> by TypedSerializer(
|
||||||
|
"auth" to AuthUIFSMState.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthUIFSMState(
|
||||||
|
override val from: UIFSMState? = null,
|
||||||
|
override val context: String = "main"
|
||||||
|
) : UIFSMState
|
||||||
|
val DefaultAuthUIFSMState = AuthUIFSMState()
|
92
client/src/jsMain/kotlin/dev/inmo/postssystem/client/JSDI.kt
Normal file
92
client/src/jsMain/kotlin/dev/inmo/postssystem/client/JSDI.kt
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.fsm.ui.*
|
||||||
|
import dev.inmo.postssystem.client.ui.*
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.*
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.UIFSMStateSerializer
|
||||||
|
import dev.inmo.postssystem.features.auth.client.ui.AuthUIModel
|
||||||
|
import dev.inmo.postssystem.features.auth.client.ui.AuthUIViewModel
|
||||||
|
import dev.inmo.postssystem.features.auth.common.AuthTokenInfo
|
||||||
|
import dev.inmo.micro_utils.coroutines.ContextSafelyExceptionHandler
|
||||||
|
import dev.inmo.micro_utils.repos.mappers.withMapper
|
||||||
|
import dev.inmo.micro_utils.serialization.typed_serializer.TypedSerializer
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.browser.localStorage
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import org.koin.core.Koin
|
||||||
|
import org.koin.core.context.loadKoinModules
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
|
val defaultTypedSerializer = TypedSerializer<Any>(
|
||||||
|
"AuthTokenInfo" to AuthTokenInfo.serializer(),
|
||||||
|
"String" to String.serializer(),
|
||||||
|
"Int" to Int.serializer(),
|
||||||
|
"Long" to Long.serializer(),
|
||||||
|
"Short" to Short.serializer(),
|
||||||
|
"Byte" to Byte.serializer(),
|
||||||
|
"Float" to Float.serializer(),
|
||||||
|
"Double" to Double.serializer(),
|
||||||
|
"UIFSMState" to UIFSMStateSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
fun baseKoin(): Koin {
|
||||||
|
val anyToString: suspend Any.() -> String = {
|
||||||
|
defaultSerialFormat.encodeToString(
|
||||||
|
defaultTypedSerializer,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return baseKoin(
|
||||||
|
CoroutineScope(
|
||||||
|
Dispatchers.Default +
|
||||||
|
ContextSafelyExceptionHandler { it.printStackTrace() } +
|
||||||
|
CoroutineExceptionHandler { _, it -> it.printStackTrace() }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
CookiesKeyValueRepo.withMapper<String, Any, String, String>(
|
||||||
|
{ this },
|
||||||
|
{
|
||||||
|
runCatching {
|
||||||
|
anyToString()
|
||||||
|
}.getOrElse {
|
||||||
|
if (it is NoSuchElementException) {
|
||||||
|
val name = this::class.simpleName!!
|
||||||
|
defaultTypedSerializer.include(name, serializer())
|
||||||
|
anyToString()
|
||||||
|
} else {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ this },
|
||||||
|
{
|
||||||
|
defaultSerialFormat.decodeFromString(
|
||||||
|
defaultTypedSerializer,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OneStateUIFSMStatesRepo(get(), localStorage)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
first.apply {
|
||||||
|
second.apply {
|
||||||
|
loadKoinModules(
|
||||||
|
module {
|
||||||
|
factory { document.getElementById("main") as HTMLElement }
|
||||||
|
|
||||||
|
factory<AuthUIModel> { DefaultAuthUIModel(get(), get()) }
|
||||||
|
factory { AuthUIViewModel(get()) }
|
||||||
|
factory { AuthView(get(), get(UIScopeQualifier)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
strictlyOn<AuthUIFSMState>(get<AuthView>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
client/src/jsMain/kotlin/dev/inmo/postssystem/client/Main.kt
Normal file
14
client/src/jsMain/kotlin/dev/inmo/postssystem/client/Main.kt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.UIFSMQualifier
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.UIFSMState
|
||||||
|
import dev.inmo.micro_utils.fsm.common.StatesMachine
|
||||||
|
import kotlinx.browser.window
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
window.addEventListener("load", {
|
||||||
|
val koin = baseKoin()
|
||||||
|
val uiStatesMachine = koin.get<StatesMachine<UIFSMState>>(UIFSMQualifier)
|
||||||
|
uiStatesMachine.start(koin.get())
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.*
|
||||||
|
import dev.inmo.micro_utils.fsm.common.managers.DefaultStatesManagerRepo
|
||||||
|
import kotlinx.serialization.StringFormat
|
||||||
|
import org.w3c.dom.*
|
||||||
|
|
||||||
|
class OneStateUIFSMStatesRepo(
|
||||||
|
private val serialFormat: StringFormat,
|
||||||
|
private val storage: Storage,
|
||||||
|
private val initialState: UIFSMState = DefaultAuthUIFSMState
|
||||||
|
) : DefaultStatesManagerRepo<UIFSMState> {
|
||||||
|
private val String.storageKey
|
||||||
|
get() = "${FSMStateSettingsFieldPrefix}$this"
|
||||||
|
private val String.UIFSMState
|
||||||
|
get() = runCatching {
|
||||||
|
serialFormat.decodeFromString(UIFSMStateSerializer, this)
|
||||||
|
}.onFailure { it.printStackTrace() }.getOrNull()
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (states().isEmpty()) {
|
||||||
|
setState(initialState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setState(state: UIFSMState) {
|
||||||
|
storage[state.context.storageKey] = serialFormat.encodeToString(UIFSMStateSerializer, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getContextState(context: Any): UIFSMState? {
|
||||||
|
return when (context) {
|
||||||
|
is String -> storage[context.storageKey] ?.UIFSMState ?: return DefaultAuthUIFSMState
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun contains(context: Any): Boolean = when (context) {
|
||||||
|
is String -> storage.get(context) ?.UIFSMState != null
|
||||||
|
else -> super.contains(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun states(): List<UIFSMState> = storage.iterator().asSequence().mapNotNull { (k, v) ->
|
||||||
|
if (k.startsWith(FSMStateSettingsFieldPrefix)) {
|
||||||
|
v.UIFSMState
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toList()
|
||||||
|
|
||||||
|
override suspend fun getStates(): List<UIFSMState> = states()
|
||||||
|
|
||||||
|
override suspend fun removeState(state: UIFSMState) {
|
||||||
|
storage.removeItem((state.context as? String) ?.storageKey ?: return)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun set(state: UIFSMState) {
|
||||||
|
setState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FSMStateSettingsFieldPrefix = "UIFSMState_"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import dev.inmo.micro_utils.pagination.Pagination
|
||||||
|
import dev.inmo.micro_utils.pagination.PaginationResult
|
||||||
|
import dev.inmo.micro_utils.pagination.utils.paginate
|
||||||
|
import dev.inmo.micro_utils.pagination.utils.reverse
|
||||||
|
import dev.inmo.micro_utils.repos.KeyValueRepo
|
||||||
|
import kotlinx.browser.localStorage
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import org.w3c.dom.get
|
||||||
|
import org.w3c.dom.set
|
||||||
|
|
||||||
|
object CookiesKeyValueRepo : KeyValueRepo<String, String> {
|
||||||
|
private val _onNewValue = MutableSharedFlow<Pair<String, String>>()
|
||||||
|
private val _onValueRemoved = MutableSharedFlow<String>()
|
||||||
|
override val onNewValue: Flow<Pair<String, String>> = _onNewValue.asSharedFlow()
|
||||||
|
override val onValueRemoved: Flow<String> = _onValueRemoved.asSharedFlow()
|
||||||
|
|
||||||
|
override suspend fun contains(key: String): Boolean = localStorage.iterator().asSequence().any { it.first == key }
|
||||||
|
|
||||||
|
override suspend fun count(): Long = localStorage.length.toLong()
|
||||||
|
|
||||||
|
override suspend fun get(k: String): String? = localStorage[k]
|
||||||
|
|
||||||
|
override suspend fun keys(
|
||||||
|
v: String,
|
||||||
|
pagination: Pagination,
|
||||||
|
reversed: Boolean
|
||||||
|
): PaginationResult<String> = localStorage.iterator().asSequence().mapNotNull {
|
||||||
|
if (it.second == v) it.first else null
|
||||||
|
}.toList().let {
|
||||||
|
it.paginate(
|
||||||
|
if (reversed) {
|
||||||
|
pagination.reverse(it.count())
|
||||||
|
} else {
|
||||||
|
pagination
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun keys(
|
||||||
|
pagination: Pagination,
|
||||||
|
reversed: Boolean
|
||||||
|
): PaginationResult<String> = localStorage.iterator().asSequence().map { it.first }.toList().let {
|
||||||
|
it.paginate(
|
||||||
|
if (reversed) {
|
||||||
|
pagination.reverse(it.count())
|
||||||
|
} else {
|
||||||
|
pagination
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun values(
|
||||||
|
pagination: Pagination,
|
||||||
|
reversed: Boolean
|
||||||
|
): PaginationResult<String> = localStorage.iterator().asSequence().map { it.second }.toList().let {
|
||||||
|
it.paginate(
|
||||||
|
if (reversed) {
|
||||||
|
pagination.reverse(it.count())
|
||||||
|
} else {
|
||||||
|
pagination
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun set(toSet: Map<String, String>) {
|
||||||
|
toSet.forEach { (k, v) ->
|
||||||
|
localStorage[k] = v
|
||||||
|
_onNewValue.emit(k to v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unset(toUnset: List<String>) {
|
||||||
|
toUnset.forEach {
|
||||||
|
localStorage[it] ?.let { _ ->
|
||||||
|
localStorage.removeItem(it)
|
||||||
|
_onValueRemoved.emit(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import org.w3c.dom.Storage
|
||||||
|
import org.w3c.dom.get
|
||||||
|
|
||||||
|
class StorageIterator(private val storage: Storage) : Iterator<Pair<String, String>> {
|
||||||
|
private var index = 0
|
||||||
|
|
||||||
|
override fun hasNext(): Boolean = index < storage.length
|
||||||
|
|
||||||
|
override fun next(): Pair<String, String> {
|
||||||
|
val k = storage.key(index) ?: error("Key for index $index was not found")
|
||||||
|
val v = storage[k] ?: error("Key for index $index was not found")
|
||||||
|
index++
|
||||||
|
return k to v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun Storage.iterator() = StorageIterator(this)
|
@ -0,0 +1,120 @@
|
|||||||
|
package dev.inmo.postssystem.client.fsm.ui
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.*
|
||||||
|
import dev.inmo.postssystem.client.utils.HTMLViewContainer
|
||||||
|
import dev.inmo.postssystem.features.auth.client.ui.*
|
||||||
|
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
|
||||||
|
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
|
||||||
|
import dev.inmo.micro_utils.fsm.common.StatesMachine
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.dom.clear
|
||||||
|
import kotlinx.html.*
|
||||||
|
import kotlinx.html.dom.append
|
||||||
|
import kotlinx.html.js.form
|
||||||
|
import kotlinx.html.js.onClickFunction
|
||||||
|
import org.w3c.dom.*
|
||||||
|
|
||||||
|
class AuthView(
|
||||||
|
private val viewModel: AuthUIViewModel,
|
||||||
|
private val uiScope: CoroutineScope
|
||||||
|
) : JSView<AuthUIFSMState>() {
|
||||||
|
private val usernameInput
|
||||||
|
get() = document.getElementById("authUsername") as? HTMLInputElement
|
||||||
|
private val passwordInput
|
||||||
|
get() = document.getElementById("authPassword") as? HTMLInputElement
|
||||||
|
private val authButton
|
||||||
|
get() = document.getElementById("authButton")
|
||||||
|
private val errorBadge
|
||||||
|
get() = document.getElementById("errorBadge") as? HTMLElement
|
||||||
|
private val progressBarDiv
|
||||||
|
get() = document.getElementById("progressBar") as? HTMLDivElement
|
||||||
|
|
||||||
|
override suspend fun StatesMachine<in UIFSMState>.safeHandleState(
|
||||||
|
htmlElement: HTMLElement,
|
||||||
|
container: HTMLViewContainer,
|
||||||
|
state: AuthUIFSMState
|
||||||
|
): UIFSMState? {
|
||||||
|
val completion = CompletableDeferred<UIFSMState?>()
|
||||||
|
htmlElement.clear()
|
||||||
|
|
||||||
|
htmlElement.append {
|
||||||
|
form(classes = "vertical_container") {
|
||||||
|
div(classes = "mdl-textfield mdl-js-textfield mdl-textfield--floating-label") {
|
||||||
|
input(type = InputType.text, classes = "mdl-textfield__input") {
|
||||||
|
id = "authUsername"
|
||||||
|
}
|
||||||
|
label(classes = "mdl-textfield__label") {
|
||||||
|
+"Имя пользователя"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(classes = "mdl-textfield mdl-js-textfield mdl-textfield--floating-label") {
|
||||||
|
input(type = InputType.password, classes = "mdl-textfield__input") {
|
||||||
|
id = "authPassword"
|
||||||
|
}
|
||||||
|
label(classes = "mdl-textfield__label") {
|
||||||
|
+"Пароль"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(classes = "mdl-progress mdl-js-progress") {
|
||||||
|
id = "progressBar"
|
||||||
|
}
|
||||||
|
span(classes = "material-icons mdl-badge mdl-badge--overlap gone") {
|
||||||
|
id = "errorBadge"
|
||||||
|
attributes["data-badge"] = "!"
|
||||||
|
}
|
||||||
|
button(classes = "mdl-button mdl-js-button mdl-button--raised") {
|
||||||
|
+"Авторизоваться"
|
||||||
|
id = "authButton"
|
||||||
|
onClickFunction = {
|
||||||
|
it.preventDefault()
|
||||||
|
val serverUrl = document.location ?.run { "$hostname:$port" }
|
||||||
|
val username = usernameInput ?.value
|
||||||
|
val password = passwordInput ?.value
|
||||||
|
if (serverUrl != null && username != null && password != null) {
|
||||||
|
uiScope.launchSafelyWithoutExceptions { viewModel.initAuth(serverUrl, username, password) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewJob = viewModel.currentState.subscribeSafelyWithoutExceptions(uiScope) {
|
||||||
|
when (it) {
|
||||||
|
is InitAuthUIState -> {
|
||||||
|
usernameInput ?.removeAttribute("disabled")
|
||||||
|
passwordInput ?.removeAttribute("disabled")
|
||||||
|
authButton ?.removeAttribute("disabled")
|
||||||
|
errorBadge ?.apply {
|
||||||
|
when (it.showError) {
|
||||||
|
ServerUnavailableAuthUIError -> {
|
||||||
|
classList.remove("gone")
|
||||||
|
innerText = "Сервер недоступен"
|
||||||
|
}
|
||||||
|
AuthIncorrectAuthUIError -> {
|
||||||
|
classList.remove("gone")
|
||||||
|
innerText = "Данные некорректны"
|
||||||
|
}
|
||||||
|
null -> classList.add("gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progressBarDiv ?.classList ?.add("gone")
|
||||||
|
}
|
||||||
|
LoadingAuthUIState -> {
|
||||||
|
usernameInput ?.setAttribute("disabled", "")
|
||||||
|
passwordInput ?.setAttribute("disabled", "")
|
||||||
|
authButton ?.setAttribute("disabled", "")
|
||||||
|
errorBadge ?.classList ?.add("gone")
|
||||||
|
progressBarDiv ?.classList ?.remove("gone")
|
||||||
|
}
|
||||||
|
AuthorizedAuthUIState -> {
|
||||||
|
htmlElement.clear()
|
||||||
|
completion.complete(state.from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completion.await().also {
|
||||||
|
viewJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package dev.inmo.postssystem.client.fsm.ui
|
||||||
|
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
|
||||||
|
val mainContainer: Element
|
||||||
|
get() = document.getElementById("main")!!
|
@ -0,0 +1,21 @@
|
|||||||
|
package dev.inmo.postssystem.client.fsm.ui
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.UIFSMHandler
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.UIFSMState
|
||||||
|
import dev.inmo.postssystem.client.utils.HTMLViewContainer
|
||||||
|
import dev.inmo.micro_utils.fsm.common.StatesMachine
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
|
abstract class JSView<T : UIFSMState> : UIFSMHandler<T> {
|
||||||
|
open suspend fun StatesMachine<in UIFSMState>.safeHandleState(
|
||||||
|
htmlElement: HTMLElement,
|
||||||
|
container: HTMLViewContainer,
|
||||||
|
state: T
|
||||||
|
): UIFSMState? = null
|
||||||
|
|
||||||
|
override suspend fun StatesMachine<in UIFSMState>.safeHandleState(state: T): UIFSMState? {
|
||||||
|
return HTMLViewContainer.from(state.context) ?.let {
|
||||||
|
safeHandleState(it.htmlElement ?: return null, it, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package dev.inmo.postssystem.client.fsm.ui.defaults
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.client.ui.fsm.UIFSMState
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.html.TagConsumer
|
||||||
|
import kotlinx.html.js.button
|
||||||
|
import kotlinx.html.js.onClickFunction
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
|
fun TagConsumer<HTMLElement>.addBackButton(
|
||||||
|
completableDeferred: CompletableDeferred<UIFSMState>,
|
||||||
|
stateToBack: UIFSMState
|
||||||
|
) {
|
||||||
|
button {
|
||||||
|
+"Назад"
|
||||||
|
onClickFunction = {
|
||||||
|
completableDeferred.complete(stateToBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
package dev.inmo.postssystem.client.utils
|
||||||
|
|
||||||
|
import com.benasher44.uuid.uuid4
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.html.*
|
||||||
|
import kotlinx.html.dom.append
|
||||||
|
import kotlinx.html.js.*
|
||||||
|
import org.w3c.dom.*
|
||||||
|
|
||||||
|
object DialogHelper {
|
||||||
|
fun createOneFieldDialog(
|
||||||
|
title: String,
|
||||||
|
hint: String,
|
||||||
|
doneButtonText: String,
|
||||||
|
closeButtonText: String,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onSubmit: (String) -> Unit
|
||||||
|
): HTMLDialogElement {
|
||||||
|
lateinit var dialogElement: HTMLDialogElement
|
||||||
|
(document.getElementsByTagName("body").item(0) as? HTMLBodyElement) ?.append {
|
||||||
|
dialogElement = dialog("mdl-dialog") {
|
||||||
|
h4("mdl-dialog__title") {
|
||||||
|
+title
|
||||||
|
}
|
||||||
|
val id = "form_${uuid4()}_text"
|
||||||
|
div(classes = "mdl-dialog__content") {
|
||||||
|
form("#") {
|
||||||
|
div("mdl-textfield mdl-js-textfield mdl-textfield--floating-label") {
|
||||||
|
input(InputType.text, classes = "mdl-textfield__input") {
|
||||||
|
this.id = id
|
||||||
|
}
|
||||||
|
label(classes = "mdl-textfield__label") {
|
||||||
|
+hint
|
||||||
|
attributes["for"] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(classes = "mdl-dialog__actions mdl-dialog__actions--full-width") {
|
||||||
|
button(classes = "mdl-button", type = ButtonType.button) {
|
||||||
|
+doneButtonText
|
||||||
|
onClickFunction = {
|
||||||
|
it.preventDefault()
|
||||||
|
|
||||||
|
val input = document.getElementById(id) as? HTMLInputElement
|
||||||
|
input ?.value ?.let {
|
||||||
|
onSubmit(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button(classes = "mdl-button", type = ButtonType.button) {
|
||||||
|
+closeButtonText
|
||||||
|
onClickFunction = {
|
||||||
|
it.preventDefault()
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dialogElement
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package dev.inmo.postssystem.client.utils
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.files.common.FullFileInfo
|
||||||
|
import dev.inmo.micro_utils.common.toArrayBuffer
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import org.w3c.dom.HTMLAnchorElement
|
||||||
|
import org.w3c.dom.url.URL
|
||||||
|
import org.w3c.files.Blob
|
||||||
|
|
||||||
|
fun triggerDownloadFile(fullFileInfo: FullFileInfo) {
|
||||||
|
val hiddenElement = document.createElement("a") as HTMLAnchorElement
|
||||||
|
|
||||||
|
val url = URL.createObjectURL(Blob(arrayOf(fullFileInfo.byteArrayAllocator().toArrayBuffer())))
|
||||||
|
hiddenElement.href = url
|
||||||
|
hiddenElement.target = "_blank"
|
||||||
|
hiddenElement.download = fullFileInfo.name.name
|
||||||
|
hiddenElement.click()
|
||||||
|
}
|
@ -0,0 +1,134 @@
|
|||||||
|
package dev.inmo.postssystem.client.utils
|
||||||
|
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.dom.clear
|
||||||
|
import kotlinx.html.dom.append
|
||||||
|
import kotlinx.html.id
|
||||||
|
import kotlinx.html.js.*
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
import org.w3c.dom.events.Event
|
||||||
|
|
||||||
|
object HTMLViewsConstants {
|
||||||
|
val mainContainer: MainHTMLViewContainer = MainHTMLViewContainer
|
||||||
|
val processesContainer: DrawerHTMLViewContainer = DrawerHTMLViewContainer
|
||||||
|
val toolsContainerId: ToolsHTMLViewContainer = ToolsHTMLViewContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface HTMLViewContainer {
|
||||||
|
val htmlElement: HTMLElement?
|
||||||
|
get() = document.getElementById(id) as? HTMLElement
|
||||||
|
val id: String
|
||||||
|
|
||||||
|
fun setIsLoading() {
|
||||||
|
htmlElement ?.apply {
|
||||||
|
clear()
|
||||||
|
append {
|
||||||
|
div("mdl-spinner mdl-js-spinner is-active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(elementId: String) = when (elementId) {
|
||||||
|
MainHTMLViewContainer.id -> MainHTMLViewContainer
|
||||||
|
DrawerHTMLViewContainer.id -> DrawerHTMLViewContainer
|
||||||
|
ToolsHTMLViewContainer.id -> ToolsHTMLViewContainer
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object MainHTMLViewContainer : HTMLViewContainer {
|
||||||
|
override val id: String
|
||||||
|
get() = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
object DrawerHTMLViewContainer : HTMLViewContainer {
|
||||||
|
data class DrawerAddButtonInfo(
|
||||||
|
val text: String,
|
||||||
|
val onAddButtonClick: (Event) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
override val id: String
|
||||||
|
get() = "drawer"
|
||||||
|
|
||||||
|
private val titleElement: HTMLElement?
|
||||||
|
get() = (document.getElementById("drawerTitle") ?:let {
|
||||||
|
htmlElement ?.append {
|
||||||
|
span("mdl-layout-title") {
|
||||||
|
id = "drawerTitle"
|
||||||
|
}
|
||||||
|
} ?.first()
|
||||||
|
}) as? HTMLElement
|
||||||
|
var title: String?
|
||||||
|
get() = titleElement ?.textContent
|
||||||
|
set(value) {
|
||||||
|
if (value == null) {
|
||||||
|
titleElement ?.classList ?.add("gone")
|
||||||
|
} else {
|
||||||
|
val element = titleElement ?: return
|
||||||
|
element.textContent = value
|
||||||
|
element.classList.remove("gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val contentElement
|
||||||
|
get() = (document.getElementById("drawerContent") ?:let {
|
||||||
|
htmlElement ?.append {
|
||||||
|
nav("mdl-navigation") {
|
||||||
|
id = "drawerContent"
|
||||||
|
}
|
||||||
|
} ?.first()
|
||||||
|
}) as? HTMLElement
|
||||||
|
|
||||||
|
fun <T> setListContent(
|
||||||
|
title: String?,
|
||||||
|
data: Iterable<T>,
|
||||||
|
getText: (T) -> String?,
|
||||||
|
addButtonInfo: DrawerAddButtonInfo? = null,
|
||||||
|
onClick: (T) -> Unit
|
||||||
|
) {
|
||||||
|
this.title = title
|
||||||
|
val contentElement = contentElement ?: return
|
||||||
|
|
||||||
|
contentElement.clear()
|
||||||
|
contentElement.append {
|
||||||
|
fun hideDrawer() {
|
||||||
|
// Emulate clicking for hiding of drawer
|
||||||
|
(document.getElementsByClassName("mdl-layout__obfuscator").item(0) as? HTMLElement) ?.click()
|
||||||
|
}
|
||||||
|
data.forEach {
|
||||||
|
val elementTitle = getText(it) ?: return@forEach
|
||||||
|
div("mdl-navigation__link") {
|
||||||
|
+elementTitle
|
||||||
|
onClickFunction = { _ ->
|
||||||
|
onClick(it)
|
||||||
|
hideDrawer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (addButtonInfo != null) {
|
||||||
|
button(classes = "mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--accent") {
|
||||||
|
+addButtonInfo.text
|
||||||
|
onClickFunction = {
|
||||||
|
addButtonInfo.onAddButtonClick(it)
|
||||||
|
hideDrawer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setIsLoading() {
|
||||||
|
contentElement ?.apply {
|
||||||
|
clear()
|
||||||
|
append {
|
||||||
|
div("mdl-spinner mdl-js-spinner is-active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object ToolsHTMLViewContainer : HTMLViewContainer {
|
||||||
|
override val id: String
|
||||||
|
get() = "tools"
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package dev.inmo.postssystem.client.utils
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.files.common.FullFileInfo
|
||||||
|
import dev.inmo.micro_utils.common.*
|
||||||
|
import dev.inmo.micro_utils.mime_types.KnownMimeTypes
|
||||||
|
import dev.inmo.micro_utils.mime_types.findBuiltinMimeType
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.khronos.webgl.ArrayBuffer
|
||||||
|
import org.w3c.dom.HTMLInputElement
|
||||||
|
import org.w3c.dom.events.Event
|
||||||
|
import org.w3c.files.FileReader
|
||||||
|
import org.w3c.files.get
|
||||||
|
|
||||||
|
fun uploadFileCallbackForHTMLInputChange(
|
||||||
|
output: MutableStateFlow<FullFileInfo?>,
|
||||||
|
scope: CoroutineScope
|
||||||
|
): (Event) -> Unit = {
|
||||||
|
(it.target as? HTMLInputElement) ?.apply {
|
||||||
|
files ?.also { files ->
|
||||||
|
files[0] ?.also { file ->
|
||||||
|
scope.launch {
|
||||||
|
val reader: FileReader = FileReader()
|
||||||
|
|
||||||
|
reader.onload = {
|
||||||
|
val bytes = ((it.target.asDynamic()).result as ArrayBuffer).toByteArray()
|
||||||
|
output.value = FullFileInfo(
|
||||||
|
FileName(file.name),
|
||||||
|
findBuiltinMimeType(file.type) ?: KnownMimeTypes.Any,
|
||||||
|
bytes.asAllocator
|
||||||
|
)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
client/src/jsMain/resources/index.html
Normal file
34
client/src/jsMain/resources/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>PostsSystem</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" type="text/css">
|
||||||
|
<link rel="stylesheet" href="styles/material.min.css" type="text/css">
|
||||||
|
<link rel="stylesheet" href="styles/containers.css" type="text/css">
|
||||||
|
<link rel="stylesheet" href="styles/visibility.css" type="text/css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Always shows a header, even in smaller screens. -->
|
||||||
|
<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
|
||||||
|
<header class="mdl-layout__header">
|
||||||
|
<div class="mdl-layout__header-row">
|
||||||
|
<!-- Title -->
|
||||||
|
<span class="mdl-layout-title">Posts System</span>
|
||||||
|
<!-- Add spacer, to align navigation to the right -->
|
||||||
|
<div class="mdl-layout-spacer"></div>
|
||||||
|
<!-- Navigation. We hide it in small screens. -->
|
||||||
|
<nav id="tools" class="mdl-navigation mdl-layout--large-screen-only"></nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mdl-layout__content">
|
||||||
|
<div id="main" class="page-content"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="application/javascript" defer src="js/material.min.js"></script>
|
||||||
|
<script type="application/javascript" src="postssystem.client.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
10
client/src/jsMain/resources/js/material.min.js
vendored
Normal file
10
client/src/jsMain/resources/js/material.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
client/src/jsMain/resources/styles/containers.css
Normal file
5
client/src/jsMain/resources/styles/containers.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.vertical_container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
8
client/src/jsMain/resources/styles/material.min.css
vendored
Normal file
8
client/src/jsMain/resources/styles/material.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
client/src/jsMain/resources/styles/visibility.css
Normal file
3
client/src/jsMain/resources/styles/visibility.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.gone {
|
||||||
|
display: none;
|
||||||
|
}
|
35
client/src/jvmMain/kotlin/dev/inmo/postssystem/client/CMD.kt
Normal file
35
client/src/jvmMain/kotlin/dev/inmo/postssystem/client/CMD.kt
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package dev.inmo.postssystem.client
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.users.common.ReadUsersStorage
|
||||||
|
import dev.inmo.micro_utils.repos.pagination.getAll
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
fun readLine(suggestionText: String): String {
|
||||||
|
while (true) {
|
||||||
|
println(suggestionText)
|
||||||
|
readLine() ?.let { return it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun main(args: Array<String>) {
|
||||||
|
val serverUrl = readLine("Server url:")
|
||||||
|
val login = readLine("Username:")
|
||||||
|
val password = readLine("Password:")
|
||||||
|
|
||||||
|
val koin = startKoin {
|
||||||
|
// modules(getAuthorizedFeaturesDIModule(serverUrl, AuthCreds(Username(login), password)))
|
||||||
|
}.koin
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val chosen = readLine(
|
||||||
|
"""
|
||||||
|
Choose action:
|
||||||
|
1. Show server users
|
||||||
|
""".trimIndent()
|
||||||
|
).toIntOrNull()
|
||||||
|
when (chosen) {
|
||||||
|
1 -> println(koin.get<ReadUsersStorage>().getAll { getByPagination(it) })
|
||||||
|
else -> println("Sorry, I didn't understand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
client/src/main/AndroidManifest.xml
Normal file
1
client/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
|||||||
|
<manifest package="dev.inmo.postssystem.client"/>
|
@ -1,7 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core
|
|
||||||
|
|
||||||
import com.soywiz.klock.DateTime
|
|
||||||
|
|
||||||
typealias UnixMillis = Double
|
|
||||||
val MIN_DATE = DateTime(Double.MIN_VALUE)
|
|
||||||
val MAX_DATE = DateTime(Double.MAX_VALUE)
|
|
@ -1,9 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core
|
|
||||||
|
|
||||||
import com.benasher44.uuid.uuid4
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.post.PostId
|
|
||||||
|
|
||||||
fun generateId() = uuid4().toString()
|
|
||||||
fun generatePostId(): PostId = generateId()
|
|
||||||
fun generateContentId(): ContentId = generateId()
|
|
@ -1,41 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.modules.*
|
|
||||||
|
|
||||||
typealias ContentId = String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Content which is planned to be registered in database
|
|
||||||
*/
|
|
||||||
interface Content
|
|
||||||
|
|
||||||
/**
|
|
||||||
* That is a content which in fact just a link to another content. It would be useful in case when user wish to reuse
|
|
||||||
* some content
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class OtherContentLinkContent(
|
|
||||||
val otherId: ContentId
|
|
||||||
) : Content
|
|
||||||
|
|
||||||
|
|
||||||
fun SerializersModuleBuilder.includeContentsSerializers(
|
|
||||||
block: PolymorphicModuleBuilder<Content>.() -> Unit
|
|
||||||
) {
|
|
||||||
polymorphic(Content::class) {
|
|
||||||
subclass(OtherContentLinkContent::class, OtherContentLinkContent.serializer())
|
|
||||||
block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Content which is already registered in database. Using its [id] you can retrieve all known
|
|
||||||
* [dev.inmo.postssystem.core.post.RegisteredPost]s by using
|
|
||||||
* [dev.inmo.postssystem.core.post.repo.ReadPostsRepo.getPostsByContent]
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class RegisteredContent(
|
|
||||||
val id: ContentId,
|
|
||||||
val content: Content
|
|
||||||
)
|
|
@ -1,6 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.repos.StandardCRUDRepo
|
|
||||||
import dev.inmo.postssystem.core.content.*
|
|
||||||
|
|
||||||
interface ContentRepo : ReadContentRepo, WriteContentRepo, StandardCRUDRepo<RegisteredContent, ContentId, Content>
|
|
@ -1,13 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.content.RegisteredContent
|
|
||||||
import dev.inmo.micro_utils.pagination.Pagination
|
|
||||||
import dev.inmo.micro_utils.pagination.PaginationResult
|
|
||||||
import dev.inmo.micro_utils.repos.ReadStandardCRUDRepo
|
|
||||||
import dev.inmo.micro_utils.repos.pagination.getAll
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple read API by different properties of [dev.inmo.postssystem.core.content.Content].
|
|
||||||
*/
|
|
||||||
interface ReadContentRepo : ReadStandardCRUDRepo<RegisteredContent, ContentId>
|
|
@ -1,7 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.repos.WriteStandardCRUDRepo
|
|
||||||
import dev.inmo.postssystem.core.content.*
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface WriteContentRepo : WriteStandardCRUDRepo<RegisteredContent, ContentId, Content>
|
|
@ -1,117 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.pagination.*
|
|
||||||
import dev.inmo.micro_utils.repos.UpdatedValuePair
|
|
||||||
import dev.inmo.postssystem.core.content.*
|
|
||||||
import dev.inmo.postssystem.core.content.api.*
|
|
||||||
import dev.inmo.postssystem.core.generateContentId
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
|
|
||||||
interface BusinessContentRepoContentAdapter {
|
|
||||||
val type: AdapterType
|
|
||||||
suspend fun storeContent(contentId: ContentId, content: Content): Boolean
|
|
||||||
suspend fun getContent(contentId: ContentId): Content?
|
|
||||||
suspend fun removeContent(contentId: ContentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
class BusinessReadContentRepo(
|
|
||||||
adapters: List<BusinessContentRepoContentAdapter>,
|
|
||||||
private val helperRepo: BusinessContentRepoReadHelper,
|
|
||||||
) : ReadContentRepo {
|
|
||||||
private val adaptersMap: Map<String, BusinessContentRepoContentAdapter> = adapters.map {
|
|
||||||
it.type to it
|
|
||||||
}.toMap()
|
|
||||||
override suspend fun contains(id: ContentId): Boolean = helperRepo.contains(id)
|
|
||||||
|
|
||||||
override suspend fun count(): Long = helperRepo.count()
|
|
||||||
|
|
||||||
override suspend fun getById(id: ContentId): RegisteredContent? = helperRepo.getType(id) ?.let {
|
|
||||||
adaptersMap[it] ?.getContent(id) ?.let { content ->
|
|
||||||
RegisteredContent(id, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getByPagination(
|
|
||||||
pagination: Pagination
|
|
||||||
): PaginationResult<RegisteredContent> = helperRepo.getKeysByPagination(
|
|
||||||
pagination
|
|
||||||
).let {
|
|
||||||
it.results.mapNotNull {
|
|
||||||
getById(it)
|
|
||||||
}.createPaginationResult(
|
|
||||||
it,
|
|
||||||
count()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BusinessWriteContentRepo(
|
|
||||||
private val adapters: List<BusinessContentRepoContentAdapter>,
|
|
||||||
private val helperRepo: BusinessContentRepoHelper
|
|
||||||
) : WriteContentRepo {
|
|
||||||
private val adaptersMap = adapters.map { it.type to it }.toMap()
|
|
||||||
private val _deletedObjectsIdsFlow = MutableSharedFlow<ContentId>()
|
|
||||||
override val deletedObjectsIdsFlow: Flow<ContentId> = _deletedObjectsIdsFlow.asSharedFlow()
|
|
||||||
private val _newObjectsFlow = MutableSharedFlow<RegisteredContent>()
|
|
||||||
override val newObjectsFlow: Flow<RegisteredContent> = _newObjectsFlow.asSharedFlow()
|
|
||||||
private val _updatedObjectsFlow = MutableSharedFlow<RegisteredContent>()
|
|
||||||
override val updatedObjectsFlow: Flow<RegisteredContent> = _updatedObjectsFlow.asSharedFlow()
|
|
||||||
|
|
||||||
override suspend fun create(values: List<Content>): List<RegisteredContent> {
|
|
||||||
return values.mapNotNull { content ->
|
|
||||||
if (content is OtherContentLinkContent) {
|
|
||||||
adapters.forEach {
|
|
||||||
val existsContent = it.getContent(content.otherId)
|
|
||||||
if (existsContent != null) {
|
|
||||||
return@mapNotNull RegisteredContent(
|
|
||||||
content.otherId,
|
|
||||||
existsContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val contentId = generateContentId()
|
|
||||||
val adapter = adapters.firstOrNull { it.storeContent(contentId, content) } ?: return@mapNotNull null
|
|
||||||
if (!helperRepo.saveType(contentId, adapter.type)) {
|
|
||||||
adapter.removeContent(contentId)
|
|
||||||
}
|
|
||||||
RegisteredContent(contentId, content).also { _newObjectsFlow.emit(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteById(ids: List<ContentId>) {
|
|
||||||
ids.forEach { contentId ->
|
|
||||||
adaptersMap[helperRepo.getType(contentId)] ?.removeContent(contentId) ?: adapters.forEach {
|
|
||||||
it.removeContent(contentId)
|
|
||||||
}
|
|
||||||
helperRepo.deleteContentId(contentId)
|
|
||||||
_deletedObjectsIdsFlow.emit(contentId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun update(id: ContentId, value: Content): RegisteredContent? {
|
|
||||||
adaptersMap[helperRepo.getType(id)] ?.removeContent(id) ?: adapters.forEach {
|
|
||||||
it.removeContent(id)
|
|
||||||
}
|
|
||||||
adapters.firstOrNull { it.storeContent(id, value) } ?: return null
|
|
||||||
return RegisteredContent(id, value).also { _updatedObjectsFlow.emit(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun update(values: List<UpdatedValuePair<ContentId, Content>>): List<RegisteredContent> {
|
|
||||||
return values.mapNotNull {
|
|
||||||
update(it.first, it.second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class BusinessContentRepo(
|
|
||||||
adapters: List<BusinessContentRepoContentAdapter>,
|
|
||||||
helperRepo: BusinessContentRepoHelper
|
|
||||||
) : ContentRepo, ReadContentRepo by BusinessReadContentRepo(
|
|
||||||
adapters,
|
|
||||||
helperRepo
|
|
||||||
), WriteContentRepo by BusinessWriteContentRepo(
|
|
||||||
adapters,
|
|
||||||
helperRepo
|
|
||||||
)
|
|
@ -1,59 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.pagination.Pagination
|
|
||||||
import dev.inmo.micro_utils.pagination.PaginationResult
|
|
||||||
import dev.inmo.micro_utils.repos.*
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
|
|
||||||
typealias AdapterType = String
|
|
||||||
|
|
||||||
interface BusinessContentRepoReadHelper : Repo {
|
|
||||||
suspend fun getKeysByPagination(pagination: Pagination): PaginationResult<ContentId>
|
|
||||||
suspend fun contains(contentId: ContentId): Boolean
|
|
||||||
suspend fun getType(contentId: ContentId): AdapterType?
|
|
||||||
suspend fun count(): Long
|
|
||||||
}
|
|
||||||
interface BusinessContentRepoWriteHelper : Repo {
|
|
||||||
suspend fun deleteContentId(contentId: ContentId)
|
|
||||||
suspend fun saveType(contentId: ContentId, type: AdapterType): Boolean
|
|
||||||
}
|
|
||||||
interface BusinessContentRepoHelper : BusinessContentRepoReadHelper, BusinessContentRepoWriteHelper
|
|
||||||
|
|
||||||
class KeyValueBusinessContentRepoReadHelper(
|
|
||||||
private val keyValueRepo: ReadStandardKeyValueRepo<ContentId, AdapterType>
|
|
||||||
) : BusinessContentRepoReadHelper {
|
|
||||||
override suspend fun getKeysByPagination(pagination: Pagination): PaginationResult<ContentId> = keyValueRepo.keys(pagination)
|
|
||||||
override suspend fun contains(contentId: ContentId): Boolean = keyValueRepo.contains(contentId)
|
|
||||||
override suspend fun getType(contentId: ContentId): AdapterType? = keyValueRepo.get(contentId)
|
|
||||||
override suspend fun count(): Long = keyValueRepo.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
class KeyValueBusinessContentRepoWriteHelper(
|
|
||||||
private val keyValueRepo: WriteStandardKeyValueRepo<ContentId, AdapterType>
|
|
||||||
) : BusinessContentRepoWriteHelper {
|
|
||||||
override suspend fun deleteContentId(contentId: ContentId) { keyValueRepo.unset(contentId) }
|
|
||||||
|
|
||||||
override suspend fun saveType(contentId: ContentId, type: AdapterType): Boolean {
|
|
||||||
keyValueRepo.set(contentId, type)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class KeyValueBusinessContentRepoHelper(
|
|
||||||
private val keyValueRepo: StandardKeyValueRepo<ContentId, AdapterType>
|
|
||||||
) : BusinessContentRepoHelper, BusinessContentRepoReadHelper by KeyValueBusinessContentRepoReadHelper(
|
|
||||||
keyValueRepo
|
|
||||||
), BusinessContentRepoWriteHelper by KeyValueBusinessContentRepoWriteHelper(
|
|
||||||
keyValueRepo
|
|
||||||
)
|
|
||||||
|
|
||||||
fun StandardKeyValueRepo<ContentId, AdapterType>.asBusinessContentRepo(
|
|
||||||
adapters: List<BusinessContentRepoContentAdapter>
|
|
||||||
) = BusinessContentRepo(
|
|
||||||
adapters,
|
|
||||||
KeyValueBusinessContentRepoHelper(this)
|
|
||||||
)
|
|
||||||
|
|
||||||
fun StandardKeyValueRepo<ContentId, AdapterType>.asBusinessContentRepo(
|
|
||||||
vararg adapters: BusinessContentRepoContentAdapter
|
|
||||||
) = asBusinessContentRepo(adapters.toList())
|
|
@ -1,29 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business.content_adapters
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.repos.*
|
|
||||||
import dev.inmo.postssystem.core.content.Content
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.AdapterType
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.BusinessContentRepoContentAdapter
|
|
||||||
|
|
||||||
class KeyValueBusinessContentRepoAdapter<T>(
|
|
||||||
override val type: AdapterType,
|
|
||||||
private val keyValueRepo: StandardKeyValueRepo<ContentId, T>,
|
|
||||||
private val contentToData: suspend (Content) -> T?,
|
|
||||||
private val dataToContent: suspend (T) -> Content
|
|
||||||
) : BusinessContentRepoContentAdapter {
|
|
||||||
override suspend fun storeContent(contentId: ContentId, content: Content): Boolean {
|
|
||||||
keyValueRepo.set(contentId, contentToData(content) ?: return false)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getContent(contentId: ContentId): Content? {
|
|
||||||
return dataToContent(
|
|
||||||
keyValueRepo.get(contentId) ?: return null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun removeContent(contentId: ContentId) {
|
|
||||||
keyValueRepo.unset(contentId)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,54 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business.content_adapters.binary
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.repos.*
|
|
||||||
import dev.inmo.postssystem.core.content.Content
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.AdapterType
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.BusinessContentRepoContentAdapter
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.content_adapters.KeyValueBusinessContentRepoAdapter
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
private val format = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
class BinaryBusinessContentRepoContentAdapter(
|
|
||||||
private val dataStore: KeyValueRepo<ContentId, String>,
|
|
||||||
private val filesStore: KeyValueRepo<ContentId, ByteArray>,
|
|
||||||
private val removeOnAbsentInOneOfStores: Boolean = false
|
|
||||||
) : BusinessContentRepoContentAdapter {
|
|
||||||
override val type: AdapterType
|
|
||||||
get() = "binary"
|
|
||||||
|
|
||||||
override suspend fun storeContent(contentId: ContentId, content: Content): Boolean {
|
|
||||||
(content as? BinaryContent) ?.also {
|
|
||||||
filesStore.set(contentId, it.dataAllocator())
|
|
||||||
dataStore.set(
|
|
||||||
contentId,
|
|
||||||
format.encodeToString(BinaryContent.serializer(), it.copy { ByteArray(0) })
|
|
||||||
)
|
|
||||||
} ?: return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getContent(contentId: ContentId): Content? {
|
|
||||||
return filesStore.get(contentId) ?.let {
|
|
||||||
val serializedData = dataStore.get(contentId)
|
|
||||||
if (serializedData != null) {
|
|
||||||
format.decodeFromString(BinaryContent.serializer(), serializedData).copy {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} ?: null.also {
|
|
||||||
if (removeOnAbsentInOneOfStores) {
|
|
||||||
filesStore.unset(contentId)
|
|
||||||
dataStore.unset(contentId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun removeContent(contentId: ContentId) {
|
|
||||||
filesStore.unset(contentId)
|
|
||||||
dataStore.unset(contentId)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business.content_adapters.binary
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.common.ByteArrayAllocator
|
|
||||||
import dev.inmo.micro_utils.common.ByteArrayAllocatorSerializer
|
|
||||||
import dev.inmo.micro_utils.mime_types.KnownMimeTypes
|
|
||||||
import dev.inmo.micro_utils.mime_types.MimeType
|
|
||||||
import dev.inmo.postssystem.core.content.Content
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class BinaryContent(
|
|
||||||
val mimeType: MimeType,
|
|
||||||
val originalFileName: String,
|
|
||||||
@Serializable(ByteArrayAllocatorSerializer::class)
|
|
||||||
val dataAllocator: ByteArrayAllocator
|
|
||||||
) : Content
|
|
||||||
|
|
||||||
val BinaryContent.isImage: Boolean
|
|
||||||
get() = mimeType is KnownMimeTypes.Image
|
|
@ -1,15 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business.content_adapters.text
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.repos.*
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.BusinessContentRepoContentAdapter
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.content_adapters.KeyValueBusinessContentRepoAdapter
|
|
||||||
|
|
||||||
class TextBusinessContentRepoContentAdapter(
|
|
||||||
private val keyValueRepo: StandardKeyValueRepo<ContentId, String>
|
|
||||||
) : BusinessContentRepoContentAdapter by KeyValueBusinessContentRepoAdapter(
|
|
||||||
"regularText",
|
|
||||||
keyValueRepo,
|
|
||||||
{ (it as? TextContent) ?.text },
|
|
||||||
{ TextContent(it) }
|
|
||||||
)
|
|
@ -1,9 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business.content_adapters.text
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.content.Content
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TextContent(
|
|
||||||
val text: String
|
|
||||||
) : Content
|
|
@ -1,61 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.post
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.UnixMillis
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import com.soywiz.klock.DateTime
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.Transient
|
|
||||||
|
|
||||||
typealias PostId = String
|
|
||||||
typealias ContentIds = List<ContentId>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base interface for creating of new post. Usually, it is just [SimplePost] instance
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
sealed class Post {
|
|
||||||
abstract val content: ContentIds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root entity of the whole system. Can be retrieved from [dev.inmo.postssystem.core.post.repo.ReadPostsRepo]
|
|
||||||
* by getting and created in [dev.inmo.postssystem.core.post.repo.WritePostsRepo] by inserting of [Post]
|
|
||||||
* instance
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
sealed class RegisteredPost : Post() {
|
|
||||||
abstract val id: PostId
|
|
||||||
|
|
||||||
abstract override val content: ContentIds
|
|
||||||
|
|
||||||
abstract val creationDate: DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base and currently (1st Nov 2019) single realisation of [Post]. There is [SimpleRegisteredPost] which is technically
|
|
||||||
* is [Post] too, but it is not direct [Post] realisation
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class SimplePost(
|
|
||||||
override val content: ContentIds
|
|
||||||
) : Post()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base and currently (1st Nov 2019) single realisation of [RegisteredPost]
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class SimpleRegisteredPost(
|
|
||||||
override val id: PostId,
|
|
||||||
override val content: ContentIds,
|
|
||||||
private val creationDateTimeMillis: UnixMillis
|
|
||||||
) : RegisteredPost() {
|
|
||||||
@Transient
|
|
||||||
override val creationDate: DateTime = DateTime(creationDateTimeMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("FunctionName")
|
|
||||||
fun RegisteredPost(
|
|
||||||
id: PostId,
|
|
||||||
content: ContentIds,
|
|
||||||
creationDate: DateTime
|
|
||||||
) = SimpleRegisteredPost(id, content, creationDate.unixMillis)
|
|
@ -1,3 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.post.repo
|
|
||||||
|
|
||||||
interface PostsRepo : ReadPostsRepo, WritePostsRepo
|
|
@ -1,53 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.post.repo
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.MAX_DATE
|
|
||||||
import dev.inmo.postssystem.core.MIN_DATE
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.post.PostId
|
|
||||||
import dev.inmo.postssystem.core.post.RegisteredPost
|
|
||||||
import com.soywiz.klock.DateTime
|
|
||||||
import dev.inmo.micro_utils.pagination.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple read API by different properties
|
|
||||||
*/
|
|
||||||
interface ReadPostsRepo {
|
|
||||||
/**
|
|
||||||
* @return [Set] of [PostId]s which can be used to get data using [getPostById]
|
|
||||||
*/
|
|
||||||
suspend fun getPostsIds(): Set<PostId>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return [RegisteredPost] if it is available by [id]
|
|
||||||
*/
|
|
||||||
suspend fun getPostById(id: PostId): RegisteredPost?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return all [RegisteredPost]s which contains content with specified content [id]
|
|
||||||
*/
|
|
||||||
suspend fun getPostsByContent(id: ContentId): List<RegisteredPost>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return all [RegisteredPost]s which was registered between [from] and [to]. Range will be used INCLUSIVE, line \[[from], [to]\]
|
|
||||||
*/
|
|
||||||
suspend fun getPostsByCreatingDates(
|
|
||||||
from: DateTime = MIN_DATE,
|
|
||||||
to: DateTime = MAX_DATE,
|
|
||||||
pagination: Pagination = FirstPagePagination()
|
|
||||||
): PaginationResult<RegisteredPost>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return all posts by pages basing on their creation date
|
|
||||||
*/
|
|
||||||
suspend fun getPostsByPagination(pagination: Pagination): PaginationResult<RegisteredPost>
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun ReadPostsRepo.getPostsByCreatingDates(
|
|
||||||
from: DateTime? = null,
|
|
||||||
to: DateTime? = null,
|
|
||||||
pagination: Pagination = FirstPagePagination()
|
|
||||||
) = getPostsByCreatingDates(
|
|
||||||
from ?: MIN_DATE,
|
|
||||||
to ?: MAX_DATE,
|
|
||||||
pagination
|
|
||||||
)
|
|
@ -1,21 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.post.repo
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.post.*
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface WritePostsRepo {
|
|
||||||
val postCreatedFlow: Flow<RegisteredPost>
|
|
||||||
val postDeletedFlow: Flow<RegisteredPost>
|
|
||||||
val postUpdatedFlow: Flow<RegisteredPost>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For creating of post you need to create all its [dev.inmo.postssystem.core.content.RegisteredContent]
|
|
||||||
* and (or just) retrieve their [ContentIds] and put it into some [Post] implementation line [SimplePost].
|
|
||||||
*
|
|
||||||
* This method SHOULD use [PostId] of [RegisteredPost.id] in case if [RegisteredPost] passed
|
|
||||||
*/
|
|
||||||
suspend fun createPost(post: Post): RegisteredPost?
|
|
||||||
suspend fun deletePost(id: PostId): Boolean
|
|
||||||
|
|
||||||
suspend fun updatePostContent(postId: PostId, post: Post): Boolean
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.api
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.common.ByteArrayAllocator
|
|
||||||
import dev.inmo.micro_utils.mime_types.KnownMimeTypes
|
|
||||||
import dev.inmo.postssystem.core.content.*
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.content_adapters.binary.BinaryContent
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.content_adapters.text.TextContent
|
|
||||||
import dev.inmo.postssystem.core.generateContentId
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.modules.SerializersModule
|
|
||||||
import kotlinx.serialization.modules.polymorphic
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
private val jsonFormat = Json {
|
|
||||||
serializersModule = SerializersModule {
|
|
||||||
polymorphic(Content::class) {
|
|
||||||
subclass(TextContent::class, TextContent.serializer())
|
|
||||||
subclass(BinaryContent::class, BinaryContent.serializer())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ContentSerialization {
|
|
||||||
private val simpleTextTestEntries = 10
|
|
||||||
private val simpleSpecialTestEntries = 10
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_that_content_correctly_serializing_and_deserializing() {
|
|
||||||
val contents = (0 until simpleTextTestEntries).map {
|
|
||||||
TextContent("Example$it")
|
|
||||||
} + (0 until simpleSpecialTestEntries).map {
|
|
||||||
BinaryContent(KnownMimeTypes.Any, "$it.example") {
|
|
||||||
byteArrayOf(it.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val registeredContentFakes = contents.map { content ->
|
|
||||||
RegisteredContent(
|
|
||||||
generateContentId(),
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val stringified = registeredContentFakes.map {
|
|
||||||
jsonFormat.encodeToString(RegisteredContent.serializer(), it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val parsed = stringified.map {
|
|
||||||
jsonFormat.decodeFromString(RegisteredContent.serializer(), it)
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.forEachIndexed { i, registeredContent ->
|
|
||||||
val content = registeredContent.content
|
|
||||||
assertEquals(registeredContentFakes[i].id, registeredContent.id)
|
|
||||||
when (content) {
|
|
||||||
is TextContent -> assertEquals(registeredContentFakes[i].content, content)
|
|
||||||
is BinaryContent -> {
|
|
||||||
val expectedContent = registeredContentFakes[i].content as BinaryContent
|
|
||||||
val fakeByteArrayAllocator: ByteArrayAllocator = { byteArrayOf() }
|
|
||||||
assertEquals(
|
|
||||||
expectedContent.copy(dataAllocator = fakeByteArrayAllocator),
|
|
||||||
content.copy(dataAllocator = fakeByteArrayAllocator)
|
|
||||||
)
|
|
||||||
val expectedData = expectedContent.dataAllocator()
|
|
||||||
val parsedData = content.dataAllocator()
|
|
||||||
assertEquals(expectedData.size, parsedData.size)
|
|
||||||
expectedData.withIndex().forEach { (i, byte) ->
|
|
||||||
assertEquals(byte, parsedData[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,79 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.content.api.business.content_adapters.binary
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.coroutines.doOutsideOfCoroutine
|
|
||||||
import dev.inmo.micro_utils.coroutines.safelyWithoutExceptions
|
|
||||||
import dev.inmo.micro_utils.pagination.*
|
|
||||||
import dev.inmo.micro_utils.repos.KeyValueRepo
|
|
||||||
import dev.inmo.micro_utils.repos.set
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class FilesStoreRepoAdapter(
|
|
||||||
private val filesRepo: KeyValueRepo<String, File>,
|
|
||||||
private val temporalFilesFolder: File
|
|
||||||
) : KeyValueRepo<String, ByteArray> {
|
|
||||||
private val File.asByteArray
|
|
||||||
get() = readBytes()
|
|
||||||
override val onNewValue: Flow<Pair<String, ByteArray>> = filesRepo.onNewValue.map { (filename, file) ->
|
|
||||||
filename to file.asByteArray
|
|
||||||
}
|
|
||||||
override val onValueRemoved: Flow<String> = filesRepo.onValueRemoved
|
|
||||||
|
|
||||||
init {
|
|
||||||
temporalFilesFolder.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun contains(key: String): Boolean = filesRepo.contains(key)
|
|
||||||
|
|
||||||
override suspend fun count(): Long = filesRepo.count()
|
|
||||||
|
|
||||||
override suspend fun get(k: String): ByteArray? = filesRepo.get(k) ?.asByteArray
|
|
||||||
|
|
||||||
override suspend fun keys(
|
|
||||||
v: ByteArray,
|
|
||||||
pagination: Pagination,
|
|
||||||
reversed: Boolean
|
|
||||||
): PaginationResult<String> = emptyPaginationResult()
|
|
||||||
|
|
||||||
override suspend fun keys(
|
|
||||||
pagination: Pagination,
|
|
||||||
reversed: Boolean
|
|
||||||
): PaginationResult<String> = filesRepo.keys(pagination, reversed)
|
|
||||||
|
|
||||||
override suspend fun set(toSet: Map<String, ByteArray>) {
|
|
||||||
supervisorScope {
|
|
||||||
toSet.map { (filename, bytes) ->
|
|
||||||
launch {
|
|
||||||
safelyWithoutExceptions {
|
|
||||||
val file = File(temporalFilesFolder, filename).also {
|
|
||||||
it.delete()
|
|
||||||
doOutsideOfCoroutine {
|
|
||||||
it.createNewFile()
|
|
||||||
it.writeBytes(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filesRepo.set(filename, file)
|
|
||||||
doOutsideOfCoroutine { file.delete() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.joinAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun unset(toUnset: List<String>) = filesRepo.unset(toUnset)
|
|
||||||
|
|
||||||
override suspend fun values(
|
|
||||||
pagination: Pagination,
|
|
||||||
reversed: Boolean
|
|
||||||
): PaginationResult<ByteArray> = filesRepo.values(pagination, reversed).let {
|
|
||||||
PaginationResult(
|
|
||||||
it.page,
|
|
||||||
it.pagesNumber,
|
|
||||||
it.results.map { it.readBytes() },
|
|
||||||
it.size
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
<manifest package="dev.inmo.postssystem.core.api"/>
|
|
@ -1,26 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "org.jetbrains.kotlin.multiplatform"
|
|
||||||
id "org.jetbrains.kotlin.plugin.serialization"
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "$mppJavaProjectPresetPath"
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
sourceSets {
|
|
||||||
commonMain {
|
|
||||||
dependencies {
|
|
||||||
api "dev.inmo:micro_utils.repos.exposed:$microutils_version"
|
|
||||||
|
|
||||||
api project(":postssystem.core.api")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jvmTest {
|
|
||||||
dependencies {
|
|
||||||
implementation "org.jetbrains.exposed:exposed-jdbc:$exposed_version"
|
|
||||||
implementation "org.xerial:sqlite-jdbc:$test_sqlite_version"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-test"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-test-junit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
test_sqlite_version=3.28.0
|
|
@ -1,205 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.exposed
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.generatePostId
|
|
||||||
import dev.inmo.postssystem.core.post.*
|
|
||||||
import dev.inmo.postssystem.core.post.repo.PostsRepo
|
|
||||||
import com.soywiz.klock.*
|
|
||||||
import dev.inmo.micro_utils.pagination.*
|
|
||||||
import dev.inmo.micro_utils.repos.exposed.ExposedRepo
|
|
||||||
import dev.inmo.micro_utils.repos.exposed.initTable
|
|
||||||
import kotlinx.coroutines.channels.BroadcastChannel
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.asFlow
|
|
||||||
import org.jetbrains.exposed.sql.*
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
|
||||||
|
|
||||||
private class PostsRepoContentRelations(
|
|
||||||
override val database: Database
|
|
||||||
) : Table(), ExposedRepo {
|
|
||||||
private val postIdColumn = text("postId")
|
|
||||||
private val contentIdColumn = text("contentId")
|
|
||||||
|
|
||||||
init {
|
|
||||||
initTable()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPostContents(postId: PostId): List<ContentId> {
|
|
||||||
return transaction(db = database) {
|
|
||||||
select { postIdColumn.eq(postId) }.map { it[contentIdColumn] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getContentPosts(contentId: ContentId): List<PostId> {
|
|
||||||
return transaction(db = database) {
|
|
||||||
select { contentIdColumn.eq(contentId) }.map { it[postIdColumn] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun linkPostAndContents(postId: PostId, vararg contentIds: ContentId) {
|
|
||||||
transaction(db = database) {
|
|
||||||
val leftToPut = contentIds.toSet() - getPostContents(postId)
|
|
||||||
leftToPut.forEach { contentId ->
|
|
||||||
insert {
|
|
||||||
it[postIdColumn] = postId
|
|
||||||
it[contentIdColumn] = contentId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun unlinkPostAndContents(postId: PostId, vararg contentIds: ContentId): Boolean {
|
|
||||||
return transaction(db = database) {
|
|
||||||
deleteWhere {
|
|
||||||
postIdColumn.eq(postId).and(contentIdColumn.inList(contentIds.toList()))
|
|
||||||
} > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val dateTimeFormat = DateFormat("EEE, dd MMM yyyy HH:mm:ss z")
|
|
||||||
|
|
||||||
private class PostsRepoDatabaseTable(
|
|
||||||
private val database: Database,
|
|
||||||
tableName: String = ""
|
|
||||||
) : PostsRepo, Table(tableName) {
|
|
||||||
private val contentsTable = PostsRepoContentRelations(database)
|
|
||||||
|
|
||||||
private val idColumn = text("postId")
|
|
||||||
private val creationDateColumn = text("creationDate").default(
|
|
||||||
DateTime.now().toString(dateTimeFormat)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
private val postCreatedBroadcastChannel = BroadcastChannel<RegisteredPost>(Channel.BUFFERED)
|
|
||||||
override val postCreatedFlow: Flow<RegisteredPost> = postCreatedBroadcastChannel.asFlow()
|
|
||||||
|
|
||||||
private val postDeletedBroadcastChannel = BroadcastChannel<RegisteredPost>(Channel.BUFFERED)
|
|
||||||
override val postDeletedFlow: Flow<RegisteredPost> = postDeletedBroadcastChannel.asFlow()
|
|
||||||
|
|
||||||
private val postUpdatedBroadcastChannel = BroadcastChannel<RegisteredPost>(Channel.BUFFERED)
|
|
||||||
override val postUpdatedFlow: Flow<RegisteredPost> = postUpdatedBroadcastChannel.asFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
transaction (db = database) {
|
|
||||||
SchemaUtils.createMissingTablesAndColumns(this@PostsRepoDatabaseTable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ResultRow.toRegisteredPost(): RegisteredPost = get(idColumn).let { id ->
|
|
||||||
SimpleRegisteredPost(
|
|
||||||
id,
|
|
||||||
contentsTable.getPostContents(id),
|
|
||||||
dateTimeFormat.parse(get(creationDateColumn)).local.unixMillis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun createPost(post: Post): RegisteredPost? {
|
|
||||||
val id = (post as? RegisteredPost) ?.let { it.id } ?: generatePostId()
|
|
||||||
return transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
insert {
|
|
||||||
it[idColumn] = id
|
|
||||||
}
|
|
||||||
contentsTable.linkPostAndContents(id, *post.content.toTypedArray())
|
|
||||||
select { idColumn.eq(id) }.firstOrNull() ?.toRegisteredPost()
|
|
||||||
} ?.also {
|
|
||||||
postCreatedBroadcastChannel.send(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deletePost(id: PostId): Boolean {
|
|
||||||
val post = getPostById(id) ?: return false
|
|
||||||
return (transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
deleteWhere { idColumn.eq(id) }
|
|
||||||
} > 0).also {
|
|
||||||
if (it) {
|
|
||||||
postDeletedBroadcastChannel.send(post)
|
|
||||||
contentsTable.unlinkPostAndContents(id, *post.content.toTypedArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updatePostContent(postId: PostId, post: Post): Boolean {
|
|
||||||
return transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
val alreadyLinked = contentsTable.getPostContents(postId)
|
|
||||||
val toRemove = alreadyLinked - post.content
|
|
||||||
val toInsert = post.content - alreadyLinked
|
|
||||||
val updated = (toRemove.isNotEmpty() && contentsTable.unlinkPostAndContents(postId, *toRemove.toTypedArray())) || toInsert.isNotEmpty()
|
|
||||||
if (toInsert.isNotEmpty()) {
|
|
||||||
contentsTable.linkPostAndContents(postId, *toInsert.toTypedArray())
|
|
||||||
}
|
|
||||||
updated
|
|
||||||
}.also {
|
|
||||||
if (it) {
|
|
||||||
getPostById(postId) ?.also { updatedPost -> postUpdatedBroadcastChannel.send(updatedPost) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
override suspend fun getPostsIds(): Set<PostId> {
|
|
||||||
return transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
selectAll().map { it[idColumn] }.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPostById(id: PostId): RegisteredPost? {
|
|
||||||
return transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
select { idColumn.eq(id) }.firstOrNull() ?.toRegisteredPost()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPostsByContent(id: ContentId): List<RegisteredPost> {
|
|
||||||
return transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
val postsIds = contentsTable.getContentPosts(id)
|
|
||||||
select { idColumn.inList(postsIds) }.map { it.toRegisteredPost() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPostsByCreatingDates(
|
|
||||||
from: DateTime,
|
|
||||||
to: DateTime,
|
|
||||||
pagination: Pagination
|
|
||||||
): PaginationResult<RegisteredPost> {
|
|
||||||
return transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
select { creationDateColumn.between(from, to) }.paginate(
|
|
||||||
pagination
|
|
||||||
).map {
|
|
||||||
it.toRegisteredPost()
|
|
||||||
}.createPaginationResult(
|
|
||||||
pagination,
|
|
||||||
selectAll().count()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPostsByPagination(pagination: Pagination): PaginationResult<RegisteredPost> {
|
|
||||||
return transaction(
|
|
||||||
db = database
|
|
||||||
) {
|
|
||||||
val posts = selectAll().paginate(pagination).orderBy(creationDateColumn).map {
|
|
||||||
it.toRegisteredPost()
|
|
||||||
}
|
|
||||||
val postsNumber = selectAll().count()
|
|
||||||
posts.createPaginationResult(pagination, postsNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExposedPostsRepo (
|
|
||||||
database: Database,
|
|
||||||
tableName: String = ""
|
|
||||||
) : PostsRepo by PostsRepoDatabaseTable(database, tableName)
|
|
@ -1,86 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.exposed
|
|
||||||
|
|
||||||
import dev.inmo.micro_utils.repos.create
|
|
||||||
import dev.inmo.micro_utils.repos.deleteById
|
|
||||||
import dev.inmo.micro_utils.repos.exposed.keyvalue.ExposedKeyValueRepo
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.AdapterType
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.asBusinessContentRepo
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.content_adapters.text.TextBusinessContentRepoContentAdapter
|
|
||||||
import dev.inmo.postssystem.core.content.api.business.content_adapters.text.TextContent
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import org.jetbrains.exposed.sql.Database
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transactionManager
|
|
||||||
import java.io.File
|
|
||||||
import java.sql.Connection
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
class ExposedContentRepoCommonTests {
|
|
||||||
private val tempFolder = System.getProperty("java.io.tmpdir")!!
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Test that it is possible to use several different databases at one time`() {
|
|
||||||
val numberOfDatabases = 8
|
|
||||||
|
|
||||||
val databaseFiles = (0 until numberOfDatabases).map {
|
|
||||||
"$tempFolder/ExposedContentAPICommonTestsDB$it.db"
|
|
||||||
}
|
|
||||||
|
|
||||||
val apis = databaseFiles.map {
|
|
||||||
File(it).also {
|
|
||||||
it.delete()
|
|
||||||
it.deleteOnExit()
|
|
||||||
}
|
|
||||||
val database = Database.Companion.connect("jdbc:sqlite:$it", driver = "org.sqlite.JDBC").also {
|
|
||||||
it.transactionManager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE
|
|
||||||
}
|
|
||||||
ExposedKeyValueRepo<ContentId, AdapterType>(
|
|
||||||
database,
|
|
||||||
{ text("contentId") },
|
|
||||||
{ text("adapterType") },
|
|
||||||
"ContentRepo"
|
|
||||||
).asBusinessContentRepo(
|
|
||||||
TextBusinessContentRepoContentAdapter(
|
|
||||||
ExposedKeyValueRepo<ContentId, String>(
|
|
||||||
database,
|
|
||||||
{ text("contentId") },
|
|
||||||
{ text("text") },
|
|
||||||
"TextContentRepo"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val results = apis.mapIndexed { i, api ->
|
|
||||||
val expectedContent = TextContent(i.toString())
|
|
||||||
val contents = runBlocking { api.create(TextContent(i.toString())) }
|
|
||||||
val idsCount = runBlocking { api.count() }
|
|
||||||
assertEquals(idsCount, 1)
|
|
||||||
assert(contents.isNotEmpty())
|
|
||||||
assertEquals(
|
|
||||||
expectedContent,
|
|
||||||
contents.first().content
|
|
||||||
)
|
|
||||||
contents.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
results.forEachIndexed { i, content ->
|
|
||||||
apis.forEachIndexed { j, api ->
|
|
||||||
assert(
|
|
||||||
runBlocking {
|
|
||||||
api.getById(content.id) == (if (i != j) null else content)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
runBlocking {
|
|
||||||
api.deleteById(content.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
databaseFiles.forEach {
|
|
||||||
File(it).delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.exposed
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.post.SimplePost
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.jetbrains.exposed.sql.Database
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transactionManager
|
|
||||||
import java.io.File
|
|
||||||
import java.sql.Connection
|
|
||||||
import kotlin.test.*
|
|
||||||
|
|
||||||
class ExposedPostsRepoCommonTests {
|
|
||||||
private val tempFolder = System.getProperty("java.io.tmpdir")!!
|
|
||||||
|
|
||||||
private val numberOfDatabases = 8
|
|
||||||
private lateinit var databaseFiles: List<File>
|
|
||||||
private lateinit var apis: List<ExposedPostsRepo>
|
|
||||||
|
|
||||||
@BeforeTest
|
|
||||||
fun prepare() {
|
|
||||||
databaseFiles = (0 until numberOfDatabases).map {
|
|
||||||
File("$tempFolder/ExposedPostsAPICommonTestsDB$it.db")
|
|
||||||
}
|
|
||||||
apis = databaseFiles.map {
|
|
||||||
val database = Database.connect("jdbc:sqlite:${it.absolutePath}").also {
|
|
||||||
it.transactionManager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE
|
|
||||||
}
|
|
||||||
ExposedPostsRepo(
|
|
||||||
database
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Test that it is possible to use several different databases at one time`() {
|
|
||||||
val posts = apis.mapIndexed { i, api ->
|
|
||||||
val content = runBlocking { api.createPost(SimplePost(listOf(i.toString()))) }
|
|
||||||
assert(content != null)
|
|
||||||
assert(runBlocking { api.getPostsIds().size == 1 })
|
|
||||||
content!!
|
|
||||||
}
|
|
||||||
|
|
||||||
posts.forEachIndexed { i, post ->
|
|
||||||
apis.forEachIndexed { j, api ->
|
|
||||||
assert(
|
|
||||||
runBlocking {
|
|
||||||
api.getPostById(post.id) == (if (i != j) null else post)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert(
|
|
||||||
runBlocking {
|
|
||||||
api.deletePost(post.id) == (i == j)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterTest
|
|
||||||
fun `Close and delete databases`() {
|
|
||||||
databaseFiles.forEach {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
|
|
@ -1 +0,0 @@
|
|||||||
|
|
@ -1,28 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor.client.post
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.ktor.postsRootRoute
|
|
||||||
import dev.inmo.postssystem.core.post.repo.*
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.features.websocket.WebSockets
|
|
||||||
|
|
||||||
class PostsRepoKtorClient private constructor(
|
|
||||||
readPostsRepo: ReadPostsRepo,
|
|
||||||
writePostsRepo: WritePostsRepo
|
|
||||||
) : PostsRepo, ReadPostsRepo by readPostsRepo, WritePostsRepo by writePostsRepo {
|
|
||||||
constructor(
|
|
||||||
baseUrl: String,
|
|
||||||
rootRoute: String? = postsRootRoute,
|
|
||||||
client: HttpClient = HttpClient {
|
|
||||||
install(WebSockets)
|
|
||||||
}
|
|
||||||
) : this(
|
|
||||||
ReadPostsRepoKtorClient(
|
|
||||||
rootRoute ?.let { "${baseUrl}/$rootRoute" } ?: baseUrl,
|
|
||||||
client
|
|
||||||
),
|
|
||||||
WritePostsRepoKtorClient(
|
|
||||||
rootRoute ?.let { "${baseUrl}/$rootRoute" } ?: baseUrl,
|
|
||||||
client
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor.client.post
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.ktor.*
|
|
||||||
import dev.inmo.postssystem.core.post.PostId
|
|
||||||
import dev.inmo.postssystem.core.post.RegisteredPost
|
|
||||||
import dev.inmo.postssystem.core.post.repo.ReadPostsRepo
|
|
||||||
import com.soywiz.klock.DateTime
|
|
||||||
import dev.inmo.micro_utils.ktor.client.uniget
|
|
||||||
import dev.inmo.micro_utils.ktor.common.asFromToUrlPart
|
|
||||||
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
|
|
||||||
import dev.inmo.micro_utils.pagination.*
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import kotlinx.serialization.builtins.nullable
|
|
||||||
|
|
||||||
class ReadPostsRepoKtorClient(
|
|
||||||
private val baseUrl: String,
|
|
||||||
private val client: HttpClient = HttpClient()
|
|
||||||
) : ReadPostsRepo {
|
|
||||||
override suspend fun getPostsIds(): Set<PostId> = client.uniget(
|
|
||||||
buildStandardUrl(baseUrl, getPostsIdsRoute),
|
|
||||||
postIdsSerializer
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun getPostById(id: PostId): RegisteredPost? = client.uniget(
|
|
||||||
buildStandardUrl(baseUrl, "$getPostByIdRoute/$id"),
|
|
||||||
RegisteredPost.serializer().nullable
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun getPostsByContent(id: ContentId): List<RegisteredPost> = client.uniget(
|
|
||||||
buildStandardUrl(baseUrl, "$getPostsByContentRoute/$id"),
|
|
||||||
registeredPostsListSerializer
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun getPostsByCreatingDates(
|
|
||||||
from: DateTime,
|
|
||||||
to: DateTime,
|
|
||||||
pagination: Pagination
|
|
||||||
): PaginationResult<RegisteredPost> = client.uniget(
|
|
||||||
buildStandardUrl(
|
|
||||||
baseUrl,
|
|
||||||
getPostsByCreatingDatesRoute,
|
|
||||||
(from to to).asFromToUrlPart + pagination.asUrlQueryParts
|
|
||||||
),
|
|
||||||
registeredPostsPaginationResultSerializer
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun getPostsByPagination(pagination: Pagination): PaginationResult<RegisteredPost> = client.uniget(
|
|
||||||
buildStandardUrl(
|
|
||||||
baseUrl,
|
|
||||||
getPostsByCreatingDatesRoute,
|
|
||||||
pagination.asUrlQueryParts
|
|
||||||
),
|
|
||||||
registeredPostsPaginationResultSerializer
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor.client.post
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.ktor.*
|
|
||||||
import dev.inmo.postssystem.core.post.*
|
|
||||||
import dev.inmo.postssystem.core.post.repo.WritePostsRepo
|
|
||||||
import dev.inmo.micro_utils.ktor.client.*
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.features.websocket.WebSockets
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.serialization.builtins.nullable
|
|
||||||
import kotlinx.serialization.builtins.serializer
|
|
||||||
|
|
||||||
class WritePostsRepoKtorClient (
|
|
||||||
private val baseUrl: String,
|
|
||||||
private val client: HttpClient = HttpClient {
|
|
||||||
install(WebSockets)
|
|
||||||
}
|
|
||||||
) : WritePostsRepo {
|
|
||||||
override val postCreatedFlow: Flow<RegisteredPost> = client.createStandardWebsocketFlow(
|
|
||||||
"$baseUrl/$postCreatedFlowRoute",
|
|
||||||
deserializer = RegisteredPost.serializer()
|
|
||||||
)
|
|
||||||
override val postDeletedFlow: Flow<RegisteredPost> = client.createStandardWebsocketFlow(
|
|
||||||
"$baseUrl/$postDeletedFlowRoute",
|
|
||||||
deserializer = RegisteredPost.serializer()
|
|
||||||
)
|
|
||||||
override val postUpdatedFlow: Flow<RegisteredPost> = client.createStandardWebsocketFlow(
|
|
||||||
"$baseUrl/$postUpdatedFlowRoute",
|
|
||||||
deserializer = RegisteredPost.serializer()
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun createPost(post: Post): RegisteredPost? = client.unipost(
|
|
||||||
"$baseUrl/$createPostRoute",
|
|
||||||
BodyPair(Post.serializer(), post),
|
|
||||||
RegisteredPost.serializer().nullable
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun deletePost(id: PostId): Boolean = client.unipost(
|
|
||||||
"$baseUrl/$deletePostRoute",
|
|
||||||
BodyPair(PostId.serializer(), id),
|
|
||||||
Boolean.serializer()
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun updatePostContent(postId: PostId, post: Post): Boolean = client.unipost(
|
|
||||||
"$baseUrl/$updatePostContentRoute",
|
|
||||||
BodyPair(UpdatePostObject.serializer(), UpdatePostObject(postId, post)),
|
|
||||||
Boolean.serializer()
|
|
||||||
)
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
<manifest package="dev.inmo.postssystem.core.ktor.client"/>
|
|
@ -1,13 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor
|
|
||||||
|
|
||||||
const val contentRootRoute = "content"
|
|
||||||
|
|
||||||
const val getContentsIdsRoute = "getContentsIds"
|
|
||||||
const val getContentByIdRoute = "getContentById"
|
|
||||||
const val getContentByPaginationRoute = "getContentByPagination"
|
|
||||||
|
|
||||||
const val registerContentRoute = "registerContent"
|
|
||||||
const val deleteContentRoute = "deleteContent"
|
|
||||||
|
|
||||||
const val contentCreatedFlowRoute = "contentCreatedFlow"
|
|
||||||
const val contentDeletedFlowRoute = "contentDeletedFlow"
|
|
@ -1,17 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor
|
|
||||||
|
|
||||||
const val postsRootRoute = "post"
|
|
||||||
const val publishedPostsSubRoute = "published"
|
|
||||||
|
|
||||||
const val getPostsIdsRoute = "getPostsIds"
|
|
||||||
const val getPostByIdRoute = "getPostById"
|
|
||||||
const val getPostsByContentRoute = "getPostsByContent"
|
|
||||||
const val getPostsByCreatingDatesRoute = "getPostsByCreatingDates"
|
|
||||||
const val getPostsByPaginationRoute = "getPostsByPagination"
|
|
||||||
|
|
||||||
const val postCreatedFlowRoute = "postCreatedFlow"
|
|
||||||
const val postDeletedFlowRoute = "postDeletedFlow"
|
|
||||||
const val postUpdatedFlowRoute = "postUpdatedFlow"
|
|
||||||
const val createPostRoute = "createPost"
|
|
||||||
const val deletePostRoute = "deletePost"
|
|
||||||
const val updatePostContentRoute = "updatePostContent"
|
|
@ -1,14 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.content.RegisteredContent
|
|
||||||
import dev.inmo.postssystem.core.post.PostId
|
|
||||||
import dev.inmo.postssystem.core.post.RegisteredPost
|
|
||||||
import dev.inmo.micro_utils.pagination.PaginationResult
|
|
||||||
import kotlinx.serialization.builtins.*
|
|
||||||
|
|
||||||
val contentIdsSerializer = SetSerializer(ContentId.serializer())
|
|
||||||
val postIdsSerializer = SetSerializer(PostId.serializer())
|
|
||||||
val registeredPostsListSerializer = ListSerializer(RegisteredPost.serializer())
|
|
||||||
val registeredPostsPaginationResultSerializer = PaginationResult.serializer(RegisteredPost.serializer())
|
|
||||||
val registeredContentPaginationResultSerializer = PaginationResult.serializer(RegisteredContent.serializer())
|
|
@ -1,11 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.post.Post
|
|
||||||
import dev.inmo.postssystem.core.post.PostId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UpdatePostObject(
|
|
||||||
val postId: PostId,
|
|
||||||
val post: Post
|
|
||||||
)
|
|
@ -1 +0,0 @@
|
|||||||
<manifest package="dev.inmo.postssystem.core.ktor.common"/>
|
|
@ -1,26 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "org.jetbrains.kotlin.multiplatform"
|
|
||||||
id "org.jetbrains.kotlin.plugin.serialization"
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "$mppJavaProjectPresetPath"
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
sourceSets {
|
|
||||||
commonMain {
|
|
||||||
dependencies {
|
|
||||||
api "dev.inmo:micro_utils.pagination.ktor.server:$microutils_version"
|
|
||||||
api "dev.inmo:micro_utils.ktor.server:$microutils_version"
|
|
||||||
|
|
||||||
api project(":postssystem.core.ktor.common")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jvmTest {
|
|
||||||
dependencies {
|
|
||||||
implementation "org.xerial:sqlite-jdbc:$test_sqlite_version"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-test"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-test-junit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor.server.post
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.ktor.postsRootRoute
|
|
||||||
import dev.inmo.postssystem.core.post.repo.PostsRepo
|
|
||||||
import dev.inmo.micro_utils.ktor.server.configurators.ApplicationRoutingConfigurator
|
|
||||||
import io.ktor.routing.Route
|
|
||||||
import io.ktor.routing.route
|
|
||||||
|
|
||||||
private inline fun configurator(proxyTo: PostsRepo): Route.() -> Unit = {
|
|
||||||
configureReadPostsRepoRoutes(proxyTo)
|
|
||||||
configureWritePostsRepoRoutes(proxyTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Route.configurePostsRepoRoutes(
|
|
||||||
proxyTo: PostsRepo,
|
|
||||||
rootRoute: String? = postsRootRoute
|
|
||||||
) {
|
|
||||||
rootRoute ?.also {
|
|
||||||
route(it, configurator(proxyTo))
|
|
||||||
} ?: configurator(proxyTo).invoke(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
class PostsRepoRoutingConfigurator(
|
|
||||||
private val proxyTo: PostsRepo,
|
|
||||||
private val rootRoute: String? = postsRootRoute
|
|
||||||
) : ApplicationRoutingConfigurator.Element {
|
|
||||||
override fun Route.invoke() {
|
|
||||||
configurePostsRepoRoutes(proxyTo, rootRoute)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor.server.post
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.MAX_DATE
|
|
||||||
import dev.inmo.postssystem.core.MIN_DATE
|
|
||||||
import dev.inmo.postssystem.core.content.ContentId
|
|
||||||
import dev.inmo.postssystem.core.ktor.*
|
|
||||||
import dev.inmo.postssystem.core.post.PostId
|
|
||||||
import dev.inmo.postssystem.core.post.RegisteredPost
|
|
||||||
import dev.inmo.postssystem.core.post.repo.ReadPostsRepo
|
|
||||||
import dev.inmo.micro_utils.ktor.server.*
|
|
||||||
import dev.inmo.micro_utils.ktor.server.configurators.ApplicationRoutingConfigurator
|
|
||||||
import dev.inmo.micro_utils.pagination.extractPagination
|
|
||||||
import io.ktor.application.call
|
|
||||||
import io.ktor.routing.Route
|
|
||||||
import io.ktor.routing.get
|
|
||||||
import kotlinx.serialization.builtins.nullable
|
|
||||||
|
|
||||||
fun Route.configureReadPostsRepoRoutes(
|
|
||||||
proxyTo: ReadPostsRepo
|
|
||||||
) {
|
|
||||||
get(getPostsIdsRoute) {
|
|
||||||
call.unianswer(
|
|
||||||
postIdsSerializer,
|
|
||||||
proxyTo.getPostsIds()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
get("$getPostByIdRoute/{id}") {
|
|
||||||
val id: PostId = call.getParameterOrSendError("id") ?: return@get
|
|
||||||
call.unianswer(
|
|
||||||
RegisteredPost.serializer().nullable,
|
|
||||||
proxyTo.getPostById(id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
get("$getPostsByContentRoute/{id}") {
|
|
||||||
val id: ContentId = call.getParameterOrSendError("id") ?: return@get
|
|
||||||
call.unianswer(
|
|
||||||
registeredPostsListSerializer,
|
|
||||||
proxyTo.getPostsByContent(id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
get(getPostsByCreatingDatesRoute) {
|
|
||||||
val fromToDateTime = call.request.queryParameters.extractFromToDateTime
|
|
||||||
val pagination = call.request.queryParameters.extractPagination
|
|
||||||
|
|
||||||
call.unianswer(
|
|
||||||
registeredPostsPaginationResultSerializer,
|
|
||||||
proxyTo.getPostsByCreatingDates(
|
|
||||||
fromToDateTime.first ?: MIN_DATE,
|
|
||||||
fromToDateTime.second ?: MAX_DATE,
|
|
||||||
pagination
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
get(getPostsByPaginationRoute) {
|
|
||||||
val pagination = call.request.queryParameters.extractPagination
|
|
||||||
|
|
||||||
call.unianswer(
|
|
||||||
registeredPostsPaginationResultSerializer,
|
|
||||||
proxyTo.getPostsByPagination(pagination)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReadPostsRepoRoutingConfigurator(
|
|
||||||
private val proxyTo: ReadPostsRepo
|
|
||||||
) : ApplicationRoutingConfigurator.Element {
|
|
||||||
override fun Route.invoke() {
|
|
||||||
configureReadPostsRepoRoutes(proxyTo)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
package dev.inmo.postssystem.core.ktor.server.post
|
|
||||||
|
|
||||||
import dev.inmo.postssystem.core.ktor.*
|
|
||||||
import dev.inmo.postssystem.core.post.*
|
|
||||||
import dev.inmo.postssystem.core.post.repo.WritePostsRepo
|
|
||||||
import dev.inmo.micro_utils.ktor.server.*
|
|
||||||
import dev.inmo.micro_utils.ktor.server.configurators.ApplicationRoutingConfigurator
|
|
||||||
import io.ktor.application.call
|
|
||||||
import io.ktor.routing.Route
|
|
||||||
import io.ktor.routing.post
|
|
||||||
import kotlinx.serialization.builtins.nullable
|
|
||||||
import kotlinx.serialization.builtins.serializer
|
|
||||||
|
|
||||||
fun Route.configureWritePostsRepoRoutes(
|
|
||||||
proxyTo: WritePostsRepo
|
|
||||||
) {
|
|
||||||
includeWebsocketHandling(postCreatedFlowRoute, proxyTo.postCreatedFlow, RegisteredPost.serializer())
|
|
||||||
includeWebsocketHandling(postDeletedFlowRoute, proxyTo.postDeletedFlow, RegisteredPost.serializer())
|
|
||||||
includeWebsocketHandling(postUpdatedFlowRoute, proxyTo.postUpdatedFlow, RegisteredPost.serializer())
|
|
||||||
|
|
||||||
post(createPostRoute) {
|
|
||||||
call.unianswer(
|
|
||||||
RegisteredPost.serializer().nullable,
|
|
||||||
proxyTo.createPost(
|
|
||||||
call.uniload(Post.serializer())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
post(deletePostRoute) {
|
|
||||||
call.unianswer(
|
|
||||||
Boolean.serializer(),
|
|
||||||
proxyTo.deletePost(call.uniload(PostId.serializer()))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
post(updatePostContentRoute) {
|
|
||||||
val updatePostObject = call.uniload(UpdatePostObject.serializer())
|
|
||||||
|
|
||||||
call.unianswer(
|
|
||||||
Boolean.serializer(),
|
|
||||||
proxyTo.updatePostContent(
|
|
||||||
updatePostObject.postId,
|
|
||||||
updatePostObject.post
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class WritePostsRepoRoutingConfigurator(
|
|
||||||
private val proxyTo: WritePostsRepo
|
|
||||||
) : ApplicationRoutingConfigurator.Element {
|
|
||||||
override fun Route.invoke() {
|
|
||||||
configureWritePostsRepoRoutes(proxyTo)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
android {
|
|
||||||
compileSdkVersion "$android_compileSdkVersion".toInteger()
|
|
||||||
buildToolsVersion "$android_buildToolsVersion"
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
minSdkVersion "$android_minSdkVersion".toInteger()
|
|
||||||
targetSdkVersion "$android_compileSdkVersion".toInteger()
|
|
||||||
versionCode "${android_code_version}".toInteger()
|
|
||||||
versionName "$version"
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
debuggable true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
packagingOptions {
|
|
||||||
exclude 'META-INF/kotlinx-serialization-runtime.kotlin_module'
|
|
||||||
exclude 'META-INF/kotlinx-serialization-cbor.kotlin_module'
|
|
||||||
exclude 'META-INF/kotlinx-serialization-properties.kotlin_module'
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
|
||||||
}
|
|
68
defaultAndroidSettings.gradle
Normal file
68
defaultAndroidSettings.gradle
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
apply plugin: 'com.getkeepsafe.dexcount'
|
||||||
|
|
||||||
|
android {
|
||||||
|
ext {
|
||||||
|
jvmKotlinFolderFile = {
|
||||||
|
String sep = File.separator
|
||||||
|
return new File("${project.projectDir}${sep}src${sep}jvmMain${sep}kotlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
enableIncludingJvmCodeInAndroidPart = {
|
||||||
|
File jvmKotlinFolder = jvmKotlinFolderFile()
|
||||||
|
if (jvmKotlinFolder.exists()) {
|
||||||
|
android.sourceSets.main.java.srcDirs += jvmKotlinFolder.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableIncludingJvmCodeInAndroidPart = {
|
||||||
|
File jvmKotlinFolder = jvmKotlinFolderFile()
|
||||||
|
String[] oldDirs = android.sourceSets.main.java.srcDirs
|
||||||
|
android.sourceSets.main.java.srcDirs = []
|
||||||
|
for (oldDir in oldDirs) {
|
||||||
|
if (oldDir != jvmKotlinFolder.path) {
|
||||||
|
android.sourceSets.main.java.srcDirs += oldDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileSdkVersion "$android_compileSdkVersion".toInteger()
|
||||||
|
buildToolsVersion "$android_buildToolsVersion"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion "$android_minSdkVersion".toInteger()
|
||||||
|
targetSdkVersion "$android_compileSdkVersion".toInteger()
|
||||||
|
versionCode "${android_code_version}".toInteger()
|
||||||
|
versionName "$version"
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
debuggable true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packagingOptions {
|
||||||
|
exclude 'META-INF/kotlinx-serialization-runtime.kotlin_module'
|
||||||
|
exclude 'META-INF/kotlinx-serialization-cbor.kotlin_module'
|
||||||
|
exclude 'META-INF/kotlinx-serialization-properties.kotlin_module'
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
String sep = File.separator
|
||||||
|
main.java.srcDirs += "src${sep}main${sep}kotlin"
|
||||||
|
}
|
||||||
|
|
||||||
|
enableIncludingJvmCodeInAndroidPart()
|
||||||
|
}
|
@ -1,10 +1,24 @@
|
|||||||
allprojects {
|
allprojects {
|
||||||
ext {
|
ext {
|
||||||
mppProjectWithSerializationPresetPath = "${rootProject.projectDir.absolutePath}/mppProjectWithSerialization"
|
projectByName = { String name ->
|
||||||
mppJavaProjectPresetPath = "${rootProject.projectDir.absolutePath}/mppJavaProject"
|
for (subproject in rootProject.subprojects) {
|
||||||
mppAndroidProjectPresetPath = "${rootProject.projectDir.absolutePath}/mppAndroidProject"
|
if (subproject.name == name) {
|
||||||
|
return subproject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
defaultAndroidSettingsPresetPath = "${rootProject.projectDir.absolutePath}/defaultAndroidSettings"
|
internalProject = { String name ->
|
||||||
|
projectByName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
publishGradlePath = "${rootProject.projectDir.absolutePath}/publish.gradle"
|
||||||
}
|
}
|
||||||
|
@ -10,9 +10,8 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
dependencies {
|
dependencies {
|
||||||
api "dev.inmo:micro_utils.ktor.common:$microutils_version"
|
api project(":postssystem.features.common.client")
|
||||||
|
api project(":postssystem.features.auth.common")
|
||||||
api project(":postssystem.publishing.api")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.client
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.auth.common.*
|
||||||
|
import dev.inmo.postssystem.features.users.common.User
|
||||||
|
import dev.inmo.micro_utils.ktor.client.UnifiedRequester
|
||||||
|
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.HttpRequestBuilder
|
||||||
|
import kotlinx.serialization.builtins.nullable
|
||||||
|
|
||||||
|
class ClientAuthFeature(
|
||||||
|
private val requester: UnifiedRequester,
|
||||||
|
baseUrl: String
|
||||||
|
) : AuthFeature {
|
||||||
|
private val rootUrl = buildStandardUrl(baseUrl.dropLastWhile { it == '/' }, authRootPathPart)
|
||||||
|
private val fullAuthPath = buildStandardUrl(
|
||||||
|
rootUrl,
|
||||||
|
authAuthPathPart
|
||||||
|
)
|
||||||
|
private val fullRefreshPath = buildStandardUrl(
|
||||||
|
rootUrl,
|
||||||
|
authRefreshPathPart
|
||||||
|
)
|
||||||
|
private val fullGetMePath = buildStandardUrl(
|
||||||
|
rootUrl,
|
||||||
|
authGetMePathPart
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(client: HttpClient, rootUrl: String): this(
|
||||||
|
UnifiedRequester(client),
|
||||||
|
rootUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun auth(creds: AuthCreds): AuthTokenInfo? = requester.unipost(
|
||||||
|
fullAuthPath,
|
||||||
|
AuthCreds.serializer() to creds,
|
||||||
|
AuthTokenInfo.serializer().nullable
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun refresh(refresh: RefreshToken): AuthTokenInfo? = requester.unipost(
|
||||||
|
fullRefreshPath,
|
||||||
|
RefreshToken.serializer() to refresh,
|
||||||
|
AuthTokenInfo.serializer().nullable
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getMe(authToken: AuthToken): User? = requester.unipost(
|
||||||
|
fullGetMePath,
|
||||||
|
AuthToken.serializer() to authToken,
|
||||||
|
User.serializer().nullable
|
||||||
|
)
|
||||||
|
|
||||||
|
fun isAuthRequest(builder: HttpRequestBuilder): Boolean = builder.url.buildString().let {
|
||||||
|
it == fullAuthPath || it == fullRefreshPath
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,112 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.client
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.auth.common.*
|
||||||
|
import dev.inmo.postssystem.features.users.common.User
|
||||||
|
import dev.inmo.micro_utils.common.*
|
||||||
|
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
|
||||||
|
import io.ktor.client.HttpClientConfig
|
||||||
|
import io.ktor.client.features.cookies.*
|
||||||
|
import io.ktor.client.features.expectSuccess
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.HttpReceivePipeline
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import io.ktor.http.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
object AuthUnavailableException : Exception()
|
||||||
|
|
||||||
|
fun HttpClientConfig<*>.installClientAuthenticator(
|
||||||
|
baseUrl: String,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
|
||||||
|
onAuthKeyUpdated: suspend (AuthTokenInfo) -> Unit,
|
||||||
|
onUserRetrieved: suspend (User?) -> Unit,
|
||||||
|
onAuthKeyInvalidated: suspend () -> Unit
|
||||||
|
) {
|
||||||
|
// install(Logging) {
|
||||||
|
// logger = Logger.DEFAULT
|
||||||
|
// level = LogLevel.HEADERS
|
||||||
|
// }
|
||||||
|
install(HttpCookies) {
|
||||||
|
// Will keep an in-memory map with all the cookies from previous requests.
|
||||||
|
storage = AcceptAllCookiesStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
val authMutex = Mutex()
|
||||||
|
var currentRefreshToken: RefreshToken? = null
|
||||||
|
initialAuthKey.onFirst {
|
||||||
|
currentRefreshToken = it as? RefreshToken
|
||||||
|
}.onSecond {
|
||||||
|
currentRefreshToken = it.refresh
|
||||||
|
}
|
||||||
|
val creds = initialAuthKey.t1 as? AuthCreds
|
||||||
|
var userRefreshJob: Job? = null
|
||||||
|
|
||||||
|
install("Auth Token Refresher") {
|
||||||
|
val clientAuthFeature = ClientAuthFeature(this, baseUrl)
|
||||||
|
fun refreshUser(newTokenInfo: AuthTokenInfo) {
|
||||||
|
userRefreshJob ?.cancel()
|
||||||
|
userRefreshJob = scope.launchSafelyWithoutExceptions {
|
||||||
|
onUserRetrieved(clientAuthFeature.getMe(newTokenInfo.token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialAuthKey.onSecond { refreshUser(it) }
|
||||||
|
|
||||||
|
suspend fun refreshToken() {
|
||||||
|
val capturedRefresh = currentRefreshToken
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
when {
|
||||||
|
capturedRefresh == null && creds == null -> throw AuthUnavailableException
|
||||||
|
capturedRefresh != null -> {
|
||||||
|
currentRefreshToken = null
|
||||||
|
val newTokenInfo = clientAuthFeature.refresh(capturedRefresh)
|
||||||
|
currentRefreshToken = newTokenInfo ?.refresh
|
||||||
|
if (newTokenInfo == null) {
|
||||||
|
refreshToken()
|
||||||
|
} else {
|
||||||
|
onAuthKeyUpdated(newTokenInfo)
|
||||||
|
refreshUser(newTokenInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
creds != null -> {
|
||||||
|
val newAuthTokenInfo = clientAuthFeature.auth(creds)
|
||||||
|
|
||||||
|
if (newAuthTokenInfo != null) {
|
||||||
|
onAuthKeyUpdated(newAuthTokenInfo)
|
||||||
|
refreshUser(newAuthTokenInfo)
|
||||||
|
currentRefreshToken = newAuthTokenInfo.refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
onAuthKeyInvalidated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPipeline.intercept(HttpSendPipeline.State) {
|
||||||
|
if (!context.url.buildString().startsWith(baseUrl) || clientAuthFeature.isAuthRequest(context)) {
|
||||||
|
return@intercept
|
||||||
|
}
|
||||||
|
context.expectSuccess = false
|
||||||
|
if (authMutex.isLocked) {
|
||||||
|
authMutex.withLock { /* do nothing, just wait while mutex will be freed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receivePipeline.intercept(HttpReceivePipeline.Before) {
|
||||||
|
if (
|
||||||
|
context.request.url.toString().startsWith(baseUrl)
|
||||||
|
&& context.response.status == HttpStatusCode.Unauthorized
|
||||||
|
) {
|
||||||
|
authMutex.withLock { refreshToken() }
|
||||||
|
val newResponse = context.client ?.request<HttpResponse>{
|
||||||
|
takeFrom(context.request)
|
||||||
|
} ?: return@intercept
|
||||||
|
proceedWith(newResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.client.ui
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.auth.common.AuthCreds
|
||||||
|
import dev.inmo.postssystem.features.common.common.UIModel
|
||||||
|
|
||||||
|
interface AuthUIModel : UIModel<AuthUIState> {
|
||||||
|
suspend fun initAuth(serverUrl: String, creds: AuthCreds)
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.client.ui
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class AuthUIError
|
||||||
|
@Serializable
|
||||||
|
object ServerUnavailableAuthUIError : AuthUIError()
|
||||||
|
@Serializable
|
||||||
|
object AuthIncorrectAuthUIError : AuthUIError()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class AuthUIState
|
||||||
|
@Serializable
|
||||||
|
data class InitAuthUIState(val showError: AuthUIError? = null) : AuthUIState()
|
||||||
|
val DefaultInitAuthUIState = InitAuthUIState()
|
||||||
|
@Serializable
|
||||||
|
object LoadingAuthUIState : AuthUIState()
|
||||||
|
@Serializable
|
||||||
|
object AuthorizedAuthUIState : AuthUIState()
|
@ -0,0 +1,34 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.client.ui
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.auth.common.AuthCreds
|
||||||
|
import dev.inmo.postssystem.features.common.common.UIViewModel
|
||||||
|
import dev.inmo.postssystem.features.users.common.Username
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class AuthUIViewModel(
|
||||||
|
private val model: AuthUIModel
|
||||||
|
) : UIViewModel<AuthUIState> {
|
||||||
|
override val currentState: StateFlow<AuthUIState>
|
||||||
|
get() = model.currentState
|
||||||
|
|
||||||
|
private fun checkIncomingData(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
password: String
|
||||||
|
): Boolean {
|
||||||
|
return serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun initAuth(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
password: String
|
||||||
|
) {
|
||||||
|
if (checkIncomingData(serverUrl, username, password)) {
|
||||||
|
model.initAuth(
|
||||||
|
serverUrl.takeIf { it.startsWith("http") } ?: "http://$serverUrl",
|
||||||
|
AuthCreds(Username(username), password)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
features/auth/client/src/main/AndroidManifest.xml
Normal file
1
features/auth/client/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
|||||||
|
<manifest package="dev.inmo.postssystem.features.auth.client"/>
|
@ -10,10 +10,8 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation kotlin('stdlib')
|
api project(":postssystem.features.common.common")
|
||||||
|
api project(":postssystem.features.users.common")
|
||||||
api project(":postssystem.core.ktor.common")
|
|
||||||
api project(":postssystem.publishing.ktor.common")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.common
|
||||||
|
|
||||||
|
import dev.inmo.postssystem.features.users.common.User
|
||||||
|
|
||||||
|
interface AuthFeature {
|
||||||
|
suspend fun auth(creds: AuthCreds): AuthTokenInfo?
|
||||||
|
suspend fun refresh(refresh: RefreshToken): AuthTokenInfo?
|
||||||
|
suspend fun getMe(authToken: AuthToken): User?
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.common
|
||||||
|
|
||||||
|
import com.benasher44.uuid.uuid4
|
||||||
|
import dev.inmo.postssystem.features.users.common.Username
|
||||||
|
import kotlinx.serialization.*
|
||||||
|
import kotlin.jvm.JvmInline
|
||||||
|
|
||||||
|
sealed interface AuthKey
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("authcreds")
|
||||||
|
data class AuthCreds(
|
||||||
|
val username: Username,
|
||||||
|
val password: String
|
||||||
|
): AuthKey
|
||||||
|
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("token")
|
||||||
|
@JvmInline
|
||||||
|
value class AuthToken(val string: String = uuid4().toString()): AuthKey {
|
||||||
|
override fun toString(): String = string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("refresh")
|
||||||
|
@JvmInline
|
||||||
|
value class RefreshToken(val string: String = uuid4().toString()): AuthKey {
|
||||||
|
override fun toString(): String = string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthTokenInfo(
|
||||||
|
val token: AuthToken,
|
||||||
|
val refresh: RefreshToken
|
||||||
|
)
|
@ -0,0 +1,8 @@
|
|||||||
|
package dev.inmo.postssystem.features.auth.common
|
||||||
|
|
||||||
|
const val tokenSessionKey = "token"
|
||||||
|
|
||||||
|
const val authRootPathPart = "auth"
|
||||||
|
const val authAuthPathPart = "auth"
|
||||||
|
const val authRefreshPathPart = "refresh"
|
||||||
|
const val authGetMePathPart = "getMe"
|
1
features/auth/common/src/main/AndroidManifest.xml
Normal file
1
features/auth/common/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
|||||||
|
<manifest package="dev.inmo.postssystem.features.auth.common"/>
|
17
features/auth/server/build.gradle
Normal file
17
features/auth/server/build.gradle
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
plugins {
|
||||||
|
id "org.jetbrains.kotlin.multiplatform"
|
||||||
|
id "org.jetbrains.kotlin.plugin.serialization"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$mppJavaProjectPresetPath"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain {
|
||||||
|
dependencies {
|
||||||
|
api project(":postssystem.features.auth.common")
|
||||||
|
api project(":postssystem.features.common.server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user