Compare commits

...

148 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
47 changed files with 3585 additions and 952 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
```

104
README.md
View file

@ -1,15 +1,15 @@
[![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
# URL Encoder for Kotlin Multiplatform
A simple defensive library to encode/decode URL components.
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).
@ -29,36 +29,54 @@ 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.
compared to other solutions like the standard `URLEncoder` in the JDK or
`UriUtils` in Spring.
## 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
@ -71,7 +89,7 @@ You have two options:
The usage is as follows:
```
```console
Encode and decode URL components defensively.
-e encode (default)
-d decode
@ -79,26 +97,58 @@ Encode and decode URL components defensively.
### Running with Gradle
```shell
./gradlew run --args="-e 'a test &'" # -> a%20test%20%26
./gradlew run --args="%#okékÉȢ" # -> %25%23ok%C3%A9k%C3%89%C8%A2
```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 --args="-d 'a%20test%20%26'" # -> a test &
./gradlew run --quiet --args="-d 'a%20test%20%26'" # -> a test &
```
### Running with Java
First build the jar file:
```shell
./gradlew clean fatJar
```console
./gradlew fatJar
```
Then run it:
```shell
java -jar lib/build/libs/urlencoder-*all.jar -e "a test &" # -> a%20test%20%26
java -jar lib/build/libs/urlencoder-*all.jar "%#okékÉȢ" # -> %25%23ok%C3%A9k%C3%89%C8%A2
```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 lib/build/libs/urlencoder-*.all.jar -d "a%20test%20%26" # -> a test &
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,18 +134,21 @@ 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.
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,245 +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 defensive library to encode/decode URL components"
group = "net.thauvin.erik"
version = "1.0.1"
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
}
}
val fatJar = register<Jar>("fatJar") {
group = "build"
dependsOn.addAll(listOf("compileJava", "compileKotlin", "processResources"))
archiveClassifier.set("all")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest { attributes(mapOf("Main-Class" to application.mainClass)) }
val sourcesMain = sourceSets.main.get()
val contents = configurations.runtimeClasspath.get()
.map { if (it.isDirectory) it else zipTree(it) } + sourcesMain.output
from(contents)
}
build {
dependsOn(fatJar)
}
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 for Kotlin")
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,14 +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>MaxLineLength:UrlEncoder.kt$UrlEncoder$*</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.1</version>
<name>UrlEncoder for Kotlin</name>
<description>A simple defensive library to encode/decode URL components</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,236 +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
/**
* 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 {
private val hexDigits = "0123456789ABCDEF".toCharArray()
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"
// 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 val unreservedChars = BitSet('z'.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)
}
}
private fun BitSet.set(c: Char) = this.set(c.code)
// 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.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.
*
* - `java -jar urlencoder-*all.jar <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 = 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-_.%7E%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))
)
);
}
}