diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..13f8d2d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gradle" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..9680345 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,25 @@ +name: check + +on: + push: + branches: + - '*' + tags-ignore: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Configure JDK + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '11' + cache: 'gradle' + - name: Fetch origin/master + run: git fetch origin master + - name: Build + run: ./gradlew -Psimple.kmm.spotless.ratchet.git.branch=origin/master clean build --no-daemon --no-parallel --stacktrace diff --git a/.github/workflows/sonatypePublish.yml b/.github/workflows/sonatypePublish.yml new file mode 100644 index 0000000..9e506c2 --- /dev/null +++ b/.github/workflows/sonatypePublish.yml @@ -0,0 +1,60 @@ +name: publish to sonatype + +on: + push: + tags: + - 'v*' +jobs: + generate_publish_id: + runs-on: ubuntu-latest + name: Create staging repository + outputs: + repository_id: ${{ steps.create.outputs.repository_id }} + steps: + - id: create + uses: nexus-actions/create-nexus-staging-repo@3e5e7209801629febdcf75541a4898710d28df9a + with: + username: ${{ secrets.PUBLISH_REPOSITORY_USERNAME }} + password: ${{ secrets.PUBLISH_REPOSITORY_PASSWORD }} + staging_profile_id: ${{ secrets.SONATYPE_PROFILE_ID }} + base_url: ${{ secrets.SONATYPE_BASE_URL }} + publish: + needs: generate_publish_id + strategy: + matrix: + include: + - os: windows-latest + gradlew: gradlew.bat + tasks: publishNativeForWindowsToMavenRepository + - os: macos-latest + gradlew: gradlew + tasks: publishNativeForMacosToMavenRepository + - os: ubuntu-latest + gradlew: gradlew + tasks: publishNativeForLinuxToMavenRepository publishNotNativeToMavenRepository + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Configure JDK + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '11' + - name: Fetch project + run: git fetch --all -f -P -p + - name: Publish + uses: gradle/gradle-build-action@v2 + with: + arguments: > + ${{ matrix.tasks }} + -Psimple.kmm.spotless.ratchet.git.branch=origin/master + -Psimple.kmm.publish.repository.url=${{ secrets.PUBLISH_REPOSITORY_URL }} + -Psimple.kmm.publish.repository.id=${{ needs.generate_publish_id.outputs.repository_id }} + -Psimple.kmm.publish.username=${{ secrets.PUBLISH_REPOSITORY_USERNAME }} + -Psimple.kmm.publish.password=${{ secrets.PUBLISH_REPOSITORY_PASSWORD }} + -Psimple.kmm.sign.key.id=${{ secrets.SIGNING_KEY_ID }} + -Psimple.kmm.sign.password=${{ secrets.SIGNING_PASSWORD }} + -Psimple.kmm.sign.private.key=${{ secrets.SIGNING_PRIVATE_KEY }} + -Psimple.kmm.kotlin.compile.target.browser.js.enabled=false + --no-daemon --no-parallel --stacktrace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f5be73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +.gradle +build/ +!**/src/commonMain/**/build/ +!**/src/commonTest/**/build/ +/logging/ +/kotlin-js-store/ +!**/src/**/Env.kt + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + 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. \ No newline at end of file diff --git a/LICENSE_FILE_HEADER b/LICENSE_FILE_HEADER new file mode 100644 index 0000000..eaf597f --- /dev/null +++ b/LICENSE_FILE_HEADER @@ -0,0 +1,14 @@ +/* + * Copyright (c) $today.year. Ilia Loginov + * 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. + */ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7bf71e --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Simple kotlix serialization multipart library + +[![Maven Central](http://img.shields.io/maven-central/v/io.github.edmondantes/simple-kotlinx-multipart-serialization?color=green&style=flat-square)](https://search.maven.org/search?q=g:io.github.edmondantes%20a:simple-kotlinx-multipart-serialization) +[![GitHub](http://img.shields.io/github/license/Simple-Kotlin-Project/simple-kotlinx-multipart-serialization?style=flat-square)](https://github.com/Simple-Kotlin-Project/simple-kotlinx-multipart-serialization/blob/master/LICENSE) +[![Kotlin](https://img.shields.io/badge/kotlin-1.9.10-blue.svg?logo=kotlin)](http://kotlinlang.org) +[![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/Simple-Kotlin-Project/simple-kotlinx-multipart-serialization/check.yml?branch=master&style=flat-square)](https://github.com/Simple-Kotlin-Project/simple-kotlinx-multipart-serialization/actions/workflows/check.yml) + +Small library for serialize and deserialize by multipart content type in http protocol + +### How to add library to you project + +#### Maven + +```xml + + + io.github.edmondantes + simple-kotlinx-multipart-serialization + ${simple_library_version} + +``` + +#### Gradle (kotlin) + +```kotlin +implementation("io.github.edmondantes:simple-kotlinx-multipart-serialization:${simple_library_version}") +``` + +#### Gradle (groovy) + +```groovy +implementation "io.github.edmondantes:simple-kotlinx-multipart-serialization:${simple_library_version}" +``` \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b35084d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.simple.kotlin.gradle) + alias(libs.plugins.kotlin.serialization.plugin) +} + +group = "io.github.edmondantes" + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.simple.kotlin.serialization.utils) + implementation(libs.simple.kotlin.multipart) + implementation(libs.kotlin.serialization) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +licenses { + apache2() +} + +developers { + developer { + name = "Ilia Loginov" + email = "masaqaz40@gmail.com" + organizationName("github") + role("Maintainer") + role("Developer") + } +} + +simplePom { + any { + title = "Simple kotlin Telegram API for Kotlin" + description = "Library with entities for Telegram API" + url = "#github::Simple-Kotlin-Project::${project.name}" + scm { + url = "#github::Simple-Kotlin-Project::${project.name}::master" + connection = "#github::Simple-Kotlin-Project::${project.name}" + developerConnection = "#github::Simple-Kotlin-Project::${project.name}" + } + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..03b4010 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +simple.kmm.kotlin.configuration.enabled=true +simple.kmm.kotlin.serialization.plugin.enabled=true +simple.kmm.kotlin.explicit.api.enabled=true + +simple.kmm.kotlin.compile.target.jvm.enabled=true +simple.kmm.kotlin.compile.target.js.enabled=true +simple.kmm.kotlin.compile.target.browser.js.enabled=false +simple.kmm.kotlin.compile.target.native.enabled=true + +simple.kmm.github.sonatype.publish.enabled=true \ No newline at end of file diff --git a/gradle/libs.version.toml b/gradle/libs.version.toml new file mode 100644 index 0000000..c58c626 --- /dev/null +++ b/gradle/libs.version.toml @@ -0,0 +1,15 @@ +[versions] +simplekmm = "0.6.3" +simple_kotlin_serialization_utils = "0.8.1" +kotlin_serialization_version = "1.6.0" +kotlin_serialization_plugin_version = "1.9.0" +simple-kotlin-multipart-version = "1.0.0" + +[libraries] +simple_kotlin_serialization_utils = { module = "io.github.edmondantes:simple-kotlinx-serialization-utils", version.ref = "simple_kotlin_serialization_utils" } +kotlin_serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlin_serialization_version" } +simple-kotlin-multipart = { module = "io.github.edmondantes:simple-kotlin-multipart", version.ref = "simple-kotlin-multipart-version" } + +[plugins] +simple_kotlin_gradle = { id = "io.github.edmondantes.simple.kmm.gradle.plugin", version.ref = "simplekmm" } +kotlin_serialization_plugin = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin_serialization_plugin_version" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..06febab --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7a0102c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,19 @@ +rootProject.name = "simple-kotlinx-multipart-serialization" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("./gradle/libs.version.toml")) + } + } +} + +pluginManagement { + repositories { + maven { + name = "localPluginRepository" + url = uri("../local-plugin-repository") + } + gradlePluginPortal() + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartDynamicHeader.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartDynamicHeader.kt new file mode 100644 index 0000000..5d92d13 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartDynamicHeader.kt @@ -0,0 +1,14 @@ +package io.github.edmondantes.multipart.serialization + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialInfo + +@OptIn(ExperimentalSerializationApi::class) +@SerialInfo +@Target(AnnotationTarget.PROPERTY) +@Repeatable +public annotation class MultipartDynamicHeader( + val headerName: String, + val serialPropertyName: String, + val headerAttribute: String = "", +) diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartDynamicHeaders.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartDynamicHeaders.kt new file mode 100644 index 0000000..853bb3f --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartDynamicHeaders.kt @@ -0,0 +1,9 @@ +package io.github.edmondantes.multipart.serialization + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialInfo + +@OptIn(ExperimentalSerializationApi::class) +@SerialInfo +@Target(AnnotationTarget.PROPERTY) +public annotation class MultipartDynamicHeaders(val headers: Array) diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartForm.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartForm.kt new file mode 100644 index 0000000..014c996 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartForm.kt @@ -0,0 +1,54 @@ +package io.github.edmondantes.multipart.serialization + +import io.github.edmondantes.multipart.MultipartFormData +import io.github.edmondantes.multipart.serialization.config.MultipartFormConfig +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.decoder.MultipartFormRootDecoder +import io.github.edmondantes.multipart.serialization.encoder.MultipartFormRootEncoder +import io.github.edmondantes.multipart.serialization.util.MultipartFormDataBuilderWithActions +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +/** + * Support classes which have properties by only these types: + * * Byte + * * Char + * * Short + * * Int + * * Long + * * Double + * * String + * * ByteArray + * * or List of one of above + */ +public class MultipartForm( + internal val config: MultipartFormConfig, + override val serializersModule: SerializersModule = config.serializersModule, +) : BinaryFormat { + + public val multipartFormEncoderDecoderConfig: MultipartFormEncoderDecoderConfig = + MultipartFormEncoderDecoderConfig(config.stringEncoder, config.stringDecoder, serializersModule) + + override fun decodeFromByteArray(deserializer: DeserializationStrategy, bytes: ByteArray): T { + return deserializer.deserialize( + MultipartFormRootDecoder( + MultipartFormData.constructFromMultipart(config.multipartDecoder.decode(config.boundary, bytes)), + multipartFormEncoderDecoderConfig, + ), + ) + } + + override fun encodeToByteArray(serializer: SerializationStrategy, value: T): ByteArray { + val builder = MultipartFormDataBuilderWithActions() + val formEncoder = MultipartFormRootEncoder(builder, multipartFormEncoderDecoderConfig) + serializer.serialize(formEncoder, value) + + return config.multipartEncoder.encode(config.boundary, builder.build()) + } + + public companion object { + public val Default: MultipartForm = MultipartForm(MultipartFormConfig.Default) + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartStaticHeader.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartStaticHeader.kt new file mode 100644 index 0000000..9eb422e --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartStaticHeader.kt @@ -0,0 +1,10 @@ +package io.github.edmondantes.multipart.serialization + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialInfo + +@OptIn(ExperimentalSerializationApi::class) +@SerialInfo +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Repeatable +public annotation class MultipartStaticHeader(val name: String, val value: String, val headerAttribute: String = "") diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartStaticHeaders.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartStaticHeaders.kt new file mode 100644 index 0000000..48dc72a --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/MultipartStaticHeaders.kt @@ -0,0 +1,9 @@ +package io.github.edmondantes.multipart.serialization + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialInfo + +@OptIn(ExperimentalSerializationApi::class) +@SerialInfo +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +public annotation class MultipartStaticHeaders(val headers: Array) diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/config/MultipartFormConfig.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/config/MultipartFormConfig.kt new file mode 100644 index 0000000..fcb4b59 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/config/MultipartFormConfig.kt @@ -0,0 +1,67 @@ +package io.github.edmondantes.multipart.serialization.config + +import io.github.edmondantes.multipart.MultipartDecoder +import io.github.edmondantes.multipart.MultipartEncoder +import io.github.edmondantes.multipart.impl.DefaultMultipartEncoderDecoder +import io.github.edmondantes.multipart.impl.DefaultMultipartEncoderDecoderConfiguration +import io.github.edmondantes.multipart.serialization.util.nextAlphanumericString +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import kotlin.random.Random + +public class MultipartFormConfig( + public val boundary: String = Random.nextAlphanumericString(70), + multipartEncoder: MultipartEncoder? = null, + multipartDecoder: MultipartDecoder? = null, + public val stringEncoder: (String) -> ByteArray = String::encodeToByteArray, + public val stringDecoder: (ByteArray) -> String = ByteArray::decodeToString, + public val serializersModule: SerializersModule = EmptySerializersModule(), +) { + public val multipartEncoder: MultipartEncoder + public val multipartDecoder: MultipartDecoder + + init { + if (multipartEncoder == null || multipartDecoder == null) { + val encoderDecoder = DefaultMultipartEncoderDecoder( + DefaultMultipartEncoderDecoderConfiguration( + shouldCheckBoundary = true, + shouldCheckTrailerCounts = true, + stringEncoder, + stringDecoder, + "\n", + ), + ) + + this.multipartEncoder = multipartEncoder ?: encoderDecoder + this.multipartDecoder = multipartDecoder ?: encoderDecoder + } else { + this.multipartEncoder = multipartEncoder + this.multipartDecoder = multipartDecoder + } + } + + public companion object { + public val Default: MultipartFormConfig + + init { + val encoderDecoder = DefaultMultipartEncoderDecoder( + DefaultMultipartEncoderDecoderConfiguration( + shouldCheckBoundary = true, + shouldCheckTrailerCounts = true, + String::encodeToByteArray, + ByteArray::decodeToString, + "\n", + ), + ) + + Default = MultipartFormConfig( + ('1'..'9').joinToString("") + '0' + ('a'..'z').joinToString(""), + encoderDecoder, + encoderDecoder, + String::encodeToByteArray, + ByteArray::decodeToString, + EmptySerializersModule(), + ) + } + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/config/MultipartFormEncoderDecoderConfig.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/config/MultipartFormEncoderDecoderConfig.kt new file mode 100644 index 0000000..8fcecb2 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/config/MultipartFormEncoderDecoderConfig.kt @@ -0,0 +1,9 @@ +package io.github.edmondantes.multipart.serialization.config + +import kotlinx.serialization.modules.SerializersModule + +public class MultipartFormEncoderDecoderConfig( + public val stringEncoder: (String) -> ByteArray, + public val stringDecoder: (ByteArray) -> String, + public val serializersModule: SerializersModule, +) diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormCompositeDecoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormCompositeDecoder.kt new file mode 100644 index 0000000..f646d40 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormCompositeDecoder.kt @@ -0,0 +1,184 @@ +package io.github.edmondantes.multipart.serialization.decoder + +import io.github.edmondantes.multipart.MultipartFormData +import io.github.edmondantes.multipart.serialization.MultipartDynamicHeader +import io.github.edmondantes.multipart.serialization.MultipartDynamicHeaders +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.decoder.MultipartFormDecoder.Companion.notSupport +import io.github.edmondantes.serialization.getElementAllAnnotation +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.serialDescriptor +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.modules.SerializersModule + +public open class MultipartFormCompositeDecoder( + protected open val multipartFormData: MultipartFormData, + protected open val config: MultipartFormEncoderDecoderConfig, + override val serializersModule: SerializersModule = config.serializersModule, +) : CompositeDecoder { + + protected open var nextElementIndex: Int = 0 + + @OptIn(ExperimentalSerializationApi::class) + override fun decodeElementIndex(descriptor: SerialDescriptor): Int = + if (nextElementIndex < descriptor.elementsCount) { + nextElementIndex++ + } else { + CompositeDecoder.DECODE_DONE + } + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int): Boolean = + decodeElement(descriptor, index).toBoolean() + + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int): Byte = + decodeElement(descriptor, index).toByte() + + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int): Short = + decodeElement(descriptor, index).toShort() + + @OptIn(ExperimentalSerializationApi::class) + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int): Char = + decodeElement(descriptor, index).getOrNull(0) + ?: throw SerializationException( + "Can not find value for property with name '${descriptor.getElementName(index)}'", + ) + + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int): Int = + decodeElement(descriptor, index).toInt() + + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int): Long = + decodeElement(descriptor, index).toLong() + + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int): Float = + decodeElement(descriptor, index).toFloat() + + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int): Double = + decodeElement(descriptor, index).toDouble() + + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int): String = + decodeElement(descriptor, index) + + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int): Decoder { + notSupport() + } + + @Suppress("UNCHECKED_CAST", "IMPLICIT_CAST_TO_ANY") + @ExperimentalSerializationApi + override fun decodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T?, + ): T? { + val elementDescriptor = descriptor.getElementDescriptor(index) + + var dynamicHeaderValue: List? = null + var partValue: List? = null + + decodeDynamicHeader(descriptor, index, { + dynamicHeaderValue = it + }) { + partValue = decodePartValue(descriptor, index) + } + + return when (elementDescriptor) { + BYTE_ARRAY_DESCRIPTOR -> partValue?.get(0) ?: dynamicHeaderValue?.get(0)?.let { config.stringEncoder(it) } + STRING_DESCRIPTOR -> dynamicHeaderValue?.get(0) ?: partValue?.get(0)?.let { config.stringDecoder(it) } + else -> if (!dynamicHeaderValue.isNullOrEmpty()) { + deserializer.deserialize( + MultipartFormDecoder( + null, + dynamicHeaderValue, + config, + serializersModule, + ), + ) + } else if (!partValue.isNullOrEmpty()) { + deserializer.deserialize( + MultipartFormDecoder( + partValue, + null, + config, + serializersModule, + ), + ) + } else { + null + } + } as T? + } + + @OptIn(ExperimentalSerializationApi::class) + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T?, + ): T = + decodeNullableSerializableElement(descriptor, index, deserializer, previousValue) + ?: throw SerializationException("Can not find value for '${descriptor.getElementName(index)}'") + + override fun endStructure(descriptor: SerialDescriptor) {} + + @OptIn(ExperimentalSerializationApi::class) + protected open fun decodeElement(descriptor: SerialDescriptor, index: Int): String = + decodeElementOrNull(descriptor, index).firstOrNull() + ?: throw SerializationException( + "Can not find value for property with name '${descriptor.getElementName(index)}'", + ) + + protected open fun decodeElementOrNull(descriptor: SerialDescriptor, index: Int): List = + decodeDynamicHeader(descriptor, index, { it }) { + decodePartValue(descriptor, index).map(config.stringDecoder) + } + + private inline fun decodeDynamicHeader( + descriptor: SerialDescriptor, + index: Int, + dynamicHeaderValueConsumer: (List) -> T, + notDynamicHeaderAction: () -> T, + ): T { + val dynamicHeaders = + descriptor + .getElementAllAnnotation(index) + .filterIsInstance() + .flatMap { it.headers.toList() } + + descriptor + .getElementAllAnnotation(index) + .filterIsInstance() + + if (dynamicHeaders.isNotEmpty()) { + var headerValue: List? = null + var i = 0 + while (headerValue == null && i < dynamicHeaders.size) { + val dynamicHeader = dynamicHeaders[i] + val header = + multipartFormData.namedParts[dynamicHeader.serialPropertyName]?.mapNotNull { it.headers[dynamicHeader.headerName] } + headerValue = + if (dynamicHeader.headerAttribute.isNotEmpty()) { + header?.mapNotNull { it.attributes[dynamicHeader.headerAttribute] } + } else { + header?.mapNotNull { it.value } + } + i++ + } + + return dynamicHeaderValueConsumer(headerValue ?: emptyList()) + } + + return notDynamicHeaderAction() + } + + @OptIn(ExperimentalSerializationApi::class) + protected open fun decodePartValue(descriptor: SerialDescriptor, index: Int): List = + multipartFormData.namedParts[descriptor.getElementName(index)]?.map { it.body } ?: emptyList() + + internal companion object { + internal val BYTE_ARRAY_DESCRIPTOR: SerialDescriptor = serialDescriptor() + internal val STRING_DESCRIPTOR: SerialDescriptor = serialDescriptor() + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormDecoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormDecoder.kt new file mode 100644 index 0000000..9bba2b8 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormDecoder.kt @@ -0,0 +1,111 @@ +package io.github.edmondantes.multipart.serialization.decoder + +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.serialization.decoding.UniqueDecoder +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.modules.SerializersModule + +public open class MultipartFormDecoder( + protected open val bytesValue: List?, + protected open val stringValue: List?, + protected open val config: MultipartFormEncoderDecoderConfig, + override val serializersModule: SerializersModule = config.serializersModule +) : UniqueDecoder { + override val id: String = ID + + @OptIn(ExperimentalSerializationApi::class) + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + if (descriptor.kind is StructureKind.LIST) { + return MultipartFormListCompositeDecoder(bytesValue, stringValue, config) + } + throw SerializationException("Can not decode not-flat objects") + } + + override fun decodeBoolean(): Boolean = + decodeOneStringElement().toBoolean() + + override fun decodeByte(): Byte = + decodeOneStringElement().toByte() + + override fun decodeShort(): Short = + decodeOneStringElement().toShort() + + override fun decodeChar(): Char = + decodeOneStringElement()[0] + + override fun decodeInt(): Int = + decodeOneStringElement().toInt() + + override fun decodeLong(): Long = + decodeOneStringElement().toLong() + + override fun decodeFloat(): Float = + decodeOneStringElement().toFloat() + + override fun decodeDouble(): Double = + decodeOneStringElement().toDouble() + + override fun decodeString(): String = + decodeOneStringElement() + + @OptIn(ExperimentalSerializationApi::class) + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { + val decodedElementName = decodeOneStringElement() + val realElementName = enumDescriptor.elementNames.find { + it.lowercase() == decodedElementName + } + ?: throw SerializationException("Can not find element with name '$decodedElementName' in enum '${enumDescriptor.serialName}'") + + return enumDescriptor.getElementIndex(realElementName) + } + + override fun decodeInline(descriptor: SerialDescriptor): Decoder { + throw SerializationException("Not support") + } + + @ExperimentalSerializationApi + override fun decodeNotNullMark(): Boolean = + stringValue?.firstOrNull()?.isNotEmpty() == true || bytesValue?.firstOrNull()?.isNotEmpty() == true + + @ExperimentalSerializationApi + override fun decodeNull(): Nothing? = null + + protected open fun decodeOneStringElement(): String { + if (stringValue != null) { + return stringValue?.firstOrNull() ?: throw SerializationException("Can not decode value") + } + + if (bytesValue != null) { + return config.stringDecoder( + bytesValue?.firstOrNull() ?: throw SerializationException("Can not decode value"), + ) + } + + throw SerializationException("Can not decode value") + } + + protected open fun decodeOneBytesElement(): ByteArray { + if (stringValue != null) { + config.stringEncoder(stringValue?.firstOrNull() ?: throw SerializationException("Can not decode value")) + } + + if (bytesValue != null) { + return bytesValue?.firstOrNull() ?: throw SerializationException("Can not decode value") + } + + throw SerializationException("Can not decode value") + } + + public companion object { + internal const val ID: String = "io.github.edmondantes.serialization.multipart.decoder" + + public fun notSupport(): Nothing = + throw SerializationException("Can not decode value. Multipart form-data decoder doesn't support to decode this values") + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormListCompositeDecoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormListCompositeDecoder.kt new file mode 100644 index 0000000..d8387da --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormListCompositeDecoder.kt @@ -0,0 +1,137 @@ +package io.github.edmondantes.multipart.serialization.decoder + +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.decoder.MultipartFormCompositeDecoder.Companion.BYTE_ARRAY_DESCRIPTOR +import io.github.edmondantes.multipart.serialization.decoder.MultipartFormDecoder.Companion.notSupport +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.modules.SerializersModule + +public open class MultipartFormListCompositeDecoder( + protected open val bytesValue: List?, + protected open val stringValue: List?, + protected open val config: MultipartFormEncoderDecoderConfig, + override val serializersModule: SerializersModule = config.serializersModule, +) : CompositeDecoder { + + protected open var nextIndex: Int = 0 + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + return if (nextIndex < (bytesValue?.size ?: stringValue?.size ?: 0)) { + nextIndex++ + } else { + CompositeDecoder.DECODE_DONE + } + } + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int): Boolean = + decodeOneStringElement(index).toBoolean() + + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int): Byte = + decodeOneStringElement(index).toByte() + + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int): Char = + decodeOneStringElement(index).let { + if (it.isEmpty()) { + notSupport() + } + + it[0] + } + + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int): Short = + decodeOneStringElement(index).toShort() + + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int): Int = + decodeOneStringElement(index).toInt() + + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int): Long = + decodeOneStringElement(index).toLong() + + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int): Float = + decodeOneStringElement(index).toFloat() + + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int): Double = + decodeOneStringElement(index).toDouble() + + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int): String = + decodeOneStringElement(index) + + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int): Decoder { + notSupport() + } + + @ExperimentalSerializationApi + @Suppress("UNCHECKED_CAST") + override fun decodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T?, + ): T? { + val elementDescriptor = descriptor.getElementDescriptor(index) + + if (BYTE_ARRAY_DESCRIPTOR == elementDescriptor) { + return decodeOneBytesElement(index) as T? + } + + if (elementDescriptor.kind is PrimitiveKind) { + return when (elementDescriptor.kind) { + PrimitiveKind.BOOLEAN -> decodeBooleanElement(descriptor, index) + PrimitiveKind.BYTE -> decodeByteElement(descriptor, index) + PrimitiveKind.CHAR -> decodeCharElement(descriptor, index) + PrimitiveKind.SHORT -> decodeShortElement(descriptor, index) + PrimitiveKind.INT -> decodeIntElement(descriptor, index) + PrimitiveKind.LONG -> decodeLongElement(descriptor, index) + PrimitiveKind.DOUBLE -> decodeDoubleElement(descriptor, index) + PrimitiveKind.STRING -> decodeStringElement(descriptor, index) + else -> null + } as T? + } + + notSupport() + } + + @OptIn(ExperimentalSerializationApi::class) + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T?, + ): T = + decodeNullableSerializableElement(descriptor, index, deserializer, previousValue) + ?: throw SerializationException("Can not decode") + + override fun endStructure(descriptor: SerialDescriptor) {} + + protected open fun decodeOneStringElement(index: Int): String { + if (stringValue != null) { + return stringValue?.getOrNull(index) ?: throw SerializationException("Can not decode element") + } + + if (bytesValue != null) { + return bytesValue?.getOrNull(index)?.let(config.stringDecoder) + ?: throw SerializationException("Can not decode element") + } + + throw SerializationException("Can not decode element") + } + + protected open fun decodeOneBytesElement(index: Int): ByteArray { + if (stringValue != null) { + stringValue?.getOrNull(index)?.let(config.stringEncoder) + ?: throw SerializationException("Can not decode element") + } + + if (bytesValue != null) { + return bytesValue?.getOrNull(index) ?: throw SerializationException("Can not decode element") + } + + throw SerializationException("Can not decode element") + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormRootDecoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormRootDecoder.kt new file mode 100644 index 0000000..6b317de --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/decoder/MultipartFormRootDecoder.kt @@ -0,0 +1,85 @@ +package io.github.edmondantes.multipart.serialization.decoder + +import io.github.edmondantes.multipart.MultipartFormData +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.decoder.MultipartFormDecoder.Companion.ID +import io.github.edmondantes.multipart.serialization.decoder.MultipartFormDecoder.Companion.notSupport +import io.github.edmondantes.serialization.decoding.UniqueDecoder +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.modules.SerializersModule + +public open class MultipartFormRootDecoder( + protected open val multipart: MultipartFormData, + protected open val config: MultipartFormEncoderDecoderConfig, + override val serializersModule: SerializersModule = config.serializersModule, +) : UniqueDecoder { + override val id: String = ID + + @OptIn(ExperimentalSerializationApi::class) + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + if (descriptor.kind is StructureKind.CLASS || descriptor.kind is StructureKind.OBJECT) { + return MultipartFormCompositeDecoder(multipart, config, serializersModule) + } else { + throw SerializationException("Multipart form-data decoder can not decode structures with kind '${descriptor.kind}'") + } + } + + override fun decodeBoolean(): Boolean { + notSupport() + } + + override fun decodeByte(): Byte { + notSupport() + } + + override fun decodeChar(): Char { + notSupport() + } + + override fun decodeShort(): Short { + notSupport() + } + + override fun decodeInt(): Int { + notSupport() + } + + override fun decodeLong(): Long { + notSupport() + } + + override fun decodeFloat(): Float { + notSupport() + } + + override fun decodeDouble(): Double { + notSupport() + } + + override fun decodeString(): String { + notSupport() + } + + override fun decodeInline(descriptor: SerialDescriptor): Decoder { + notSupport() + } + + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { + notSupport() + } + + @ExperimentalSerializationApi + override fun decodeNotNullMark(): Boolean { + notSupport() + } + + @ExperimentalSerializationApi + override fun decodeNull(): Nothing? { + notSupport() + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormCompositeEncoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormCompositeEncoder.kt new file mode 100644 index 0000000..c9e912b --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormCompositeEncoder.kt @@ -0,0 +1,187 @@ +package io.github.edmondantes.multipart.serialization.encoder + +import io.github.edmondantes.multipart.builder.MultipartPartBuilder +import io.github.edmondantes.multipart.builder.multipartPart +import io.github.edmondantes.multipart.builder.multipartPartBuilder +import io.github.edmondantes.multipart.serialization.MultipartDynamicHeader +import io.github.edmondantes.multipart.serialization.MultipartDynamicHeaders +import io.github.edmondantes.multipart.serialization.MultipartStaticHeader +import io.github.edmondantes.multipart.serialization.MultipartStaticHeaders +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.encoder.MultipartFormEncoder.Companion.ID +import io.github.edmondantes.multipart.serialization.encoder.MultipartFormEncoder.Companion.notSupport +import io.github.edmondantes.multipart.serialization.util.MultipartFormDataBuilderWithActions +import io.github.edmondantes.serialization.encoding.UniqueCompositeEncoder +import io.github.edmondantes.serialization.getElementAllAnnotation +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule + +public open class MultipartFormCompositeEncoder( + protected open val multipartBuilder: MultipartFormDataBuilderWithActions, + protected open val config: MultipartFormEncoderDecoderConfig, + protected open val name: String? = null, + override val serializersModule: SerializersModule = config.serializersModule, +) : UniqueCompositeEncoder { + override val id: String = ID + + override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) { + encodeElement(descriptor, index, value.toString()) + } + + override fun encodeByteElement(descriptor: SerialDescriptor, index: Int, value: Byte) { + encodeElement(descriptor, index, value.toString()) + } + + override fun encodeCharElement(descriptor: SerialDescriptor, index: Int, value: Char) { + encodeElement(descriptor, index, CharArray(1) { value }.concatToString()) + } + + override fun encodeShortElement(descriptor: SerialDescriptor, index: Int, value: Short) { + encodeElement(descriptor, index, value.toString()) + } + + override fun encodeIntElement(descriptor: SerialDescriptor, index: Int, value: Int) { + encodeElement(descriptor, index, value.toString()) + } + + override fun encodeLongElement(descriptor: SerialDescriptor, index: Int, value: Long) { + encodeElement(descriptor, index, value.toString()) + } + + override fun encodeFloatElement(descriptor: SerialDescriptor, index: Int, value: Float) { + encodeElement(descriptor, index, value.toString()) + } + + override fun encodeDoubleElement(descriptor: SerialDescriptor, index: Int, value: Double) { + encodeElement(descriptor, index, value.toString()) + } + + override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int): Encoder { + notSupport() + } + + override fun encodeStringElement(descriptor: SerialDescriptor, index: Int, value: String) { + encodeElement(descriptor, index, value) + } + + @ExperimentalSerializationApi + override fun encodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T?, + ) { + if (value != null) { + encodeSerializableElement(descriptor, index, serializer, value) + } + } + + @OptIn(ExperimentalSerializationApi::class) + override fun encodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T, + ) { + when (value) { + is String -> encodeElement(descriptor, index, value) + is ByteArray -> encodeElement(descriptor, index, value) + + else -> { + val part = multipartPartBuilder() + serializer.serialize( + MultipartFormEncoder( + multipartBuilder, + config, + part, + name ?: descriptor.getElementName(index), + ), + value, + ) + + part.body?.also { encodeElement(descriptor, index, it) } + } + } + } + + override fun endStructure(descriptor: SerialDescriptor) {} + + protected open fun encodeElement(descriptor: SerialDescriptor, index: Int, value: String) { + if (!tryToEncodeDynamicHeader(descriptor, index, value)) { + encodeElement(descriptor, index, config.stringEncoder(value), false) + } + } + + @OptIn(ExperimentalSerializationApi::class) + protected open fun encodeElement( + descriptor: SerialDescriptor, + index: Int, + value: ByteArray, + tryEncodeDynamicHeader: Boolean = true, + ) { + if (tryEncodeDynamicHeader) { + tryToEncodeDynamicHeader(descriptor, index, value.decodeToString()) + } + + multipartBuilder.add( + name ?: descriptor.getElementName(index), + multipartPart { + body = value + + val staticHeaders = + descriptor + .getElementAllAnnotation(index) + .filterIsInstance() + .flatMap { it.headers.toList() } + + descriptor + .getElementAllAnnotation(index) + .filterIsInstance() + + staticHeaders.forEach { + encodeHeaderValueOrAttribute(it.name, it.headerAttribute, this, it.value) + } + }, + ) + } + + protected open fun tryToEncodeDynamicHeader(descriptor: SerialDescriptor, index: Int, value: String): Boolean { + val dynamicHeader = getDynamicHeaders(descriptor, index) + + dynamicHeader.forEach { header -> + multipartBuilder.addOnBuild { formDataBuilder -> + formDataBuilder.edit(header.serialPropertyName) { partBuilder -> + encodeHeaderValueOrAttribute(header.headerName, header.headerAttribute, partBuilder, value) + } + } + } + + return dynamicHeader.isNotEmpty() + } + + protected open fun getDynamicHeaders(descriptor: SerialDescriptor, index: Int): List = + descriptor + .getElementAllAnnotation(index) + .filterIsInstance() + .flatMap { it.headers.toList() } + + descriptor + .getElementAllAnnotation(index) + .filterIsInstance() + + protected open fun encodeHeaderValueOrAttribute( + name: String, + headerAttribute: String, + builder: MultipartPartBuilder, + value: String, + ) { + builder.header(name) { + if (headerAttribute.isNotBlank()) { + attributes[headerAttribute] = value + } else { + this.value = value + } + } + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormEncoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormEncoder.kt new file mode 100644 index 0000000..3f54ede --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormEncoder.kt @@ -0,0 +1,105 @@ +package io.github.edmondantes.multipart.serialization.encoder + +import io.github.edmondantes.multipart.builder.MultipartPartBuilder +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.util.MultipartFormDataBuilderWithActions +import io.github.edmondantes.serialization.encoding.UniqueEncoder +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule + +@OptIn(ExperimentalSerializationApi::class) +public open class MultipartFormEncoder( + protected open val builder: MultipartFormDataBuilderWithActions, + protected open val config: MultipartFormEncoderDecoderConfig, + protected open val partBuilder: MultipartPartBuilder? = null, + protected open val name: String? = null, + override val serializersModule: SerializersModule = config.serializersModule, +) : UniqueEncoder { + override val id: String = ID + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = + when (descriptor.kind) { + is StructureKind.LIST -> { + MultipartFormListCompositeEncoder( + builder, + name + ?: throw SerializationException("Multipart form-data encoder can not encode list without name"), + config, + serializersModule, + ) + } + + is StructureKind.CLASS, is StructureKind.OBJECT -> { + throw SerializationException("Multipart form-data encoder can not encode non-flat structures") + } + + else -> { + throw SerializationException("Multipart form-data encoder can not encode structures with kind '${descriptor.kind}'") + } + } + + override fun encodeBoolean(value: Boolean) { + encodeElement(value) + } + + override fun encodeByte(value: Byte) { + encodeElement(value) + } + + override fun encodeChar(value: Char) { + encodeElement(value) + } + + override fun encodeShort(value: Short) { + encodeElement(value) + } + + override fun encodeInt(value: Int) { + encodeElement(value) + } + + override fun encodeLong(value: Long) { + encodeElement(value) + } + + override fun encodeFloat(value: Float) { + encodeElement(value) + } + + override fun encodeDouble(value: Double) { + encodeElement(value) + } + + override fun encodeString(value: String) { + encodeElement(value) + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + encodeElement(enumDescriptor.getElementName(index)) + } + + override fun encodeInline(descriptor: SerialDescriptor): Encoder { + notSupport() + } + + @ExperimentalSerializationApi + override fun encodeNull() { + (partBuilder ?: notSupport()).body = ByteArray(0) + } + + protected open fun encodeElement(value: Any) { + (partBuilder ?: notSupport()).body = config.stringEncoder(value.toString()) + } + + public companion object { + internal const val ID: String = "io.github.edmondantes.serialization.multipart.encoder" + + public fun notSupport(): Nothing = + throw SerializationException("Can not encode value. Multipart form-data encoder doesn't support to encode this values") + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormListCompositeEncoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormListCompositeEncoder.kt new file mode 100644 index 0000000..2eb6ae9 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormListCompositeEncoder.kt @@ -0,0 +1,106 @@ +package io.github.edmondantes.multipart.serialization.encoder + +import io.github.edmondantes.multipart.builder.multipartPart +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.encoder.MultipartFormEncoder.Companion.ID +import io.github.edmondantes.multipart.serialization.encoder.MultipartFormEncoder.Companion.notSupport +import io.github.edmondantes.multipart.serialization.util.MultipartFormDataBuilderWithActions +import io.github.edmondantes.serialization.encoding.UniqueCompositeEncoder +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule + +public open class MultipartFormListCompositeEncoder( + protected open val builder: MultipartFormDataBuilderWithActions, + protected open val name: String, + protected open val config: MultipartFormEncoderDecoderConfig, + override val serializersModule: SerializersModule = config.serializersModule, +) : UniqueCompositeEncoder { + override val id: String = ID + + override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) { + encodeElement(value.toString()) + } + + override fun encodeByteElement(descriptor: SerialDescriptor, index: Int, value: Byte) { + encodeElement(value.toString()) + } + + override fun encodeCharElement(descriptor: SerialDescriptor, index: Int, value: Char) { + encodeElement(value.toString()) + } + + override fun encodeShortElement(descriptor: SerialDescriptor, index: Int, value: Short) { + encodeElement(value.toString()) + } + + override fun encodeIntElement(descriptor: SerialDescriptor, index: Int, value: Int) { + encodeElement(value.toString()) + } + + override fun encodeLongElement(descriptor: SerialDescriptor, index: Int, value: Long) { + encodeElement(value.toString()) + } + + override fun encodeFloatElement(descriptor: SerialDescriptor, index: Int, value: Float) { + encodeElement(value.toString()) + } + + override fun encodeDoubleElement(descriptor: SerialDescriptor, index: Int, value: Double) { + encodeElement(value.toString()) + } + + override fun encodeStringElement(descriptor: SerialDescriptor, index: Int, value: String) { + encodeElement(value) + } + + override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int): Encoder { + notSupport() + } + + @ExperimentalSerializationApi + override fun encodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T?, + ) { + if (value != null) { + encodeSerializableElement(descriptor, index, serializer, value) + } + } + + override fun encodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T, + ) { + when (value) { + is ByteArray -> builder.add(name, multipartPart { body = value }) + is Boolean -> encodeBooleanElement(descriptor, index, value) + is Byte -> encodeByteElement(descriptor, index, value) + is Char -> encodeCharElement(descriptor, index, value) + is Short -> encodeShortElement(descriptor, index, value) + is Int -> encodeIntElement(descriptor, index, value) + is Long -> encodeLongElement(descriptor, index, value) + is Float -> encodeFloatElement(descriptor, index, value) + is Double -> encodeDoubleElement(descriptor, index, value) + is String -> encodeStringElement(descriptor, index, value) + else -> notSupport() + } + } + + override fun endStructure(descriptor: SerialDescriptor) {} + + protected open fun encodeElement(value: String) { + builder.add( + name, + multipartPart { + body = config.stringEncoder(value) + }, + ) + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormRootEncoder.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormRootEncoder.kt new file mode 100644 index 0000000..6f9bcaa --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/encoder/MultipartFormRootEncoder.kt @@ -0,0 +1,80 @@ +package io.github.edmondantes.multipart.serialization.encoder + +import io.github.edmondantes.multipart.serialization.config.MultipartFormEncoderDecoderConfig +import io.github.edmondantes.multipart.serialization.encoder.MultipartFormEncoder.Companion.ID +import io.github.edmondantes.multipart.serialization.encoder.MultipartFormEncoder.Companion.notSupport +import io.github.edmondantes.multipart.serialization.util.MultipartFormDataBuilderWithActions +import io.github.edmondantes.serialization.encoding.UniqueEncoder +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule + +public open class MultipartFormRootEncoder( + protected open val builder: MultipartFormDataBuilderWithActions, + protected open val config: MultipartFormEncoderDecoderConfig, + override val serializersModule: SerializersModule = config.serializersModule, +) : UniqueEncoder { + override val id: String = ID + + @OptIn(ExperimentalSerializationApi::class) + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder { + if (descriptor.kind is StructureKind.CLASS || descriptor.kind is StructureKind.OBJECT) { + return MultipartFormCompositeEncoder(builder, config) + } else { + throw SerializationException("Multipart form-data encoder can not encode root structures with kind '${descriptor.kind}'") + } + } + + override fun encodeBoolean(value: Boolean) { + notSupport() + } + + override fun encodeByte(value: Byte) { + notSupport() + } + + override fun encodeChar(value: Char) { + notSupport() + } + + override fun encodeShort(value: Short) { + notSupport() + } + + override fun encodeInt(value: Int) { + notSupport() + } + + override fun encodeLong(value: Long) { + notSupport() + } + + override fun encodeFloat(value: Float) { + notSupport() + } + + override fun encodeDouble(value: Double) { + notSupport() + } + + override fun encodeString(value: String) { + notSupport() + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + notSupport() + } + + override fun encodeInline(descriptor: SerialDescriptor): Encoder { + notSupport() + } + + @ExperimentalSerializationApi + override fun encodeNull() { + notSupport() + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/util/MultipartFormDataBuilderWithActions.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/util/MultipartFormDataBuilderWithActions.kt new file mode 100644 index 0000000..9131575 --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/util/MultipartFormDataBuilderWithActions.kt @@ -0,0 +1,21 @@ +package io.github.edmondantes.multipart.serialization.util + +import io.github.edmondantes.multipart.MultipartFormData +import io.github.edmondantes.multipart.builder.MultipartFormDataBuilder + +public class MultipartFormDataBuilderWithActions : MultipartFormDataBuilder() { + + private val onBuildActions: MutableList<(MultipartFormDataBuilder) -> Unit> = mutableListOf() + + public fun addOnBuild(action: (MultipartFormDataBuilder) -> Unit): MultipartFormDataBuilderWithActions = apply { + onBuildActions.add(action) + } + + override fun build(): MultipartFormData { + onBuildActions.forEach { action -> + action(this) + } + + return super.build() + } +} diff --git a/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/util/RandomUtil.kt b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/util/RandomUtil.kt new file mode 100644 index 0000000..275e63c --- /dev/null +++ b/src/commonMain/kotlin/io/github/edmondantes/multipart/serialization/util/RandomUtil.kt @@ -0,0 +1,21 @@ +package io.github.edmondantes.multipart.serialization.util + +import kotlin.random.Random + +public fun Random.nextChar(from: Char, until: Char): Char = + nextInt(from.code, until.code).toChar() + +public fun Random.nextAlphanumericString(size: Int): String { + val array = CharArray(size) + for (i in array.indices) { + array[i] = + when (Random.nextInt(0, 3)) { + 0 -> nextChar('a', 'z') + 1 -> nextChar('A', 'Z') + 2 -> nextChar('0', '9') + else -> error("Can not generate character") + } + } + + return array.concatToString() +} diff --git a/src/commonTest/kotlin/io/github/edmondantes/serialization/multipart/encoder/MultipartFormDataEncoderTest.kt b/src/commonTest/kotlin/io/github/edmondantes/serialization/multipart/encoder/MultipartFormDataEncoderTest.kt new file mode 100644 index 0000000..f12240d --- /dev/null +++ b/src/commonTest/kotlin/io/github/edmondantes/serialization/multipart/encoder/MultipartFormDataEncoderTest.kt @@ -0,0 +1,503 @@ +package io.github.edmondantes.serialization.multipart.encoder + +import io.github.edmondantes.multipart.Multipart +import io.github.edmondantes.multipart.MultipartPart +import io.github.edmondantes.multipart.builder.MultipartFormDataBuilder +import io.github.edmondantes.multipart.builder.MultipartPartBuilder +import io.github.edmondantes.multipart.builder.multipartFormData +import io.github.edmondantes.multipart.builder.multipartPart +import io.github.edmondantes.multipart.serialization.MultipartDynamicHeader +import io.github.edmondantes.multipart.serialization.MultipartForm +import io.github.edmondantes.multipart.serialization.MultipartStaticHeader +import io.github.edmondantes.multipart.serialization.util.nextAlphanumericString +import io.github.edmondantes.multipart.serialization.util.nextChar +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlin.random.Random +import kotlin.reflect.KProperty0 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class MultipartFormDataEncoderTest { + + @Test + fun encodeEmptyTest() { + encode(::EmptyTestClass) { Multipart.Empty } + } + + @Test + fun decodeEmptyTest() { + decode(::EmptyTestClass) { Multipart.Empty } + } + + @Test + fun encodeNullTest() { + encode(::NullTestClass, ::NULL_TEST_BYTES) + } + + @Test + fun decodeNullTest() { + decode(::NullTestClass, ::NULL_TEST_BYTES) + } + + @Test + fun encodeFlatTest() { + encode(::FlatTestClass, ::FLAT_TEST_BYTES) + } + + @Test + fun decodeFlatTest() { + decode(::FlatTestClass, ::FLAT_TEST_BYTES) + } + + /** + * Because default string encoder is [String.encodeToByteArray], and the method can change some characters + * + * **See also** [Kotlin Docs](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.text/encode-to-byte-array.html) + */ + @Test + fun encodeWrongChar() { + val obj = FlatTestClass(char = Char(56152)) + encode({ obj }, ::FLAT_TEST_BYTES) + } + + /** + * Because default string encoder is [String.encodeToByteArray], and the method can change some characters + * + * **See also** [Kotlin Docs](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.text/encode-to-byte-array.html) + */ + @Test + fun decodeWrongChar() { + assertFailsWith { + val obj = FlatTestClass(char = Char(56152)) + decode({ obj }, ::FLAT_TEST_BYTES) + } + } + + @Test + fun encodeByteArrayTest() { + encode(::ByteArrayTestClass, ::BYTE_ARRAY_BYTES) + } + + @Test + fun decodeByteArrayTest() { + decode(::ByteArrayTestClass, ::BYTE_ARRAY_BYTES) + } + + @Test + fun encodeIntArrayTest() { + encode(::IntArrayTestClass, ::INT_ARRAY_BYTES) + } + + @Test + fun decodeIntArrayTest() { + decode(::IntArrayTestClass, ::INT_ARRAY_BYTES) + } + + @Test + fun encodeListTest() { + encode(::ListTestClass, ::LIST_BYTES) + } + + @Test + fun decodeListTest() { + decode(::ListTestClass, ::LIST_BYTES) + } + + @Test + fun encodeListByteArrayTest() { + encode(::ListByteArrayTestClass, ::LIST_BYTE_ARRAY_BYTES) + } + + @Test + fun decodeListByteArrayTest() { + decode(::ListByteArrayTestClass, ::LIST_BYTE_ARRAY_BYTES) + } + + @Test + fun encodeListIntArrayTest() { + assertFailsWith("Can not encode value. Multipart form-data encoder doesn't support to encode this values") { + encode(::ListIntArrayTestClass, ::LIST_INT_ARRAY_BYTES) + } + } + + @Test + fun decodeListIntArrayTest() { + assertFailsWith("Can not decode value. Multipart form-data decoder doesn't support to decode this values") { + decode(::ListIntArrayTestClass, ::LIST_INT_ARRAY_BYTES) + } + } + + @Test + fun encodeListListTest() { + assertFailsWith("Can not encode value. Multipart form-data encoder doesn't support to encode this values") { + encode(::ListListTestClass, ::LIST_LIST_BYTES) + } + } + + @Test + fun decodeListListTest() { + assertFailsWith("Can not encode value. Multipart form-data encoder doesn't support to encode this values") { + decode(::ListListTestClass, ::LIST_LIST_BYTES) + } + } + + @Test + fun encodeMapTest() { + assertFailsWith("Can not encode value. Multipart form-data encoder doesn't support to encode this values") { + encode(::MapTestClass) { Multipart.Empty } + } + } + + @Test + fun encodeStaticHeaderTest() { + encode(::StaticHeaderTestClass, ::STATIC_HEADER_BYTES) + } + + @Test + fun decodeStaticHeaderTest() { + decode(::StaticHeaderTestClass, ::STATIC_HEADER_BYTES) + } + + @Test + fun encodeStaticHeaderAttributeTest() { + encode(::StaticHeaderAttributeTestClass, ::STATIC_HEADER_ATTRIBUTE_BYTES) + } + + @Test + fun decodeStaticHeaderAttributeTest() { + decode(::StaticHeaderAttributeTestClass, ::STATIC_HEADER_ATTRIBUTE_BYTES) + } + + @Test + fun encodeDynamicHeaderTest() { + encode(::DynamicHeaderTestClass, ::DYNAMIC_HEADER_BYTES) + } + + @Test + fun decodeDynamicHeaderTest() { + decode(::DynamicHeaderTestClass, ::DYNAMIC_HEADER_BYTES) + } + + @Test + fun encodeDynamicHeaderAttributeTest() { + encode(::DynamicHeaderAttributeTestClass, ::DYNAMIC_HEADER_ATTRIBUTE_BYTES) + } + + @Test + fun decodeDynamicHeaderAttributeTest() { + decode(::DynamicHeaderAttributeTestClass, ::DYNAMIC_HEADER_ATTRIBUTE_BYTES) + } + + private inline fun encode(factory: () -> T, multipartFactory: (T) -> Multipart) { + val objForEncode = factory() + val expected = multipartFactory(objForEncode).let { encoder.encode(boundary, it) } + val actual = format.encodeToByteArray(objForEncode) + + assertContentEquals(expected, actual) + } + + private inline fun decode(factory: () -> T, multipartFactory: (T) -> Multipart) { + val expected = factory() + val objForDecode = multipartFactory(expected).let { encoder.encode(boundary, it) } + val actual = format.decodeFromByteArray(objForDecode) + + assertEquals(expected, actual) + } + + private companion object { + val format = MultipartForm.Default + val encoder = format.config.multipartEncoder + val boundary = format.config.boundary + + fun NULL_TEST_BYTES(obj: NullTestClass): Multipart = multipartFormData { + addNamed(obj::simple) + } + + fun FLAT_TEST_BYTES(obj: FlatTestClass): Multipart = multipartFormData { + addNamed(obj::byte) + addNamed(obj::short) + addNamed(obj::char) + addNamed(obj::int) + addNamed(obj::long) + addNamed(obj::float) + addNamed(obj::double) + addNamed(obj::string) + } + + fun BYTE_ARRAY_BYTES(obj: ByteArrayTestClass): Multipart = multipartFormData { + addNamed(obj::simple) + addNamed(obj::array) + } + + fun INT_ARRAY_BYTES(obj: IntArrayTestClass): Multipart = multipartFormData { + addNamed(obj::simple) + obj.array.forEach { element -> + add( + "array", + multipartPart { + body = element.toString().encodeToByteArray() + }, + ) + } + } + + fun LIST_BYTES(obj: ListTestClass): Multipart = multipartFormData { + obj.list.forEach { element -> + add( + "list", + multipartPart { + body = element.encodeToByteArray() + }, + ) + } + } + + fun LIST_BYTE_ARRAY_BYTES(obj: ListByteArrayTestClass): Multipart = multipartFormData { + obj.list.forEach { element -> + add( + "list", + multipartPart { + body = element + }, + ) + } + } + + fun LIST_INT_ARRAY_BYTES(obj: ListIntArrayTestClass): Multipart = multipartFormData { + obj.list.flatMap { it.toList() }.forEach { element -> + add( + "list", + multipartPart { + body = element.toString().encodeToByteArray() + }, + ) + } + } + + fun LIST_LIST_BYTES(obj: ListListTestClass): Multipart = multipartFormData { + obj.list.flatten().forEach { element -> + add("list", multipartPart { body = element.encodeToByteArray() }) + } + } + + fun STATIC_HEADER_BYTES(obj: StaticHeaderTestClass): Multipart = multipartFormData { + addNamed(obj::field) { + header("Header") { + value = "Element1" + } + } + } + + fun STATIC_HEADER_ATTRIBUTE_BYTES(obj: StaticHeaderAttributeTestClass): Multipart = multipartFormData { + addNamed(obj::field) { + header("Header") { + value = "Element1" + attributes["attr"] = "Attribute1" + } + } + } + + fun DYNAMIC_HEADER_BYTES(obj: DynamicHeaderTestClass): Multipart = multipartFormData { + addNamed(obj::field) { + header("Header") { + value = obj.header + } + } + } + + fun DYNAMIC_HEADER_ATTRIBUTE_BYTES(obj: DynamicHeaderAttributeTestClass): Multipart = multipartFormData { + addNamed(obj::field) { + header("Header") { + value = obj.header + attributes["attr"] = obj.attribute + } + } + } + + @Serializable + class EmptyTestClass { + override fun equals(other: Any?): Boolean = + if (this === other || other is EmptyTestClass) { + true + } else { + super.equals(other) + } + + override fun hashCode(): Int { + return this::class.hashCode() + } + } + + @Serializable + data class NullTestClass( + val simple: String = Random.nextAlphanumericString(10), + val nullable: String? = null, + ) + + @Serializable + data class FlatTestClass( + val byte: Byte = Random.nextBytes(1)[0], + val short: Short = Random.nextInt(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort(), + val char: Char = Random.nextChar('a', 'z'), + val int: Int = Random.nextInt(), + val long: Long = Random.nextLong(), + val float: Float = Random.nextFloat(), + val double: Double = Random.nextDouble(), + val string: String = Random.nextAlphanumericString(100), + ) + + @Serializable + data class ByteArrayTestClass( + val simple: String = Random.nextAlphanumericString(10), + val array: ByteArray = Random.nextAlphanumericString(10).encodeToByteArray(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ByteArrayTestClass) return false + + if (simple != other.simple) return false + if (!array.contentEquals(other.array)) return false + + return true + } + + override fun hashCode(): Int { + var result = simple.hashCode() + result = 31 * result + array.contentHashCode() + return result + } + } + + @Serializable + data class IntArrayTestClass( + val simple: String = Random.nextAlphanumericString(10), + val array: IntArray = generateSequence { Random.nextInt() }.take(10).toList().toIntArray(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IntArrayTestClass) return false + + if (simple != other.simple) return false + if (!array.contentEquals(other.array)) return false + + return true + } + + override fun hashCode(): Int { + var result = simple.hashCode() + result = 31 * result + array.contentHashCode() + return result + } + } + + @Serializable + data class ListTestClass( + val list: List = generateSequence { Random.nextAlphanumericString(10) }.take(10).toList(), + ) + + @Serializable + data class ListByteArrayTestClass( + val list: List = generateSequence { Random.nextBytes(70) }.take(10).toList(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ListByteArrayTestClass) return false + + if (list.size != other.list.size) return false + + list.forEachIndexed { index, element -> + if (!element.contentEquals(other.list[index])) { + return false + } + } + + return true + } + + override fun hashCode(): Int { + return list.hashCode() + } + } + + @Serializable + data class ListIntArrayTestClass( + val list: List = generateSequence { + generateSequence { Random.nextInt() }.take(10).toList().toIntArray() + }.take(10).toList(), + ) + + @Serializable + data class ListListTestClass( + val list: List> = generateSequence { + generateSequence { Random.nextAlphanumericString(10) }.take(10).toList() + }.take(10).toList(), + ) + + @Serializable + data class MapTestClass( + val map: Map = generateSequence { Random.nextAlphanumericString(10) } + .take(10) + .map { it to it } + .toMap(), + ) + + @Serializable + data class StaticHeaderTestClass( + @MultipartStaticHeader("Header", "Element1") + val field: String = Random.nextAlphanumericString(10), + ) + + @Serializable + data class StaticHeaderAttributeTestClass( + @MultipartStaticHeader("Header", "Element1") + @MultipartStaticHeader("Header", "Attribute1", "attr") + val field: String = Random.nextAlphanumericString(10), + ) + + @Serializable + data class DynamicHeaderTestClass( + val field: String = Random.nextAlphanumericString(10), + @MultipartDynamicHeader("Header", "field") + val header: String = Random.nextAlphanumericString(10), + ) + + @Serializable + data class DynamicHeaderAttributeTestClass( + val field: String = Random.nextAlphanumericString(10), + @MultipartDynamicHeader("Header", "field") + val header: String = Random.nextAlphanumericString(10), + @MultipartDynamicHeader("Header", "field", "attr") + val attribute: String = Random.nextAlphanumericString(10), + ) + + private inline fun Any.part(block: MultipartPartBuilder.() -> Unit = {}): MultipartPart = + multipartPart { + body = if (this@part is ByteArray) { + this@part + } else { + this@part.toString().encodeToByteArray() + } + apply(block) + } + + private inline fun MultipartFormDataBuilder.addNamed( + name: String, + any: Any, + block: MultipartPartBuilder.() -> Unit = {}, + ) { + add(name, any.part(block)) + } + + private inline fun MultipartFormDataBuilder.addNamed( + property: KProperty0<*>, + block: MultipartPartBuilder.() -> Unit = {}, + ) { + property.get()?.also { addNamed(property.name, it, block) } + } + } +}