Compare commits

...

153 commits

Author SHA1 Message Date
b882d68d4c
Removed git-cliff configuration and script 2024-10-20 18:24:53 -07:00
9e60b31f9e
Version 1.6.0 2024-10-19 09:01:36 -07:00
c69d7b7076
Added git-cliff configuration and script 2024-10-18 16:23:04 -07:00
742c221a20
Revert "Set Kotlin's language version to Java 8"
This reverts commit 0b6e55b338.
2024-10-16 12:13:00 -07:00
0b6e55b338
Set Kotlin's language version to Java 8 2024-10-16 11:17:34 -07:00
09b44d53e8
Compile public version with Java 21 2024-10-16 11:15:39 -07:00
df7482c3a0
Version 1.6.0-SNAPSHOT 2024-10-15 13:00:33 -07:00
3e36e389fb
Bumped Gradle to version 8.10.2 2024-10-15 12:43:53 -07:00
d66fdb12e6
Updated dependencies
Bumped Detekt to version 1.23.7
Bumped Kover to version 0.8.3
Bumped Kotlin to version 1.9.24
2024-10-15 12:43:01 -07:00
Ronny Bräunlich
6f33f9c2a9
feat: Java 8 compatibility (#19)
Closes #18

Co-authored-by: Ronny Bräunlich <ronny.braeunlich@cbc.de>
2024-10-15 12:03:54 -07:00
dd3d500496
Version 1.5.0 2024-03-27 21:44:29 -07:00
Adam
c801a9703f
Tidy publishing config (#17)
* remove custom POM location (Gradle gets confused)

* - update GitHub actions
- use new `gradle/actions/setup-gradle`
- block cancel-in-progress for publish.yml
2024-03-27 10:54:49 -07:00
1ef2045b32
Reworked GitHub workflows 2024-03-26 22:14:18 -07:00
Adam
d03c61cb92
bump version to 1.5.0-SNAPSHOT (#16) 2024-03-26 21:38:50 -07:00
a689c56e1e
Bumped Gradle to version 8.7 2024-03-26 14:14:08 -07:00
b59d01ec6d
Bumped Dokka to version 1.9.20 2024-03-26 14:13:32 -07:00
Adam
8890fef665
bump Kotlin to 1.9.23, enable additional Kotlin Multiplatform targets (#15)
* bump Kotlin to 1.9.23, enable additional Kotlin Multiplatform targets

* replace soon-to-be-deprecated kotlinOptions with newer compilerOptions
2024-03-26 13:52:11 -07:00
Adam
171570159e
add Java tests for verifying @JvmStatic (#14) 2024-02-25 04:17:08 -08:00
61137e0878 Updated copyright 2024-02-24 22:21:30 -08:00
f3fc266c6f Bumped dependencies 2024-02-24 22:21:17 -08:00
7817198be1 Removed JDK 20 deprecation reference 2023-11-17 08:56:40 -08:00
b9791dc507 Added java.net.URLEncoder deprecation reference 2023-11-01 00:52:22 -07:00
ee35984130 Added pom.xml generation 2023-10-18 21:04:08 -07:00
05d4a52240 Updated dependencies 2023-10-18 20:16:44 -07:00
8ebc178474 Revert "Added codecov"
This reverts commit 404ca93137.
2023-09-29 00:32:01 -07:00
404ca93137 Added codecov 2023-09-29 00:19:35 -07:00
2523b31073 Remove iOS X64 simulator 2023-09-25 01:50:45 -07:00
6acf3417e5 Added iOS simulator target 2023-09-24 21:53:27 -07:00
f711cf719f Try using iOS target shortcut 2023-09-24 13:08:56 -07:00
ebdc9ceef8 Added iOS publish workflow 2023-09-24 12:41:46 -07:00
9f3803bdcd Added iOS publications 2023-09-24 12:12:01 -07:00
5b46ff448c Updated Kotlin badge 2023-09-22 04:41:04 -07:00
d790a02365 Fixed examples 2023-09-21 22:04:34 -07:00
7ea75e30e7 Fixed Kotlin badge 2023-09-21 19:28:03 -07:00
e54ec3d98d Updated maven-central badge and links 2023-09-21 19:05:55 -07:00
0f369ca080 Removed quality gate 2023-09-21 14:54:17 -07:00
efcfe5e56f Removed deploy tasks 2023-09-21 14:06:08 -07:00
0fa364b47e Hard-coded description 2023-09-21 14:01:48 -07:00
392af12f50 Now using in memory PGP key
Fixed signin syntax

Fixed dangling char

Fixed typo

Added GPG key password
2023-09-21 13:07:10 -07:00
0235444814 Version 1.4.0
Added setting GPG_TTY

Made GPG_TTY global

Added GPG_TTY setup for each platforms
2023-09-21 10:59:21 -07:00
938bd54f2b Fixed snapshot badge 2023-09-13 13:35:34 -07:00
f3f70f1f93 Changed language version to 1.6 as 1.5 is deprecrated 2023-09-13 13:13:23 -07:00
b6b7dabb50 Added laguage version 2023-09-13 13:04:29 -07:00
080b7a971f Delete ~/.gnupg on Windows 2023-09-13 13:04:14 -07:00
61a93bd523 Updated dependencies 2023-09-13 13:03:04 -07:00
53f8fbab21 Change package id/group 2023-09-06 16:24:50 -07:00
1ad7dc46dd Add rm action for Windows 2023-09-06 14:54:21 -07:00
825bcd7355 Fix Gradle dependencies example
Fix invalid secret names

Remove concurrency

Remove invalid rm flag on Windows

Replace rm with rd on Windows
2023-09-06 14:54:13 -07:00
e7ab107f9c Add multiplatform to publishing name 2023-09-06 12:42:55 -07:00
a0e0cff0cd Add publish workflow 2023-09-06 12:13:43 -07:00
Adam
8fcd629bce
Convert urlencoder lib to Kotlin Multiplatform (#10)
* convert UrlEncoderUtil to be multiplatform compatible

* convert lib tests to KMP

* convert UrlEncoderTest to commonTest (but since there's only a JVM target there's no changes), and also convert mutable test data with read-only types.

* Update copyright

---------

Co-authored-by: Erik C. Thauvin <erik@thauvin.net>
2023-09-05 13:55:29 -07:00
ae060f5bd2 Configure dokka for JVM-only 2023-09-04 14:45:09 -07:00
dd2dc9e380 Remove lock files 2023-09-04 14:44:51 -07:00
32e21491c7 Update dependencies 2023-09-03 14:41:05 -07:00
5a6edfbfa0 Added Adam 2023-08-07 10:06:15 -07:00
e5cb0bd903 Replaced jvmToolChain with Kotlin/Java compile options 2023-08-07 09:17:03 -07:00
57eb20a160
Merge pull request #9 from ethauvin/update/gh-workflow
update Gradle Build workflow
2023-08-07 08:56:34 -07:00
Adam
cdb0176560 update Gradle Build workflow 2023-08-07 12:00:23 +02:00
38dee4d236 Updated git index with executable permissions for gradlew 2023-08-06 10:14:32 -07:00
fb19749795
Merge pull request #8 from aSemy/update/readme
Update README with Kotlin Multiplatform info, add Contributing guide with lockfile instructions
2023-08-06 10:04:17 -07:00
2eb1c45f95
Merge pull request #7 from aSemy/feat/additional-kotlin-targets
enable Kotlin Native and JS targets
2023-08-06 10:03:55 -07:00
Adam
3f049993b9 add Contributing guide with lockfile info 2023-08-06 11:43:21 +02:00
Adam
dcb4dfb735 Update README with Kotlin MP info 2023-08-06 11:43:09 +02:00
Adam
5f8533f9aa update Gradle wrapper 2023-08-06 11:42:55 +02:00
Adam
2d01ef821a enable Kotlin/Native and Kotlin/JS targets. Update lock files. Disable Kotlin/Native warnings. 2023-08-06 11:18:48 +02:00
Adam
757e651642 enable Kotlin/Native and Kotlin/JS targets. Update lock files. Disable Kotlin/Native warnings. 2023-08-06 11:15:43 +02:00
Adam
54ab7ff75f update gradle wrapper 2023-08-06 11:09:07 +02:00
9988c003b4 Added JDK 20 workflow 2023-08-04 10:20:02 -07:00
d55080773b Updated dependencies for JDK 20 support 2023-08-04 10:05:52 -07:00
641ffad173
Merge pull request #6 from ethauvin/feat/setup_kotlin_mp_jvm
restructure project to Kotlin Multiplatform
2023-08-04 08:51:07 -07:00
Adam
6d9023c8a6 update lockfiles 2023-07-13 21:51:30 +02:00
Adam
1d6fa1c0ad Merge remote-tracking branch 'origin/master' into feat/setup_kotlin_mp_jvm
# Conflicts:
#	app/pom.xml
#	build.gradle.kts
#	buildSrc/build.gradle.kts
#	lib/pom.xml
2023-07-13 21:44:38 +02:00
c177d49934 Fixed Kover missing dependency 2023-07-13 12:32:01 -07:00
de16f451fa Removed SonarCloud 2023-07-13 12:15:40 -07:00
bcf59ee0e7 Upgraded to Kotlin 1.9.0
Removed Sonar plugin
2023-07-13 12:12:53 -07:00
Adam
05ee976d13 add new task to update gradle lockfiles, and update lockfiles 2023-07-13 14:19:02 +02:00
Adam
b6fbea813b re-add gradle.properties 2023-07-13 14:17:58 +02:00
Adam
1b0a5aa208 remove Sonarqube, and tidy up build config a little 2023-07-13 14:17:36 +02:00
Adam
60feafa3f9 only lock compile & runtime classpaths in buildSrc 2023-06-13 19:37:33 +02:00
Adam
a4e919a75f commit Gradle dependency lock files 2023-06-13 19:37:33 +02:00
Adam
a493d4e00b try fixing Sonar + KMP... 2023-06-13 19:37:33 +02:00
Adam
dce203845e restructure project to Kotlin Multiplatform
- custom POM location removed (there are now two POMs, one for the 'Kotlin Multiplatform' publication, and another for Kotlin/JVM, and more are on the way, which would lead to a cluttered build dir)
- renamed the directories (the directory name is how Kotlin Multiplatform chooses the published artifact ID, and there's no an easier way to change it.)
- updated README examples, and link to `-jvm` variant guide
2023-06-13 19:37:33 +02:00
4df6d3f599 Upgraded grade-versions plugin to 0.47.0 2023-06-09 08:58:09 -07:00
7f8d044d04 Sonar report paths now using project.rootDir 2023-06-07 17:00:23 -07:00
221156e1fd Added more app tests 2023-06-07 11:50:45 -07:00
6f1e6fd203 Added app kover report to sonar 2023-06-07 09:06:21 -07:00
c946b9d6d9 Switched app tests to kotlin.test 2023-06-06 23:10:28 -07:00
3a19b5e895 Switched to kotlin.test 2023-06-06 22:42:16 -07:00
00eef331d8
Merge pull request #5 from aSemy/fix/sonar_test_coverage
Fix Sonar report
2023-06-05 14:02:51 -07:00
Adam
17ea49aba6 configure Sonar only on the root project, aggregate the sonar reports, and always generate Kover XML coverage reports after tests 2023-06-05 22:20:06 +02:00
e45fc71ab9 Updated dependencies 2023-06-05 12:10:51 -07:00
090ccbff18
Merge pull request #4 from aSemy/refactor/split_lib_app
split up app/lib
2023-06-05 11:55:32 -07:00
Adam
9b34b5684c tidy up sonar config 2023-06-04 21:10:11 +02:00
Adam
9be2d3897e use sonar {} extension instead of sonarqube {} 2023-06-04 21:01:41 +02:00
Adam
231bca79fb Merge branch 'master' into refactor/split_lib_app 2023-06-04 20:40:23 +02:00
01c46bbdc9 Fixed sonar code smells 2023-06-04 11:39:41 -07:00
Adam
6dbd5f7e15 Merge branch 'master' into refactor/split_lib_app 2023-06-04 20:36:06 +02:00
cb460faf2b Potential fix for sonarqube task 2023-06-04 11:17:17 -07:00
Adam
1986a8e56d try only adding sonar to :lib 2023-06-04 17:47:18 +02:00
Adam
f1622d0c0a try only setting sonar properties on the root project 2023-06-04 17:24:12 +02:00
Adam
c1efd8a955 don't skip :lib? 2023-06-04 17:18:29 +02:00
Adam
97a23a195f try skipping projects... 2023-06-04 16:57:29 +02:00
Adam
76750cfd71 try adding sonar to the root project again... 2023-06-04 16:38:17 +02:00
Adam
3b6fffdc52 remove Kover & Sonar from base project 2023-06-02 09:43:57 +02:00
Adam
09a58f4142 remove SonarQube from Kotlin/JVM convention 2023-06-02 09:40:15 +02:00
Adam
13c73903c3 only enable SonarQube in :app 2023-06-02 09:33:55 +02:00
Adam
86ae661788 enable verbose sonar to try and help debugging 2023-06-02 09:22:48 +02:00
Adam
bccfd0003f enable sonarqube on Kotlin projects 2023-06-02 09:15:31 +02:00
Adam
e53dc8c546 only run sonar task on the root project 2023-06-02 09:13:17 +02:00
Adam
7ddfa061c6 tidy up/add some docs 2023-06-02 09:13:09 +02:00
Adam
78403becf2 refactor sonar build config 2023-06-02 08:49:55 +02:00
228b3fb4e3
Merge branch 'master' into refactor/split_lib_app 2023-05-31 15:33:15 -07:00
8bf5bb8349 Updated more dependencies 2023-05-31 15:20:31 -07:00
f6b5a77eb5 Updated dependencies 2023-05-31 14:54:55 -07:00
Adam
12c1c4f12a tidy fatJar task 2023-05-31 23:46:57 +02:00
Adam
62cc110651 fix base archive name by adding -lib suffix 2023-05-31 23:30:51 +02:00
Adam
fc3a5648ed tidy build config, update pom.xmls 2023-05-31 23:27:56 +02:00
Adam
1b40145970 fix main class val 2023-05-31 23:26:04 +02:00
bbfd09c3ba Updated workflow to latest actions 2023-05-31 14:25:35 -07:00
Adam
ae8ffb91bb tidy up build config files 2023-05-31 23:24:41 +02:00
Adam
b2316b0029 split up app/lib 2023-05-31 23:21:34 +02:00
d90fe5a7c8
Merge pull request #3 from aSemy/refactor/build_convention_plugins
set up convention plugins
2023-05-31 14:16:51 -07:00
Adam
671e4c6810 move test config to code-quality convention plugin 2023-05-31 23:10:23 +02:00
Adam
acfaaec754 only enable signing if not snapshot OR running 'publish' task 2023-05-31 23:07:13 +02:00
Adam
6670346890 tweaks and fixes
- introduce kotlin-jvm convention
- tidy up code-quality build config
2023-05-31 22:48:49 +02:00
Adam
b8a394c9ad Merge branch 'tidy_gitignore' into refactor/build_convention_plugins 2023-05-31 21:47:12 +02:00
Adam
b2d20e93ed add deploy/ dir 2023-05-31 21:33:33 +02:00
Adam
61b51824fd define all plugins in buildSrc/build.gradle.kts, and bump Kover version 2023-05-31 21:27:52 +02:00
Adam
4d294bfee8 tidy up build config 2023-05-31 21:18:10 +02:00
Adam
93e113fa69 set up convention plugins
- tidy up build scripts to use convention plugins
- use centralised repo definition
- tidy up some of the build config
2023-05-31 21:10:57 +02:00
Adam
c9bbe70fbb put git overrides last 2023-05-31 20:58:52 +02:00
Adam
425e79eb38 rm .idea files 2023-05-31 20:58:41 +02:00
Adam
a3d973100a tidy up the .gitignore 2023-05-31 20:51:03 +02:00
34b69a7d1f Updated intro to match Java version 2023-01-23 21:44:47 -08:00
13c09989b2 Minor cleanup 2023-01-10 01:49:16 -08:00
f18f145ead Added Kotlin badge 2023-01-07 07:50:55 -08:00
67ecf7f069 Minor cleanup 2023-01-07 07:50:30 -08:00
ac278b6fef Minor cleanup 2023-01-06 19:47:10 -08:00
41912076b0 Version 1.3.0 2023-01-06 19:12:00 -08:00
53a9f6a4ac Updated baseline 2023-01-06 18:35:48 -08:00
9f6b5a0913 Minor cleanup 2023-01-06 18:32:47 -08:00
4b746ba75c Added spaceToPlus parameter to decode function 2023-01-06 15:57:22 -08:00
7c8a4358b6 More optimization to spaceToPlus 2023-01-06 10:33:49 -08:00
f8b9376f40 Added spaceToPlus parameter to encode function 2023-01-05 20:42:00 -08:00
50ffe56ba8 Minor cleanup 2023-01-05 00:39:56 -08:00
a049175ed6
Minor cleanup 2023-01-05 00:33:39 -08:00
d4a6347310 Minor cleanup 2023-01-05 00:18:00 -08:00
1da595e08a Added URLEncoder section 2023-01-04 23:55:18 -08:00
aa3b40f307 Version 1.0.1 2023-01-04 20:04:32 -08:00
a7b67c39af Made the encoding even more defensive 2023-01-04 19:29:04 -08:00
e20c096cfe Clarified Android's URI.encode statement 2023-01-04 11:40:55 -08:00
3f44a59ff5 Minor cleanup 2023-01-04 00:13:49 -08:00
b9c1449d55 Added fatJar task and standalone instructions 2023-01-04 00:04:59 -08:00
47 changed files with 3629 additions and 916 deletions

View file

@ -1,61 +1,52 @@
name: gradle-ci
on: [push, pull_request, workflow_dispatch]
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
concurrency:
group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-XX:MaxMetaspaceSize=512m"
SONAR_JDK: "11"
strategy:
matrix:
java-version: [ 11, 17, 19 ]
java-version: [8, 11, 17, 21]
os:
- macos-latest
- ubuntu-latest
- windows-latest
fail-fast: false
runs-on: ${{ matrix.os }}
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-XX:MaxMetaspaceSize=512m"
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v1
uses: actions/setup-java@v4
with:
distribution: "zulu"
java-version: ${{ matrix.java-version }}
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Validate Gradle wrapper
uses: gradle/wrapper-validation-action@v2
- name: Cache SonarCloud packages
if: matrix.java-version == env.SONAR_JDK
uses: actions/cache@v1
- name: Cache Kotlin Konan
uses: actions/cache@v4
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
path: ~/.konan/**/*
key: kotlin-konan-${{ runner.os }}
- name: Cache Gradle packages
uses: actions/cache@v2
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ matrix.java-version }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-${{ matrix.java-version }}-
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
- name: Test with Gradle
run: ./gradlew build check --stacktrace -PtestsBadgeApiKey=${{ secrets.TESTS_BADGE_API_KEY }}
- name: SonarCloud
if: success() && matrix.java-version == env.SONAR_JDK
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew sonar
- name: Cleanup Gradle Cache
run: |
rm -f ~/.gradle/caches/modules-2/modules-2.lock
rm -f ~/.gradle/caches/modules-2/gc.properties
- name: Publish
run: ./gradlew check build --stacktrace -PtestsBadgeApiKey=${{ secrets.TESTS_BADGE_API_KEY }}

44
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: publish
on:
workflow_dispatch:
env:
ORG_GRADLE_PROJECT_ossrhUsername: ${{ secrets.OSSRH_USERNAME}}
ORG_GRADLE_PROJECT_ossrhPassword: ${{ secrets.OSSRH_PASSWORD}}
ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGN_SECRET_KEY }}
ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGN_SECRET_PWD }}
concurrency:
group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
# Don't cancel midway through publishing if another workflow is triggered, it might cause partial publications
cancel-in-progress: false
jobs:
publish:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "zulu"
- name: Validate Gradle wrapper
uses: gradle/wrapper-validation-action@v2
- name: Cache Kotlin Konan
uses: actions/cache@v4
with:
path: ~/.konan/**/*
key: kotlin-konan-${{ runner.os }}
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
- name: Publish
run: ./gradlew publish --no-parallel --stacktrace

149
.gitignore vendored
View file

@ -1,85 +1,72 @@
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
!.vscode/tasks.json
*.class
*.code-workspace
*.ctxt
*.iws
*.log
*.nar
*.rar
*.sublime-*
*.tar.gz
*.zip
.DS_Store
.classpath
### Gradle ###
.gradle
.history
.kobalt
.mtj.tmp/
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
.nb-gradle
.project
.scannerwork
.settings
.vscode/*
/**/.idea/$CACHE_FILE$
/**/.idea/$PRODUCT_WORKSPACE_FILE$
/**/.idea/**/caches/build_file_checksums.ser
/**/.idea/**/contentModel.xml
/**/.idea/**/dataSources.ids
/**/.idea/**/dataSources.local.xml
/**/.idea/**/dataSources/
/**/.idea/**/dbnavigator.xml
/**/.idea/**/dictionaries
/**/.idea/**/dynamic.xml
/**/.idea/**/gradle.xml
/**/.idea/**/httpRequests
/**/.idea/**/libraries
/**/.idea/**/mongoSettings.xml
/**/.idea/**/replstate.xml
/**/.idea/**/shelf
/**/.idea/**/shelf/
/**/.idea/**/sqlDataSources.xml
/**/.idea/**/tasks.xml
/**/.idea/**/uiDesigner.xml
/**/.idea/**/usage.statistics.xml
/**/.idea/**/workspace.xml
/**/.idea/sonarlint*
/**/.idea_modules/
Thumbs.db
__pycache__
atlassian-ide-plugin.xml
bin/
build/
cmake-build-*/
com_crashlytics_export_strings.xml
crashlytics-build.properties
crashlytics.properties
dependency-reduced-pom.xml
deploy/
dist/
ehthumbs.db
fabric.properties
gen/
gradle.properties
### Kotlin/JVM ###
*.class
*.log
hs_err_pid*
kobaltBuild
kobaltw*-test
lib/kotlin*
libs/
local.properties
out/
pom.xml.asc
pom.xml.next
pom.xml.releaseBackup
pom.xml.tag
pom.xml.versionsBackup
proguard-project.txt
project.properties
release.properties
target/
test-output
venv
replay_pid*
*.hprof
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
### IntelliJ ###
.idea/**/*
### Eclipse ###
.metadata
bin/
tmp/
*.tmp
*.bak
*.swp
*~.nib
.settings/
.loadpath
.recommenders
.classpath
.apt_generated/
.apt_generated_test/
.project
### Linux ###
*~
.fuse_hidden*
.Trash-*
.nfs*
### Windows ###
[Dd]esktop.ini
$RECYCLE.BIN/
*.lnk
### macOS ###
.DS_Store
._*
# Icon must end with two \r
Icon
###########################
# place overrides last, so they're not themselves overridden
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
!.idea/copyright/**

3
.idea/.gitignore generated vendored
View file

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/compiler.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

View file

@ -1,6 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright 2001-&amp;#36;today.year Geert Bevin (gbevin[remove] at uwyn dot com)&#10;Copyright &amp;#36;today.year Erik C. Thauvin (erik@thauvin.net)&#10;&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10; http://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License." />
<option name="notice" value="Copyright 2001-&amp;#36;today.year the original author or authors.&#10; &#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10; https://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License." />
<option name="myName" value="Apache License" />
</copyright>
</component>

View file

@ -1,8 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="JavadocDeclaration" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ADDITIONAL_TAGS" value="created" />
</inspection_tool>
</profile>
</component>

View file

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://oss.sonatype.org/content/repositories/snapshots" />
</remote-repository>
</component>
</project>

6
.idea/kotlinc.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.0" />
</component>
</project>

8
.idea/misc.xml generated
View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="PDMPlugin">
<option name="skipTestSources" value="false" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="19" project-jdk-type="JavaSDK" />
</project>

6
.idea/vcs.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

36
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,36 @@
# Contributing to UrlEncoder
First and foremost, thank you for your interest in contributing! Here's a brief guide on how to contribute to the
UrlEncoder project.
## Getting Started
1. Fork the repository.
2. Clone your fork locally.
3. Create a new branch for your feature or bugfix.
## Updating Dependencies
To support deterministic builds, and to help with dependency analysis tools like Snyk, UrlEncoder uses lockfiles
to ensure consistent dependencies. Whenever a dependency is updated the lockfiles must be updated.
### Gradle Lock Files
Gradle's [dependency lockfiles](https://docs.gradle.org/current/userguide/dependency_locking.html)
can be updated by running
```bash
./gradlew resolveAndLockAllDependencies --write-locks
```
### Kotlin/JS Lockfile
The Kotlin/JS target
[also uses a lockfile](https://kotlinlang.org/docs/js-project-setup.html#version-locking-via-kotlin-js-store),
which is managed by Yarn.
To update the Kotlin/JS lockfile, run
```bash
./gradlew kotlinNpmInstall
```

138
README.md
View file

@ -1,50 +1,154 @@
[![License](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Nexus Snapshot](https://img.shields.io/nexus/s/net.thauvin.erik/urlencoder?server=https%3A%2F%2Foss.sonatype.org%2F)](https://oss.sonatype.org/content/repositories/snapshots/net/thauvin/erik/urlencoder/)
[![Kotlin](https://img.shields.io/badge/kotlin-1.6%2B-blue)](https://kotlinlang.org/)
[![Nexus Snapshot](https://img.shields.io/nexus/s/net.thauvin.erik.urlencoder/urlencoder-lib?label=snapshot&server=https%3A%2F%2Foss.sonatype.org%2F)](https://oss.sonatype.org/content/repositories/snapshots/net/thauvin/erik/urlencoder/)
[![Release](https://img.shields.io/github/release/ethauvin/urlencoder.svg)](https://github.com/ethauvin/urlencoder/releases/latest)
[![Maven Central](https://maven-badges.herokuapp.com/maven-central/net.thauvin.erik/urlencoder/badge.svg?color=blue)](https://maven-badges.herokuapp.com/maven-central/net.thauvin.erik/urlencoder)
[![Maven Central](https://img.shields.io/maven-central/v/net.thauvin.erik.urlencoder/urlencoder-lib)](https://central.sonatype.com/search?namespace=net.thauvin.erik.urlencoder)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ethauvin_urlencoder&metric=alert_status)](https://sonarcloud.io/dashboard?id=ethauvin_urlencoder)
[![GitHub CI](https://github.com/ethauvin/urlencoder/actions/workflows/gradle.yml/badge.svg)](https://github.com/ethauvin/urlencoder/actions/workflows/gradle.yml)
[![Tests](https://rife2.com/tests-badge/badge/net.thauvin.erik/urlencoder)](https://github.com/ethauvin/urlencoder/actions/workflows/gradle.yml)
# URL Encoder for Kotlin and Java
# URL Encoder for Kotlin Multiplatform
A simple library to encode/decode URL parameters.
UrlEncoder is a simple defensive library to encode/decode URL components.
This library was adapted from the [RIFE2 Web Application Framework](https://rife2.com).
A pure Java version can also be found at [https://github.com/gbevin/urlencoder](https://github.com/gbevin/urlencoder).
The rules are determined by combining the unreserved character set from
[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#page-13) with the
percent-encode set from
[application/x-www-form-urlencoded](https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set).
For decades we've been using [java.net.URLEncoder](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/URLEncoder.html) because of its improper naming. It is actually intended to encode HTML form parameters, not URLs, causing the wrong escape sequences to be used.
Both specs above support percent decoding of two hexadecimal digits to a
binary octet, however their unreserved set of characters differs and
`application/x-www-form-urlencoded` adds conversion of space to `+`,
that has the potential to be misunderstood.
Additionally, `java.net.URLEncoder` allocates memory even when no encoding is necessary, significantly impacting performance. This library has a negligible performance impact when the specified string doesn't need to be encoded.
This class encodes with rules that will be decoded correctly in either case.
Additionally, this library allocates no memory when encoding isn't needed and
does the work in a single pass without multiple loops. Both of these
optimizations have a significantly beneficial impact on performance of encoding
compared to other solutions like the standard `URLEncoder` in the JDK or
`UriUtils` in Spring.
Android's [Uri.encode](https://developer.android.com/reference/android/net/Uri#encode(java.lang.String,%20java.lang.String)) also addresses these issues, but does not currently support [unicode surrogate pairs](https://learn.microsoft.com/en-us/globalization/encoding/surrogate-pairs).
## Examples (TL;DR)
```kotlin
UrlEncoder.encode("a test &") // -> a%20test%20%26
UrlEncoder.encode("%#okékÉȢ smile!😁") // -> %25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81
UrlEncoder.encode("?test=a test", allow = "?=") // -> ?test=a%20test
UrlEncoderUtil.encode("a test &") // -> a%20test%20%26
UrlEncoderUtil.encode("%#okékÉȢ smile!😁") // -> %25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81
UrlEncoderUtil.encode("?test=a test", allow = "?=") // -> ?test=a%20test
UrlEncoderUtil.endode("foo bar", spaceToPlus = true) // -> foo+bar
UrlEncoder.decode("a%20test%20%26") // -> a test &
UrlEncoder.decode("%25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81") // -> %#okékÉȢ smile!😁
UrlEncoderUtil.decode("a%20test%20%26") // -> a test &
UrlEncoderUtil.decode("%25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81") // -> %#okékÉȢ smile!😁
UrlEncoderUtil.decode("foo+bar", plusToSpace = true) // -> foo bar
```
## Gradle, Maven, etc.
To use with [Gradle](https://gradle.org/), include the following dependency in your build file:
```gradle
```kotlin
repositories {
mavenCentral()
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } // only needed for SNAPSHOT
// only needed for SNAPSHOT
maven("https://oss.sonatype.org/content/repositories/snapshots") {
name = "SonatypeSnapshots"
mavenContent { snapshotsOnly() }
}
}
dependencies {
implementation("net.thauvin.erik:urlencoder:1.0.0")
implementation("net.thauvin.erik.urlencoder:urlencoder-lib:1.6.0")
}
```
Instructions for using with Maven, Ivy, etc. can be found on [Maven Central](https://maven-badges.herokuapp.com/maven-central/net.thauvin.erik/urlencoder).
Adding a dependency in [Maven](https://maven.apache.org/) requires specifying the JVM variant by adding a `-jvm` suffix
to the artifact URL.
```xml
<dependency>
<groupId>net.thauvin.erik.urlencoder</groupId>
<artifactId>urlencoder-lib-jvm</artifactId>
<version>1.6.0</version>
</dependency>
```
Instructions for using with Ivy, etc. can be found on
[Maven Central](https://central.sonatype.com/search?namespace=net.thauvin.erik.urlencoder).
## Standalone usage
UrlEncoder can be used on the command line also, both for encoding and decoding.
You have two options:
* run it with Gradle
* build the jar and launch it with Java
The usage is as follows:
```console
Encode and decode URL components defensively.
-e encode (default)
-d decode
```
### Running with Gradle
```console
./gradlew run --quiet --args="-e 'a test &'" # -> a%20test%20%26
./gradlew run --quiet --args="%#okékÉȢ" # -> %25%23ok%C3%A9k%C3%89%C8%A2
./gradlew run --quiet --args="-d 'a%20test%20%26'" # -> a test &
```
### Running with Java
First build the jar file:
```console
./gradlew fatJar
```
Then run it:
```console
java -jar urlencoder-app/build/libs/urlencoder-*all.jar -e "a test &" # -> a%20test%20%26
java -jar urlencoder-app/build/libs/urlencoder-*all.jar "%#okékÉȢ" # -> %25%23ok%C3%A9k%C3%89%C8%A2
java -jar urlencoder-app/build/libs/urlencoder-*all.jar -d "a%20test%20%26" # -> a test &
```
## Why not simply use `java.net.URLEncoder`?
Apart for being quite inefficient, some URL components encoded with `URLEncoder.encode` might not be able to be properly decoded.
For example, a simple search query such as:
```kotlin
val u = URLEncoder.encode("foo +bar", StandardCharsets.UTF_8)
```
would be encoded as:
```
foo+%2Bbar
```
Trying to decode it with [Spring](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/util/UriUtils.html#decode(java.lang.String,java.lang.String)), for example:
```kotlin
UriUtils.decode(u, StandardCharsets.UTF_8)
```
would return:
```
foo++bar
```
Unfortunately, decoding with [Uri.decode](https://developer.android.com/reference/android/net/Uri#decode(java.lang.String)) on Android, [decodeURI](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURI) in Javascript, etc. would yield the exact same result.
![URLEncoder](https://live.staticflickr.com/65535/52607534147_6197b42666_z.jpg)

12
build.gradle.kts Normal file
View file

@ -0,0 +1,12 @@
plugins {
buildsrc.conventions.base
id("org.jetbrains.kotlinx.kover")
}
group = "net.thauvin.erik.urlencoder"
version = "1.6.0"
dependencies {
kover(projects.urlencoderLib)
kover(projects.urlencoderApp)
}

12
buildSrc/build.gradle.kts Normal file
View file

@ -0,0 +1,12 @@
plugins {
`kotlin-dsl`
}
dependencies {
implementation("com.github.ben-manes:gradle-versions-plugin:0.51.0")
implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.7")
implementation("org.jetbrains.dokka:dokka-gradle-plugin:1.9.20")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24")
implementation("org.jetbrains.kotlinx:kover-gradle-plugin:0.8.3")
implementation("org.apache.httpcomponents:httpclient:4.5.13")
}

View file

@ -0,0 +1,16 @@
rootProject.name = "buildSrc"
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}

View file

@ -0,0 +1,18 @@
package buildsrc.conventions
/** common config for all subprojects */
plugins {
base
}
if (project != rootProject) {
project.version = rootProject.version
project.group = rootProject.group
}
tasks.withType<AbstractArchiveTask>().configureEach {
// https://docs.gradle.org/current/userguide/working_with_files.html#sec:reproducible_archives
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}

View file

@ -0,0 +1,64 @@
package buildsrc.conventions.lang
import buildsrc.utils.Rife2TestListener
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
/**
* Base configuration for all Kotlin/Multiplatform conventions.
*
* This plugin does not enable any Kotlin target. To enable a target in a subproject, prefer applying specific Kotlin
* target convention plugins.
*/
plugins {
id("buildsrc.conventions.base")
kotlin("multiplatform")
id("io.gitlab.arturbosch.detekt")
id("org.jetbrains.kotlinx.kover")
}
kotlin {
//jvmToolchain(11)
applyDefaultHierarchyTemplate()
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
languageVersion = KotlinVersion.KOTLIN_1_6
}
// configure all Kotlin/JVM Tests to use JUnit
targets.withType<KotlinJvmTarget>().configureEach {
testRuns.configureEach {
executionTask.configure {
useJUnitPlatform()
}
}
}
}
tasks {
withType<JavaCompile>().configureEach {
sourceCompatibility = JavaVersion.VERSION_1_8.toString()
targetCompatibility = JavaVersion.VERSION_1_8.toString()
}
withType<KotlinJvmCompile>().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8)
}
withType<Test>().configureEach {
val testsBadgeApiKey = providers.gradleProperty("testsBadgeApiKey")
addTestListener(Rife2TestListener(testsBadgeApiKey))
testLogging {
exceptionFormat = TestExceptionFormat.FULL
events = setOf(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
}
}
}

View file

@ -0,0 +1,46 @@
package buildsrc.conventions.lang
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
/** conventions for a Kotlin/JS subproject */
plugins {
id("buildsrc.conventions.lang.kotlin-multiplatform-base")
}
kotlin {
js(IR) {
browser()
nodejs()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
nodejs()
}
@OptIn(ExperimentalWasmDsl::class)
wasmWasi {
nodejs()
}
}
relocateKotlinJsStore()
//region FIXME: WORKAROUND https://youtrack.jetbrains.com/issue/KT-65864
rootProject.plugins.withType<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin> {
rootProject.extensions.configure<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension> {
// Use a Node.js version current enough to support Kotlin/Wasm
nodeVersion = "22.0.0-nightly2024010568c8472ed9"
logger.lifecycle("Using Node.js $nodeVersion to support Kotlin/Wasm")
nodeDownloadBaseUrl = "https://nodejs.org/download/nightly"
}
}
rootProject.tasks.withType<org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask>().configureEach {
// Prevent Yarn from complaining about newer Node.js versions.
args.add("--ignore-engines")
}
//endregion

View file

@ -0,0 +1,11 @@
package buildsrc.conventions.lang
plugins {
id("buildsrc.conventions.lang.kotlin-multiplatform-base")
}
kotlin {
jvm {
withJava()
}
}

View file

@ -0,0 +1,32 @@
package buildsrc.conventions.lang
/** conventions for a Kotlin/Native subproject */
plugins {
id("buildsrc.conventions.lang.kotlin-multiplatform-base")
}
kotlin {
linuxX64()
mingwX64()
linuxArm64()
macosX64()
macosArm64()
iosArm64()
iosX64()
iosSimulatorArm64()
watchosArm32()
watchosArm64()
watchosX64()
watchosSimulatorArm64()
watchosDeviceArm64()
tvosArm64()
tvosX64()
tvosSimulatorArm64()
}

View file

@ -0,0 +1,18 @@
package buildsrc.conventions.lang
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension
/**
* `kotlin-js` and `kotlin-multiplatform` plugins adds a directory in the root-dir for the Yarn
* lockfile. That's a bit annoying. It's a little neater if it's in the Gradle dir, next to the
* version catalog.
*/
internal fun Project.relocateKotlinJsStore() {
afterEvaluate {
rootProject.extensions.configure<YarnRootExtension> {
lockFileDirectory = project.rootDir.resolve("gradle/kotlin-js-store")
}
}
}

View file

@ -0,0 +1,109 @@
package buildsrc.conventions
plugins {
id("maven-publish")
id("signing")
id("org.jetbrains.dokka")
}
val gitHub = "ethauvin/${rootProject.name}"
val mavenUrl = "https://github.com/$gitHub"
val isSnapshotVersion = { project.version.toString().contains("SNAPSHOT") }
publishing {
publications {
withType<MavenPublication>().configureEach {
pom {
name.set("UrlEncoder for Kotlin Multiplatform")
description.set("A simple defensive library to encode/decode URL components")
url.set(mavenUrl)
licenses {
license {
name.set("The Apache License, Version 2.0")
url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
}
}
developers {
developer {
id.set("gbevin")
name.set("Geert Bevin")
email.set("gbevin@uwyn.com")
url.set("https://github.com/gbevin")
}
developer {
id.set("ethauvin")
name.set("Erik C. Thauvin")
email.set("erik@thauvin.net")
url.set("https://erik.thauvin.net/")
}
developer {
id.set("aSemy")
name.set("Adam")
url.set("https://github.com/aSemy")
}
}
scm {
connection.set("scm:git://github.com/$gitHub.git")
developerConnection.set("scm:git@github.com:$gitHub.git")
url.set(mavenUrl)
}
issueManagement {
system.set("GitHub")
url.set("$mavenUrl/issues")
}
}
}
}
repositories {
maven(
if (isSnapshotVersion()) {
uri("https://oss.sonatype.org/content/repositories/snapshots/")
} else {
uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/")
}
) {
name = "ossrh"
credentials(PasswordCredentials::class)
}
}
}
signing {
val signingKey: String? by project
val signingPassword: String? by project
useInMemoryPgpKeys(signingKey, signingPassword)
sign(publishing.publications)
setRequired({
// only enable signing for non-snapshot versions, or when publishing to a non-local repo, otherwise
// publishing to Maven Local requires signing for users without access to the signing key.
!isSnapshotVersion() || gradle.taskGraph.hasTask("publish")
})
}
tasks {
withType<Sign>().configureEach {
val signingRequiredPredicate = provider { signing.isRequired }
onlyIf { signingRequiredPredicate.get() }
}
}
// https://youtrack.jetbrains.com/issue/KT-46466
val signingTasks = tasks.withType<Sign>()
tasks.withType<AbstractPublishToMaven>().configureEach {
dependsOn(signingTasks)
}
val javadocJar by tasks.registering(Jar::class) {
description = "Generate Javadoc using Dokka"
dependsOn(tasks.dokkaJavadoc)
from(tasks.dokkaJavadoc)
archiveClassifier.set("javadoc")
}
publishing {
publications.withType<MavenPublication>().configureEach {
artifact(javadocJar)
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package buildsrc.utils
import org.apache.http.client.methods.HttpPost
import org.apache.http.impl.client.HttpClients
import org.apache.http.util.EntityUtils
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.testing.TestDescriptor
import org.gradle.api.tasks.testing.TestListener
import org.gradle.api.tasks.testing.TestResult
class Rife2TestListener(
private val testBadgeApiKey: Provider<String>
) : TestListener {
override fun beforeTest(p0: TestDescriptor?) = Unit
override fun beforeSuite(p0: TestDescriptor?) = Unit
override fun afterTest(desc: TestDescriptor, result: TestResult) = Unit
override fun afterSuite(desc: TestDescriptor, result: TestResult) {
if (desc.parent == null) {
val passed = result.successfulTestCount
val failed = result.failedTestCount
val skipped = result.skippedTestCount
val apiKey = testBadgeApiKey.orNull
if (apiKey != null) {
println(apiKey)
val url = "https://rife2.com/tests-badge/update/net.thauvin.erik/urlencoder?" +
"apiKey=$apiKey&" +
"passed=$passed&" +
"failed=$failed&" +
"skipped=$skipped"
val client = HttpClients.createDefault()
val post = HttpPost(url)
val response = client.execute(post)
val entity = response.entity
val statusCode = response.statusLine.statusCode
val responseBody = EntityUtils.toString(entity, "UTF-8")
println("RESPONSE: $statusCode")
println(responseBody)
}
}
}
}

11
gradle.properties Normal file
View file

@ -0,0 +1,11 @@
org.gradle.jvmargs=-Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.welcome=never
# enableKgpDependencyResolution provides a smoother import experience in multiplatform projects
# https://kotlinlang.org/docs/whatsnew1820.html#preview-of-gradle-composite-builds-support-in-kotlin-multiplatform
kotlin.mpp.import.enableKgpDependencyResolution=true
# hide warning "Some Kotlin/Native targets cannot be built on this mingw_x64 machine and are disabled"
kotlin.native.ignoreDisabledTargets=true

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

34
gradlew vendored
View file

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -83,10 +85,9 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# 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"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -133,10 +134,13 @@ 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.
if ! command -v java >/dev/null 2>&1
then
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
fi
# Increase the maximum file descriptors if we can.
@ -144,7 +148,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@ -152,7 +156,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -197,11 +201,15 @@ if "$cygwin" || "$msys" ; then
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.
# 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"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \

22
gradlew.bat vendored
View file

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 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.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -57,11 +59,11 @@ 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.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail

View file

@ -1,229 +0,0 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jetbrains.dokka.gradle.DokkaTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
plugins {
id("application")
id("com.github.ben-manes.versions") version "0.44.0"
id("io.gitlab.arturbosch.detekt") version "1.22.0"
id("java-library")
id("maven-publish")
id("org.jetbrains.dokka") version "1.7.20"
id("org.jetbrains.kotlin.jvm") version "1.8.0"
id("org.jetbrains.kotlinx.kover") version "0.6.1"
id("org.sonarqube") version "3.5.0.2730"
id("signing")
}
description = "A simple library to encode/decode URL parameters"
group = "net.thauvin.erik"
version = "1.0.0"
val mavenName = "UrlEncoder"
val deployDir = "deploy"
val gitHub = "ethauvin/${rootProject.name}"
val mavenUrl = "https://github.com/$gitHub"
val publicationName = "mavenJava"
val myClassName = "$group.${rootProject.name}.$mavenName"
repositories {
mavenCentral()
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
}
dependencies {
// testImplementation("com.willowtreeapps.assertk:assertk-jvm:0.25")
testImplementation("org.junit.jupiter:junit-jupiter:5.9.0")
}
base {
archivesName.set(rootProject.name)
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
withSourcesJar()
}
application {
mainClass.set(myClassName)
}
sonarqube {
properties {
property("sonar.projectName", rootProject.name)
property("sonar.projectKey", "ethauvin_${rootProject.name}")
property("sonar.organization", "ethauvin-github")
property("sonar.host.url", "https://sonarcloud.io")
property("sonar.sourceEncoding", "UTF-8")
property("sonar.coverage.jacoco.xmlReportPaths", "${project.buildDir}/reports/kover/xml/report.xml")
}
}
val javadocJar by tasks.creating(Jar::class) {
dependsOn(tasks.dokkaJavadoc)
from(tasks.dokkaJavadoc)
archiveClassifier.set("javadoc")
}
tasks {
jar {
manifest {
attributes["Main-Class"] = myClassName
}
}
withType<KotlinCompile>().configureEach {
kotlinOptions.jvmTarget = java.targetCompatibility.toString()
}
test {
useJUnitPlatform()
addTestListener(object : TestListener {
override fun beforeTest(p0: TestDescriptor?) = Unit
override fun beforeSuite(p0: TestDescriptor?) = Unit
override fun afterTest(desc: TestDescriptor, result: TestResult) = Unit
override fun afterSuite(desc: TestDescriptor, result: TestResult) {
if (desc.parent == null) {
val passed = result.successfulTestCount
val failed = result.failedTestCount
val skipped = result.skippedTestCount
if (project.properties["testsBadgeApiKey"] != null) {
val apiKey = project.properties["testsBadgeApiKey"]
println(apiKey)
val response: HttpResponse<String> = HttpClient.newHttpClient()
.send(
HttpRequest.newBuilder()
.uri(
URI(
"https://rife2.com/tests-badge/update/net.thauvin.erik/urlencoder?" +
"apiKey=$apiKey&" +
"passed=$passed&" +
"failed=$failed&" +
"skipped=$skipped"
)
)
.POST(HttpRequest.BodyPublishers.noBody())
.build(), HttpResponse.BodyHandlers.ofString()
)
println("RESPONSE: ${response.statusCode()}")
println(response.body())
}
}
}
})
}
withType<Test> {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
events = setOf(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
}
}
withType<GenerateMavenPom> {
destination = file("$projectDir/pom.xml")
}
clean {
doLast {
project.delete(fileTree(deployDir))
}
}
withType<DokkaTask>().configureEach {
dokkaSourceSets {
named("main") {
moduleName.set("UrlEncoder API")
}
}
}
val copyToDeploy by registering(Copy::class) {
from(configurations.runtimeClasspath) {
exclude("annotations-*.jar")
}
from(jar)
into(deployDir)
}
register("deploy") {
description = "Copies all needed files to the $deployDir directory."
group = PublishingPlugin.PUBLISH_TASK_GROUP
dependsOn(clean, build, jar)
outputs.dir(deployDir)
inputs.files(copyToDeploy)
mustRunAfter(clean)
}
"sonar" {
dependsOn(koverReport)
}
}
publishing {
publications {
create<MavenPublication>(publicationName) {
from(components["java"])
artifactId = rootProject.name
artifact(javadocJar)
pom {
name.set(mavenName)
description.set(project.description)
url.set(mavenUrl)
licenses {
license {
name.set("The Apache License, Version 2.0")
url.set("http://www.apache.org/licenses/LICENSE-2.0.txt")
}
}
developers {
developer {
id.set("gbevin")
name.set("Geert Bevin")
email.set("gbevin@uwyn.com")
url.set("https://github.com/gbevin")
}
developer {
id.set("ethauvin")
name.set("Erik C. Thauvin")
email.set("erik@thauvin.net")
url.set("https://erik.thauvin.net/")
}
}
scm {
connection.set("scm:git://github.com/$gitHub.git")
developerConnection.set("scm:git@github.com:$gitHub.git")
url.set(mavenUrl)
}
issueManagement {
system.set("GitHub")
url.set("$mavenUrl/issues")
}
}
}
}
repositories {
maven {
name = "ossrh"
url = if (project.version.toString().contains("SNAPSHOT"))
uri("https://oss.sonatype.org/content/repositories/snapshots/") else
uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/")
credentials(PasswordCredentials::class)
}
}
}
signing {
useGpgCmd()
sign(publishing.publications[publicationName])
}

View file

@ -1,13 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:UrlEncoder.kt$UrlEncoder$hasOption &amp;&amp; args.size == 2 || !hasOption &amp;&amp; args.size == 1</ID>
<ID>MagicNumber:UrlEncoder.kt$UrlEncoder$0x80</ID>
<ID>MagicNumber:UrlEncoder.kt$UrlEncoder$0xFF</ID>
<ID>MagicNumber:UrlEncoder.kt$UrlEncoder$16</ID>
<ID>MagicNumber:UrlEncoder.kt$UrlEncoder$3</ID>
<ID>MagicNumber:UrlEncoder.kt$UrlEncoder$4</ID>
<ID>NestedBlockDepth:UrlEncoder.kt$UrlEncoder$@JvmStatic fun encode(source: String, allow: String): String</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- This module was also published with a richer model, Gradle metadata, -->
<!-- which should be used instead. Do not delete the following line which -->
<!-- is to indicate to Gradle or any Gradle module metadata file consumer -->
<!-- that they should prefer consuming it instead. -->
<!-- do_not_remove: published-with-gradle-metadata -->
<modelVersion>4.0.0</modelVersion>
<groupId>net.thauvin.erik</groupId>
<artifactId>urlencoder</artifactId>
<version>1.0.0</version>
<name>UrlEncoder</name>
<description>A simple library to encode/decode URL parameters</description>
<url>https://github.com/ethauvin/urlencoder</url>
<licenses>
<license>
<name>The Apache License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>
<developers>
<developer>
<id>gbevin</id>
<name>Geert Bevin</name>
<email>gbevin@uwyn.com</email>
<url>https://github.com/gbevin</url>
</developer>
<developer>
<id>ethauvin</id>
<name>Erik C. Thauvin</name>
<email>erik@thauvin.net</email>
<url>https://erik.thauvin.net/</url>
</developer>
</developers>
<scm>
<connection>scm:git://github.com/ethauvin/urlencoder.git</connection>
<developerConnection>scm:git@github.com:ethauvin/urlencoder.git</developerConnection>
<url>https://github.com/ethauvin/urlencoder</url>
</scm>
<issueManagement>
<system>GitHub</system>
<url>https://github.com/ethauvin/urlencoder/issues</url>
</issueManagement>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>1.8.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View file

@ -1,227 +0,0 @@
/*
* Copyright 2001-2023 Geert Bevin (gbevin[remove] at uwyn dot com)
* Copyright 2023 Erik C. Thauvin (erik@thauvin.net)
*
* 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.
*/
package net.thauvin.erik.urlencoder
import java.nio.charset.StandardCharsets
import java.util.BitSet
import kotlin.system.exitProcess
/**
* URL parameters encoding and decoding.
*
* - Rules determined by [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#page-13),
*
* @author Geert Bevin (gbevin[remove] at uwyn dot com)
* @author Erik C. Thauvin (erik@thauvin.net)
*/
object UrlEncoder {
private val hexDigits = "0123456789ABCDEF".toCharArray()
internal val usage =
"Usage : kotlin -cp urlencoder-*.jar ${UrlEncoder::class.java.name} [-ed] text" + System.lineSeparator() +
"Encode and decode URL parameters." + System.lineSeparator() + " -e encode (default) " +
System.lineSeparator() + " -d decode"
// see https://www.rfc-editor.org/rfc/rfc3986#page-13
private val unreservedChars = BitSet('~'.code + 1).apply {
set('-')
set('.')
for (c in '0'..'9') {
set(c)
}
for (c in 'A'..'Z') {
set(c)
}
set('_'.code)
for (c in 'a'.code..'z'.code) {
set(c)
}
set('~')
}
private fun BitSet.set(c: Char) = this.set(c.code)
// see https://www.rfc-editor.org/rfc/rfc3986#page-13
private fun Char.isUnreserved(): Boolean {
return this <= '~' && unreservedChars.get(code)
}
private fun StringBuilder.appendEncodedDigit(digit: Int) {
this.append(hexDigits[digit and 0x0F])
}
private fun StringBuilder.appendEncodedByte(ch: Int) {
this.append("%")
this.appendEncodedDigit(ch shr 4)
this.appendEncodedDigit(ch)
}
/**
* Transforms a provided [String] into a new string, containing decoded URL characters in the UTF-8
* encoding.
*/
@JvmStatic
fun decode(source: String): String {
if (source.isBlank()) {
return source
}
val length = source.length
var out: StringBuilder? = null
var ch: Char
var bytesBuffer: ByteArray? = null
var bytesPos = 0
var i = 0
while (i < length) {
ch = source[i]
if (ch == '%') {
if (out == null) {
out = StringBuilder(length)
out.append(source, 0, i)
}
if (bytesBuffer == null) {
// the remaining characters divided by the length of the encoding format %xx, is the maximum number
// of bytes that can be extracted
bytesBuffer = ByteArray((length - i) / 3)
}
i++
require(length >= i + 2) { "Illegal escape sequence" }
try {
val v: Int = source.substring(i, i + 2).toInt(16)
require(v in 0..0xFF) { "Illegal escape value" }
bytesBuffer[bytesPos++] = v.toByte()
i += 2
} catch (e: NumberFormatException) {
throw IllegalArgumentException("Illegal characters in escape sequence: $e.message")
}
} else {
if (bytesBuffer != null) {
out!!.append(String(bytesBuffer, 0, bytesPos, StandardCharsets.UTF_8))
bytesBuffer = null
bytesPos = 0
}
out?.append(ch)
i++
}
}
if (bytesBuffer != null) {
out!!.append(String(bytesBuffer, 0, bytesPos, StandardCharsets.UTF_8))
}
return out?.toString() ?: source
}
/**
* Transforms a provided [String] object into a new string, containing only valid URL characters in the UTF-8
* encoding.
*
* - Letters, numbers, unreserved (`_-!.~'()*`) and allowed characters are left intact.
*/
@JvmStatic
fun encode(source: String, allow: String): String {
if (source.isEmpty()) {
return source
}
var out: StringBuilder? = null
var ch: Char
var i = 0
while (i < source.length) {
ch = source[i]
if (ch.isUnreserved() || allow.indexOf(ch) != -1) {
out?.append(ch)
i++
} else {
if (out == null) {
out = StringBuilder(source.length)
out.append(source, 0, i)
}
val cp = source.codePointAt(i)
if (cp < 0x80) {
out.appendEncodedByte(cp)
i++
} else if (Character.isBmpCodePoint(cp)) {
for (b in ch.toString().toByteArray(StandardCharsets.UTF_8)) {
out.appendEncodedByte(b.toInt())
}
i++
} else if (Character.isSupplementaryCodePoint(cp)) {
val high = Character.highSurrogate(cp)
val low = Character.lowSurrogate(cp)
for (b in charArrayOf(high, low).concatToString().toByteArray(StandardCharsets.UTF_8)) {
out.appendEncodedByte(b.toInt())
}
i += 2
}
}
}
return out?.toString() ?: source
}
/**
* Transforms a provided [String] object into a new string, containing only valid URL characters in the UTF-8
* encoding.
*
* - Letters, numbers, unreserved (`_-!.~'()*`) and allowed characters are left intact.
*/
@JvmStatic
fun encode(source: String, vararg allow: Char): String {
return encode(source, String(allow))
}
/**
* Encodes and decodes URLs from the command line.
*
* - `kotlin -cp urlencoder-*.jar net.thauvin.erik.urlencoder.UrlEncoder`
*/
@JvmStatic
fun main(args: Array<String>) {
try {
val result = processMain(args)
if (result.status == 1) {
System.err.println(result.output)
} else {
println(result.output)
}
exitProcess(result.status)
} catch (e: IllegalArgumentException) {
System.err.println("${UrlEncoder::class.java.simpleName}: ${e.message}");
exitProcess(1)
}
}
internal data class MainResult(var output: String = usage, var status: Int = 1)
internal fun processMain(args: Array<String>): MainResult {
val result = MainResult()
if (args.isNotEmpty() && args[0].isNotEmpty()) {
val hasDecode = (args[0] == "-d")
val hasOption = (hasDecode || args[0] == "-e")
if (hasOption && args.size == 2 || !hasOption && args.size == 1) {
val arg = if (hasOption) args[1] else args[0]
if (hasDecode) {
result.output = decode(arg)
} else {
result.output = encode(arg)
}
result.status = 0
}
}
return result
}
}

View file

@ -1,156 +0,0 @@
/*
* Copyright 2001-2023 Geert Bevin (gbevin[remove] at uwyn dot com)
* Copyright 2023 Erik C. Thauvin (erik@thauvin.net)
*
* 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.
*/
package net.thauvin.erik.urlencoder
import net.thauvin.erik.urlencoder.UrlEncoder.decode
import net.thauvin.erik.urlencoder.UrlEncoder.encode
import net.thauvin.erik.urlencoder.UrlEncoder.processMain
import net.thauvin.erik.urlencoder.UrlEncoder.usage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.Arguments.arguments
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.ValueSource
import java.util.stream.Stream
class UrlEncoderTest {
private val same = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~"
companion object {
@JvmStatic
fun invalid() = arrayOf("sdkjfh%", "sdkjfh%6", "sdkjfh%xx", "sdfjfh%-1")
@JvmStatic
fun validMap(): Stream<Arguments> = Stream.of(
arguments("a test &", "a%20test%20%26"),
arguments(
"!abcdefghijklmnopqrstuvwxyz%%ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~=",
"%21abcdefghijklmnopqrstuvwxyz%25%25ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~%3D"
),
arguments("%#okékÉȢ smile!😁", "%25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81"),
arguments(
"\uD808\uDC00\uD809\uDD00\uD808\uDF00\uD808\uDD00", "%F0%92%80%80%F0%92%94%80%F0%92%8C%80%F0%92%84%80"
)
)
}
@ParameterizedTest(name = "decode({0}) should be {1}")
@MethodSource("validMap")
fun `Decode URL`(expected: String, source: String) {
assertEquals(expected, decode(source))
}
@ParameterizedTest(name = "decode({0})")
@MethodSource("invalid")
fun `Decode with Exception`(source: String) {
assertThrows(IllegalArgumentException::class.java, { decode(source) }, "decode($source)")
}
@Test
fun `Decode when None needed`() {
assertSame(same, decode(same))
assertEquals("", decode(""), "decode('')")
assertEquals(" ", decode(" "), "decode(' ')")
}
@ParameterizedTest(name = "encode({0}) should be {1}")
@MethodSource("validMap")
fun `Encode URL`(source: String, expected: String) {
assertEquals(expected, encode(source))
}
@Test
fun `Encode Empty or Blank`() {
assertTrue(encode("", "").isEmpty(), "encode('','')")
assertEquals("", encode(""), "encode('')")
assertEquals("%20", encode(" "), "encode('')")
}
@Test
fun `Encode when None needed`() {
assertSame(same, encode(same))
assertSame(same, encode(same, ""), "with empty allow")
}
@Test
fun `Encode with Allow Arg`() {
assertEquals("?test=a%20test", encode("?test=a test", '=', '?'), "encode(x, =, ?)")
assertEquals("?test=a%20test", encode("?test=a test", "=?"), "encode(x, =?)")
assertEquals("aaa", encode("aaa", 'a'), "encode(aaa, a)")
assertEquals(" ", encode(" ", ' '), "encode(' ', ' ')")
}
@ParameterizedTest(name = "processMain(-d {1}) should be {0}")
@MethodSource("validMap")
fun `Main Decode`(expected: String, source: String) {
val result: UrlEncoder.MainResult = processMain(arrayOf("-d", source))
assertEquals(expected, result.output)
assertEquals(0, result.status, "processMain(-d $source).status")
}
@ParameterizedTest(name = "processMain(-d {0})")
@MethodSource("invalid")
fun `Main Decode with Exception`(source: String) {
assertThrows(IllegalArgumentException::class.java, { processMain(arrayOf("-d", source)) }, source)
}
@ParameterizedTest(name = "processMain(-e {0})")
@MethodSource("validMap")
fun `Main Encode`(source: String, expected: String) {
val result = processMain(arrayOf(source))
assertEquals(expected, result.output)
assertEquals(0, result.status, "processMain(-e $source).status")
}
@ParameterizedTest(name = "processMain(-e {0})")
@MethodSource("validMap")
fun `Main Encode with Option`(source: String, expected: String) {
val result = processMain(arrayOf("-e", source))
assertEquals(expected, result.output)
assertEquals(0, result.status, "processMain(-e $source).status")
}
@Test
fun `Main Usage with Empty Args`() {
assertEquals(usage, processMain(arrayOf(" ", " ")).output, "processMain(' ', ' ')")
assertEquals(usage, processMain(arrayOf("foo", " ")).output, "processMain('foo', ' ')")
assertEquals(usage, processMain(arrayOf(" ", "foo")).output, "processMain(' ', 'foo')")
assertEquals(usage, processMain(arrayOf("-d ", "")).output, "processMain('-d', '')")
assertEquals("%20", processMain(arrayOf("-e", " ")).output, "processMain('-e', ' ')")
assertEquals(" ", processMain(arrayOf("-d", " ")).output, "processMain('-d', ' ')")
}
@ParameterizedTest
@ValueSource(strings = ["", "-d", "-e"])
fun `Main Usage with Invalid arg`(arg: String) {
val result = processMain(arrayOf(arg))
assertEquals(usage, result.output, "processMain('$arg')")
assertEquals(1, result.status, "processMain('$arg').status")
}
@Test
fun `Main Usage with too Many Args`() {
assertEquals(usage, processMain(arrayOf("foo", "bar", "test")).output, "too many args")
}
}

View file

@ -1,12 +1,90 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
*
* Detailed information about configuring a multi-project build in Gradle can be found
* in the user manual at https://docs.gradle.org/7.6/userguide/multi_project_builds.html
* This project uses @Incubating APIs which are subject to change.
*/
rootProject.name = "urlencoder"
include("lib")
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
mavenCentral()
maven("https://oss.sonatype.org/content/repositories/snapshots") {
name = "Sonatype Snapshots"
mavenContent { snapshotsOnly() }
}
// Declare the Node.js & Yarn download repositories
exclusiveContent {
forRepositories(
ivy("https://nodejs.org/dist/") {
name = "Node Distributions at $url"
patternLayout { artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") }
metadataSources { artifact() }
},
ivy("https://nodejs.org/download/v8-canary/") {
name = "Node Canary Distributions at $url"
patternLayout { artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") }
metadataSources { artifact() }
},
ivy("https://nodejs.org/download/nightly/") {
name = "Node Nightly Distributions at $url"
patternLayout { artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") }
metadataSources { artifact() }
},
)
filter { includeGroup("org.nodejs") }
}
exclusiveContent {
forRepository {
ivy("https://github.com/yarnpkg/yarn/releases/download") {
name = "Yarn Distributions at $url"
patternLayout { artifact("v[revision]/[artifact](-v[revision]).[ext]") }
metadataSources { artifact() }
}
}
filter { includeGroup("com.yarnpkg") }
}
// workaround for https://youtrack.jetbrains.com/issue/KT-51379
exclusiveContent {
forRepository {
ivy("https://download.jetbrains.com/kotlin/native/builds") {
name = "Kotlin Native"
patternLayout {
// example download URLs:
// https://download.jetbrains.com/kotlin/native/builds/releases/1.7.20/linux-x86_64/kotlin-native-prebuilt-linux-x86_64-1.7.20.tar.gz
// https://download.jetbrains.com/kotlin/native/builds/releases/1.7.20/windows-x86_64/kotlin-native-prebuilt-windows-x86_64-1.7.20.zip
// https://download.jetbrains.com/kotlin/native/builds/releases/1.7.20/macos-x86_64/kotlin-native-prebuilt-macos-x86_64-1.7.20.tar.gz
listOf(
"macos-x86_64",
"macos-aarch64",
"osx-x86_64",
"osx-aarch64",
"linux-x86_64",
"windows-x86_64",
).forEach { os ->
listOf("dev", "releases").forEach { stage ->
artifact("$stage/[revision]/$os/[artifact]-[revision].[ext]")
}
}
}
metadataSources { artifact() }
}
}
filter { includeModuleByRegex(".*", ".*kotlin-native-prebuilt.*") }
}
}
}
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(
":urlencoder-app",
":urlencoder-lib",
)

View file

@ -0,0 +1,67 @@
import org.jetbrains.dokka.gradle.DokkaTask
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
plugins {
buildsrc.conventions.lang.`kotlin-multiplatform-jvm`
buildsrc.conventions.publishing
id("com.github.ben-manes.versions")
}
val urlEncoderMainClass = "net.thauvin.erik.urlencoder.UrlEncoder"
kotlin {
jvm {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
mainRun {
mainClass.set(urlEncoderMainClass)
}
}
sourceSets {
commonMain {
dependencies {
implementation(projects.urlencoderLib)
}
}
jvmTest {
dependencies {
//implementation("com.willowtreeapps.assertk:assertk-jvm:0.25")
//implementation("org.junit.jupiter:junit-jupiter:5.9.1")
implementation(kotlin("test"))
}
}
}
}
base {
archivesName.set(rootProject.name)
}
tasks {
jvmJar {
manifest {
attributes["Main-Class"] = urlEncoderMainClass
}
}
val fatJar by registering(Jar::class) {
group = LifecycleBasePlugin.BUILD_GROUP
archiveClassifier.set("all")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest { attributes(mapOf("Main-Class" to urlEncoderMainClass)) }
from(sourceSets.main.map { it.output })
dependsOn(configurations.jvmRuntimeClasspath)
from(configurations.jvmRuntimeClasspath.map { classpath ->
classpath.filter { it.name.endsWith(".jar") }.map { zipTree(it) }
})
}
build {
dependsOn(fatJar)
}
withType<DokkaTask>().configureEach {
dokkaSourceSets.configureEach {
moduleName.set("UrlEncoder Application")
}
}
}

View file

@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:UrlEncoder.kt$UrlEncoder$hasOption &amp;&amp; args.size == 2 || !hasOption &amp;&amp; args.size == 1</ID>
<ID>MaxLineLength:UrlEncoder.kt$UrlEncoder$*</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,106 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package net.thauvin.erik.urlencoder
import kotlin.system.exitProcess
/**
* Most defensive approach to URL encoding and decoding.
*
* - Rules determined by combining the unreserved character set from
* [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#page-13) with the percent-encode set from
* [application/x-www-form-urlencoded](https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set).
*
* - Both specs above support percent decoding of two hexadecimal digits to a binary octet, however their unreserved
* set of characters differs and `application/x-www-form-urlencoded` adds conversion of space to `+`, which has the
* potential to be misunderstood.
*
* - This library encodes with rules that will be decoded correctly in either case.
*
* @author Geert Bevin (gbevin(remove) at uwyn dot com)
* @author Erik C. Thauvin (erik@thauvin.net)
**/
object UrlEncoder {
internal val usage =
"Usage : java -jar urlencoder-*all.jar [-ed] text" + System.lineSeparator() +
"Encode and decode URL components defensively." + System.lineSeparator() +
" -e encode (default) " + System.lineSeparator() +
" -d decode"
/**
* Encodes and decodes URLs from the command line.
*
* - `java -jar urlencoder-*all.jar [-ed] text`
*/
@JvmStatic
fun main(args: Array<String>) {
try {
val result = processMain(args)
if (result.status == 1) {
System.err.println(result.output)
} else {
println(result.output)
}
exitProcess(result.status)
} catch (e: IllegalArgumentException) {
System.err.println("${UrlEncoder::class.java.simpleName}: ${e.message}")
exitProcess(1)
}
}
internal data class MainResult(var output: String = usage, var status: Int = 1)
internal fun processMain(args: Array<String>): MainResult {
val result = MainResult()
if (args.isNotEmpty() && args[0].isNotEmpty()) {
val hasDecode = (args[0] == "-d")
val hasOption = (hasDecode || args[0] == "-e")
if (hasOption && args.size == 2 || !hasOption && args.size == 1) {
val arg = if (hasOption) args[1] else args[0]
if (hasDecode) {
result.output = decode(arg)
} else {
result.output = UrlEncoderUtil.encode(arg)
}
result.status = 0
}
}
return result
}
/**
* Transforms a provided [String] into a new string, containing decoded URL characters in the UTF-8
* encoding.
*/
@JvmStatic
@JvmOverloads
fun decode(source: String, plusToSpace: Boolean = false): String =
// delegate to UrlEncoderFunctions for backwards compatibility
UrlEncoderUtil.decode(source, plusToSpace)
/**
* Transforms a provided [String] object into a new string, containing only valid URL characters in the UTF-8
* encoding.
*
* - Letters, numbers, unreserved (`_-!.'()*`) and allowed characters are left intact.
*/
@JvmStatic
@JvmOverloads
fun encode(source: String, allow: String = "", spaceToPlus: Boolean = false): String =
UrlEncoderUtil.encode(source, allow, spaceToPlus)
}

View file

@ -0,0 +1,115 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package net.thauvin.erik.urlencoder
import net.thauvin.erik.urlencoder.UrlEncoder.processMain
import net.thauvin.erik.urlencoder.UrlEncoder.usage
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class UrlEncoderTest {
companion object {
val invalid = listOf("sdkjfh%", "sdkjfh%6", "sdkjfh%xx", "sdfjfh%-1")
val validMap = listOf(
"a test &" to "a%20test%20%26",
"!abcdefghijklmnopqrstuvwxyz%%ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~=" to
"%21abcdefghijklmnopqrstuvwxyz%25%25ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.%7E%3D",
"%#okékÉȢ smile!😁" to "%25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81",
"\uD808\uDC00\uD809\uDD00\uD808\uDF00\uD808\uDD00" to "%F0%92%80%80%F0%92%94%80%F0%92%8C%80%F0%92%84%80",
)
}
@Test
fun `Encode with SpaceToPlus`() {
assertEquals("this+is+a+test", UrlEncoder.encode("this is a test", spaceToPlus = true))
}
@Test
fun `Encode with Allow`() {
assertEquals("this is a test", UrlEncoder.encode("this is a test", allow = " "))
}
@Test
fun `Encode without Parameters`() {
for (m in validMap) {
assertEquals(m.second, UrlEncoder.encode(m.first), "encode(${m.first})")
}
}
@Test
fun `Main Decode`() {
for (m in validMap) {
val result: UrlEncoder.MainResult = processMain(arrayOf("-d", m.second))
assertEquals(m.first, result.output)
assertEquals(0, result.status, "processMain(-d ${m.second}).status")
}
}
@Test
fun `Main Decode with Exception`() {
for (source in invalid) {
assertFailsWith<IllegalArgumentException>(
message = source,
block = { processMain(arrayOf("-d", source)) }
)
}
}
@Test
fun `Main Encode`() {
for (m in validMap) {
val result = processMain(arrayOf(m.first))
assertEquals(m.second, result.output)
assertEquals(0, result.status, "processMain(-e ${m.first}).status")
}
}
@Test
fun `Main Encode with Option`() {
for (m in validMap) {
val result = processMain(arrayOf("-e", m.first))
assertEquals(m.second, result.output)
assertEquals(0, result.status, "processMain(-e ${m.first}).status")
}
}
@Test
fun `Main Usage with Empty Args`() {
assertEquals(usage, processMain(arrayOf(" ", " ")).output, "processMain(' ', ' ')")
assertEquals(usage, processMain(arrayOf("foo", " ")).output, "processMain('foo', ' ')")
assertEquals(usage, processMain(arrayOf(" ", "foo")).output, "processMain(' ', 'foo')")
assertEquals(usage, processMain(arrayOf("-d ", "")).output, "processMain('-d', '')")
assertEquals("%20", processMain(arrayOf("-e", " ")).output, "processMain('-e', ' ')")
assertEquals(" ", processMain(arrayOf("-d", " ")).output, "processMain('-d', ' ')")
}
@Test
fun `Main Usage with Invalid arg`() {
for (arg in arrayOf("", "-d", "-e")) {
val result = processMain(arrayOf(arg))
assertEquals(usage, result.output, "processMain('$arg')")
assertEquals(1, result.status, "processMain('$arg').status")
}
}
@Test
fun `Main Usage with too Many Args`() {
assertEquals(usage, processMain(arrayOf("foo", "bar", "test")).output, "too many args")
}
}

View file

@ -0,0 +1,46 @@
import org.jetbrains.dokka.gradle.DokkaTask
plugins {
buildsrc.conventions.lang.`kotlin-multiplatform-jvm`
buildsrc.conventions.lang.`kotlin-multiplatform-js`
buildsrc.conventions.lang.`kotlin-multiplatform-native`
buildsrc.conventions.publishing
id("com.github.ben-manes.versions")
}
kotlin {
sourceSets {
commonTest {
dependencies {
implementation(kotlin("test"))
}
}
}
}
base {
archivesName.set("${rootProject.name}-lib")
}
tasks {
dokkaJavadoc {
dokkaSourceSets {
configureEach {
suppress.set(true)
}
val commonMain by getting {
suppress.set(false)
platform.set(org.jetbrains.dokka.Platform.jvm)
}
}
}
withType<DokkaTask>().configureEach {
dokkaSourceSets.configureEach {
moduleName.set("UrlEncoder Library")
}
}
}

View file

@ -0,0 +1,15 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:UrlEncoderUtil.kt$UrlEncoderUtil$0x80</ID>
<ID>MagicNumber:UrlEncoderUtil.kt$UrlEncoderUtil$0xFF</ID>
<ID>MagicNumber:UrlEncoderUtil.kt$UrlEncoderUtil$16</ID>
<ID>MagicNumber:UrlEncoderUtil.kt$UrlEncoderUtil$3</ID>
<ID>MagicNumber:UrlEncoderUtil.kt$UrlEncoderUtil$4</ID>
<ID>MaxLineLength:UrlEncoderUtil.kt$UrlEncoderUtil$*</ID>
<ID>NestedBlockDepth:UrlEncoderUtil.kt$UrlEncoderUtil$@JvmStatic @JvmOverloads fun decode(source: String, plusToSpace: Boolean = false): String</ID>
<ID>NestedBlockDepth:UrlEncoderUtil.kt$UrlEncoderUtil$@JvmStatic @JvmOverloads fun encode(source: String, allow: String = "", spaceToPlus: Boolean = false): String</ID>
<ID>WildcardImport:UrlEncoderUtilTest.kt$import kotlin.test.*</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,73 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package net.thauvin.erik.urlencoder
import kotlin.Char.Companion.MIN_HIGH_SURROGATE
import kotlin.Char.Companion.MIN_LOW_SURROGATE
/**
* Kotlin Multiplatform equivalent for `java.lang.Character`
*
* @author <a href="https://github.com/aSemy">aSemy</a>
*/
internal object Character {
/**
* See https://www.tutorialspoint.com/java/lang/character_issupplementarycodepoint.htm
*
* Determines whether the specified character (Unicode code point) is in the supplementary character range.
* The supplementary character range in the Unicode system falls in `U+10000` to `U+10FFFF`.
*
* The Unicode code points are divided into two categories:
* Basic Multilingual Plane (BMP) code points and Supplementary code points.
* BMP code points are present in the range U+0000 to U+FFFF.
*
* Whereas, supplementary characters are rare characters that are not represented using the original 16-bit Unicode.
* For example, these type of characters are used in Chinese or Japanese scripts and hence, are required by the
* applications used in these countries.
*
* @returns `true` if the specified code point falls in the range of supplementary code points
* ([MIN_SUPPLEMENTARY_CODE_POINT] to [MAX_CODE_POINT], inclusive), `false` otherwise.
*/
internal fun isSupplementaryCodePoint(codePoint: Int): Boolean =
codePoint in MIN_SUPPLEMENTARY_CODE_POINT..MAX_CODE_POINT
internal fun toCodePoint(highSurrogate: Char, lowSurrogate: Char): Int =
(highSurrogate.code shl 10) + lowSurrogate.code + SURROGATE_DECODE_OFFSET
/** Basic Multilingual Plane (BMP) */
internal fun isBmpCodePoint(codePoint: Int): Boolean = codePoint ushr 16 == 0
internal fun highSurrogateOf(codePoint: Int): Char =
((codePoint ushr 10) + HIGH_SURROGATE_ENCODE_OFFSET.code).toChar()
internal fun lowSurrogateOf(codePoint: Int): Char =
((codePoint and 0x3FF) + MIN_LOW_SURROGATE.code).toChar()
// private const val MIN_CODE_POINT: Int = 0x000000
private const val MAX_CODE_POINT: Int = 0x10FFFF
private const val MIN_SUPPLEMENTARY_CODE_POINT: Int = 0x10000
private const val SURROGATE_DECODE_OFFSET: Int =
MIN_SUPPLEMENTARY_CODE_POINT -
(MIN_HIGH_SURROGATE.code shl 10) -
MIN_LOW_SURROGATE.code
private const val HIGH_SURROGATE_ENCODE_OFFSET: Char = MIN_HIGH_SURROGATE - (MIN_SUPPLEMENTARY_CODE_POINT ushr 10)
}

View file

@ -0,0 +1,247 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package net.thauvin.erik.urlencoder
import kotlin.jvm.JvmOverloads
import kotlin.jvm.JvmStatic
/**
* Most defensive approach to URL encoding and decoding.
*
* - Rules determined by combining the unreserved character set from
* [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#page-13) with the percent-encode set from
* [application/x-www-form-urlencoded](https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set).
*
* - Both specs above support percent decoding of two hexadecimal digits to a binary octet, however their unreserved
* set of characters differs and `application/x-www-form-urlencoded` adds conversion of space to `+`, which has the
* potential to be misunderstood.
*
* - This library encodes with rules that will be decoded correctly in either case.
*
* @author Geert Bevin (gbevin(remove) at uwyn dot com)
* @author Erik C. Thauvin (erik@thauvin.net)
**/
object UrlEncoderUtil {
private val hexDigits = "0123456789ABCDEF".toCharArray()
/**
* A [BooleanArray] with entries for the [character codes][Char.code] of
*
* * `0-9`,
* * `A-Z`,
* * `a-z`
*
* set to `true`.
*/
private val unreservedChars = BooleanArray('z'.code + 1).apply {
set('-'.code, true)
set('.'.code, true)
set('_'.code, true)
for (c in '0'..'9') {
set(c.code, true)
}
for (c in 'A'..'Z') {
set(c.code, true)
}
for (c in 'a'..'z') {
set(c.code, true)
}
}
// see https://www.rfc-editor.org/rfc/rfc3986#page-13
// and https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set
private fun Char.isUnreserved(): Boolean {
return this <= 'z' && unreservedChars[code]
}
private fun StringBuilder.appendEncodedDigit(digit: Int) {
this.append(hexDigits[digit and 0x0F])
}
private fun StringBuilder.appendEncodedByte(ch: Int) {
this.append("%")
this.appendEncodedDigit(ch shr 4)
this.appendEncodedDigit(ch)
}
/**
* Transforms a provided [String] into a new string, containing decoded URL characters in the UTF-8
* encoding.
*/
@JvmStatic
@JvmOverloads
fun decode(source: String, plusToSpace: Boolean = false): String {
if (source.isEmpty()) {
return source
}
val length = source.length
val out = StringBuilder(length)
var bytesBuffer: ByteArray? = null
var bytesPos = 0
var i = 0
var started = false
while (i < length) {
val ch = source[i]
if (ch == '%') {
if (!started) {
out.append(source, 0, i)
started = true
}
if (bytesBuffer == null) {
// the remaining characters divided by the length of the encoding format %xx, is the maximum number
// of bytes that can be extracted
bytesBuffer = ByteArray((length - i) / 3)
}
i++
require(length >= i + 2) { "Incomplete trailing escape ($ch) pattern" }
try {
val v = source.substring(i, i + 2).toInt(16)
require(v in 0..0xFF) { "Illegal escape value" }
bytesBuffer[bytesPos++] = v.toByte()
i += 2
} catch (e: NumberFormatException) {
throw IllegalArgumentException("Illegal characters in escape sequence: $e.message", e)
}
} else {
if (bytesBuffer != null) {
out.append(bytesBuffer.decodeToString(0, bytesPos))
started = true
bytesBuffer = null
bytesPos = 0
}
if (plusToSpace && ch == '+') {
if (!started) {
out.append(source, 0, i)
started = true
}
out.append(" ")
} else if (started) {
out.append(ch)
}
i++
}
}
if (bytesBuffer != null) {
out.append(bytesBuffer.decodeToString(0, bytesPos))
}
return if (!started) source else out.toString()
}
/**
* Transforms a provided [String] object into a new string, containing only valid URL
* characters in the UTF-8 encoding.
*
* - Letters, numbers, unreserved (`_-!.'()*`) and allowed characters are left intact.
*/
@JvmStatic
@JvmOverloads
fun encode(source: String, allow: String = "", spaceToPlus: Boolean = false): String {
if (source.isEmpty()) {
return source
}
var out: StringBuilder? = null
var i = 0
while (i < source.length) {
val ch = source[i]
if (ch.isUnreserved() || ch in allow) {
out?.append(ch)
i++
} else {
if (out == null) {
out = StringBuilder(source.length)
out.append(source, 0, i)
}
val cp = source.codePointAt(i)
when {
cp < 0x80 -> {
if (spaceToPlus && ch == ' ') {
out.append('+')
} else {
out.appendEncodedByte(cp)
}
i++
}
Character.isBmpCodePoint(cp) -> {
for (b in ch.toString().encodeToByteArray()) {
out.appendEncodedByte(b.toInt())
}
i++
}
Character.isSupplementaryCodePoint(cp) -> {
val high = Character.highSurrogateOf(cp)
val low = Character.lowSurrogateOf(cp)
for (b in charArrayOf(high, low).concatToString().encodeToByteArray()) {
out.appendEncodedByte(b.toInt())
}
i += 2
}
}
}
}
return out?.toString() ?: source
}
/**
* Returns the Unicode code point at the specified index.
*
* The `index` parameter is the regular `CharSequence` index, i.e. the number of `Char`s from the start of the character
* sequence.
*
* If the code point at the specified index is part of the Basic Multilingual Plane (BMP), its value can be represented
* using a single `Char` and this method will behave exactly like [CharSequence.get].
* Code points outside the BMP are encoded using a surrogate pair a `Char` containing a value in the high surrogate
* range followed by a `Char` containing a value in the low surrogate range. Together these two `Char`s encode a single
* code point in one of the supplementary planes. This method will do the necessary decoding and return the value of
* that single code point.
*
* In situations where surrogate characters are encountered that don't form a valid surrogate pair starting at `index`,
* this method will return the surrogate code point itself, behaving like [CharSequence.get].
*
* If the `index` is out of bounds of this character sequence, this method throws an [IndexOutOfBoundsException].
*
* ```kotlin
* // Text containing code points outside the BMP (encoded as a surrogate pairs)
* val text = "\uD83E\uDD95\uD83E\uDD96"
*
* var index = 0
* while (index < text.length) {
* val codePoint = text.codePointAt(index)
* // (Do something with codePoint...)
* index += CodePoints.charCount(codePoint)
* }
* ```
*/
private fun CharSequence.codePointAt(index: Int): Int {
if (index !in indices) throw IndexOutOfBoundsException("index $index was not in range $indices")
val firstChar = this[index]
if (firstChar.isHighSurrogate()) {
val nextChar = getOrNull(index + 1)
if (nextChar?.isLowSurrogate() == true) {
return Character.toCodePoint(firstChar, nextChar)
}
}
return firstChar.code
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package net.thauvin.erik.urlencoder
import kotlin.jvm.JvmField
const val standardContent = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_."
val invalidContent = listOf("sdkjfh%", "sdkjfh%6", "sdkjfh%xx", "sdfjfh%-1")
/**
* List of unencoded content paired with the encoded content.
*/
val decodedToEncoded = listOf(
TestData("a test &", "a%20test%20%26"),
TestData(
"!abcdefghijklmnopqrstuvwxyz%%ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~=",
"%21abcdefghijklmnopqrstuvwxyz%25%25ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.%7E%3D"
),
TestData("%#okékÉȢ smile!😁", "%25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81"),
TestData("\uD808\uDC00\uD809\uDD00\uD808\uDF00\uD808\uDD00", "%F0%92%80%80%F0%92%94%80%F0%92%8C%80%F0%92%84%80"),
)
data class TestData(
@JvmField
val unencoded: String,
@JvmField
val encoded: String,
)

View file

@ -0,0 +1,93 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package net.thauvin.erik.urlencoder
import net.thauvin.erik.urlencoder.UrlEncoderUtil.decode
import net.thauvin.erik.urlencoder.UrlEncoderUtil.encode
import kotlin.test.*
import kotlin.test.DefaultAsserter.assertEquals
import kotlin.test.DefaultAsserter.assertSame
class UrlEncoderUtilTest {
@Test
fun decodeURL() {
for ((unencoded, encoded) in decodedToEncoded) {
assertEquals(unencoded, decode(encoded))
}
}
@Test
fun decodeWithException() {
for (source in invalidContent) {
assertFailsWith<IllegalArgumentException>(
message = "decode($source)",
block = { decode(source) }
)
}
}
@Test
fun decodeWhenNoneNeeded() {
assertSame(standardContent, decode(standardContent))
assertEquals("decode('')", decode(""), "")
assertEquals("decode(' ')", decode(" "), " ")
}
@Test
fun decodeWithPlusToSpace() {
assertEquals("foo bar", decode("foo+bar", true))
assertEquals("foo bar foo", decode("foo+bar++foo", true))
assertEquals("foo bar foo", decode("foo+%20bar%20+foo", true))
assertEquals("foo + bar", decode("foo+%2B+bar", plusToSpace = true))
assertEquals("foo+bar", decode("foo%2Bbar", plusToSpace = true))
}
@Test
fun encodeURL() {
for ((unencoded, encoded) in decodedToEncoded) {
assertEquals(encoded, encode(unencoded))
}
}
@Test
fun encodeEmptyOrBlank() {
assertTrue(encode("", allow = "").isEmpty(), "encode('','')")
assertEquals("encode('')", encode(""), "")
assertEquals("encode(' ')", encode(" "), "%20")
}
@Test
fun encodeWhenNoneNeeded() {
assertSame(encode(standardContent), standardContent)
assertSame("with empty allow", encode(standardContent, allow = ""), standardContent)
}
@Test
fun encodeWithAllow() {
assertEquals("encode(x, =?)", "?test=a%20test", encode("?test=a test", allow = "=?"))
assertEquals("encode(aaa, a)", "aaa", encode("aaa", "a"))
assertEquals("encode(' ')", " ", encode(" ", " "))
}
@Test
fun encodeWithSpaceToPlus() {
assertEquals("foo+bar", encode("foo bar", spaceToPlus = true))
assertEquals("foo+bar++foo", encode("foo bar foo", spaceToPlus = true))
assertEquals("foo bar", encode("foo bar", " ", true))
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright 2001-2024 the original author or 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.
*/
package net.thauvin.erik.urlencoder;
import org.junit.jupiter.api.Test;
import static net.thauvin.erik.urlencoder.TestDataKt.getDecodedToEncoded;
import static net.thauvin.erik.urlencoder.UrlEncoderUtil.decode;
import static net.thauvin.erik.urlencoder.UrlEncoderUtil.encode;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
class UrlEncoderJavaTest {
@Test
public void decodeURL() {
assertAll(
getDecodedToEncoded()
.stream()
.map(data ->
() -> assertEquals(data.unencoded, decode(data.encoded))
)
);
}
@Test
public void encodeURL() {
assertAll(
getDecodedToEncoded()
.stream()
.map(data ->
() -> assertEquals(data.encoded, encode(data.unencoded))
)
);
}
}