Compare commits

..

No commits in common. "master" and "1.0.0" have entirely different histories.

47 changed files with 915 additions and 3628 deletions

View file

@ -1,52 +1,61 @@
name: gradle-ci
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
on: [push, pull_request, workflow_dispatch]
jobs:
build:
strategy:
matrix:
java-version: [8, 11, 17, 21]
os:
- macos-latest
- ubuntu-latest
- windows-latest
fail-fast: false
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-XX:MaxMetaspaceSize=512m"
SONAR_JDK: "11"
strategy:
matrix:
java-version: [ 11, 17, 19 ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v4
uses: actions/setup-java@v1
with:
distribution: "zulu"
java-version: ${{ matrix.java-version }}
- name: Validate Gradle wrapper
uses: gradle/wrapper-validation-action@v2
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Cache Kotlin Konan
uses: actions/cache@v4
- name: Cache SonarCloud packages
if: matrix.java-version == env.SONAR_JDK
uses: actions/cache@v1
with:
path: ~/.konan/**/*
key: kotlin-konan-${{ runner.os }}
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
- 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: Publish
run: ./gradlew check build --stacktrace -PtestsBadgeApiKey=${{ secrets.TESTS_BADGE_API_KEY }}
- 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

View file

@ -1,44 +0,0 @@
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

147
.gitignore vendored
View file

@ -1,72 +1,85 @@
### Gradle ###
.gradle
build/
deploy/
### Kotlin/JVM ###
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
!.vscode/tasks.json
*.class
*.code-workspace
*.ctxt
*.iws
*.log
hs_err_pid*
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 ###
*.sublime-*
*.tar.gz
*.zip
.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/**
.classpath
.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
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

3
.idea/.gitignore generated vendored Normal file
View file

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

6
.idea/compiler.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?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 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="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="myName" value="Apache License" />
</copyright>
</component>

View file

@ -0,0 +1,8 @@
<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>

25
.idea/jarRepositories.xml generated Normal file
View file

@ -0,0 +1,25 @@
<?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 Normal file
View file

@ -0,0 +1,6 @@
<?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 Normal file
View file

@ -0,0 +1,8 @@
<?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 Normal file
View file

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

View file

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

138
README.md
View file

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

View file

@ -1,12 +0,0 @@
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)
}

View file

@ -1,12 +0,0 @@
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

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

View file

@ -1,18 +0,0 @@
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

@ -1,64 +0,0 @@
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

@ -1,46 +0,0 @@
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

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

View file

@ -1,32 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,109 +0,0 @@
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

@ -1,63 +0,0 @@
/*
* 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)
}
}
}
}

View file

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

34
gradlew vendored
View file

@ -15,8 +15,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -57,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -85,9 +83,10 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# 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
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"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -134,13 +133,10 @@ location of your Java installation."
fi
else
JAVACMD=java
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.
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
@ -148,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@ -156,7 +152,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=SC2039,SC3045
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -201,15 +197,11 @@ if "$cygwin" || "$msys" ; then
done
fi
# 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.
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \

22
gradlew.bat vendored
View file

@ -13,8 +13,6 @@
@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 ##########################################################################
@ -45,11 +43,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
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
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
@ -59,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
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
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail

229
lib/build.gradle.kts Normal file
View file

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

13
lib/detekt-baseline.xml Normal file
View file

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

52
lib/pom.xml Normal file
View file

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

View file

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

View file

@ -0,0 +1,156 @@
/*
* Copyright 2001-2023 Geert Bevin (gbevin[remove] at uwyn dot com)
* Copyright 2023 Erik C. Thauvin (erik@thauvin.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.thauvin.erik.urlencoder
import net.thauvin.erik.urlencoder.UrlEncoder.decode
import net.thauvin.erik.urlencoder.UrlEncoder.encode
import net.thauvin.erik.urlencoder.UrlEncoder.processMain
import net.thauvin.erik.urlencoder.UrlEncoder.usage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.Arguments.arguments
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.ValueSource
import java.util.stream.Stream
class UrlEncoderTest {
private val same = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~"
companion object {
@JvmStatic
fun invalid() = arrayOf("sdkjfh%", "sdkjfh%6", "sdkjfh%xx", "sdfjfh%-1")
@JvmStatic
fun validMap(): Stream<Arguments> = Stream.of(
arguments("a test &", "a%20test%20%26"),
arguments(
"!abcdefghijklmnopqrstuvwxyz%%ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~=",
"%21abcdefghijklmnopqrstuvwxyz%25%25ABCDEFGHIJKLMNOPQRSTUVQXYZ0123456789-_.~%3D"
),
arguments("%#okékÉȢ smile!😁", "%25%23ok%C3%A9k%C3%89%C8%A2%20smile%21%F0%9F%98%81"),
arguments(
"\uD808\uDC00\uD809\uDD00\uD808\uDF00\uD808\uDD00", "%F0%92%80%80%F0%92%94%80%F0%92%8C%80%F0%92%84%80"
)
)
}
@ParameterizedTest(name = "decode({0}) should be {1}")
@MethodSource("validMap")
fun `Decode URL`(expected: String, source: String) {
assertEquals(expected, decode(source))
}
@ParameterizedTest(name = "decode({0})")
@MethodSource("invalid")
fun `Decode with Exception`(source: String) {
assertThrows(IllegalArgumentException::class.java, { decode(source) }, "decode($source)")
}
@Test
fun `Decode when None needed`() {
assertSame(same, decode(same))
assertEquals("", decode(""), "decode('')")
assertEquals(" ", decode(" "), "decode(' ')")
}
@ParameterizedTest(name = "encode({0}) should be {1}")
@MethodSource("validMap")
fun `Encode URL`(source: String, expected: String) {
assertEquals(expected, encode(source))
}
@Test
fun `Encode Empty or Blank`() {
assertTrue(encode("", "").isEmpty(), "encode('','')")
assertEquals("", encode(""), "encode('')")
assertEquals("%20", encode(" "), "encode('')")
}
@Test
fun `Encode when None needed`() {
assertSame(same, encode(same))
assertSame(same, encode(same, ""), "with empty allow")
}
@Test
fun `Encode with Allow Arg`() {
assertEquals("?test=a%20test", encode("?test=a test", '=', '?'), "encode(x, =, ?)")
assertEquals("?test=a%20test", encode("?test=a test", "=?"), "encode(x, =?)")
assertEquals("aaa", encode("aaa", 'a'), "encode(aaa, a)")
assertEquals(" ", encode(" ", ' '), "encode(' ', ' ')")
}
@ParameterizedTest(name = "processMain(-d {1}) should be {0}")
@MethodSource("validMap")
fun `Main Decode`(expected: String, source: String) {
val result: UrlEncoder.MainResult = processMain(arrayOf("-d", source))
assertEquals(expected, result.output)
assertEquals(0, result.status, "processMain(-d $source).status")
}
@ParameterizedTest(name = "processMain(-d {0})")
@MethodSource("invalid")
fun `Main Decode with Exception`(source: String) {
assertThrows(IllegalArgumentException::class.java, { processMain(arrayOf("-d", source)) }, source)
}
@ParameterizedTest(name = "processMain(-e {0})")
@MethodSource("validMap")
fun `Main Encode`(source: String, expected: String) {
val result = processMain(arrayOf(source))
assertEquals(expected, result.output)
assertEquals(0, result.status, "processMain(-e $source).status")
}
@ParameterizedTest(name = "processMain(-e {0})")
@MethodSource("validMap")
fun `Main Encode with Option`(source: String, expected: String) {
val result = processMain(arrayOf("-e", source))
assertEquals(expected, result.output)
assertEquals(0, result.status, "processMain(-e $source).status")
}
@Test
fun `Main Usage with Empty Args`() {
assertEquals(usage, processMain(arrayOf(" ", " ")).output, "processMain(' ', ' ')")
assertEquals(usage, processMain(arrayOf("foo", " ")).output, "processMain('foo', ' ')")
assertEquals(usage, processMain(arrayOf(" ", "foo")).output, "processMain(' ', 'foo')")
assertEquals(usage, processMain(arrayOf("-d ", "")).output, "processMain('-d', '')")
assertEquals("%20", processMain(arrayOf("-e", " ")).output, "processMain('-e', ' ')")
assertEquals(" ", processMain(arrayOf("-d", " ")).output, "processMain('-d', ' ')")
}
@ParameterizedTest
@ValueSource(strings = ["", "-d", "-e"])
fun `Main Usage with Invalid arg`(arg: String) {
val result = processMain(arrayOf(arg))
assertEquals(usage, result.output, "processMain('$arg')")
assertEquals(1, result.status, "processMain('$arg').status")
}
@Test
fun `Main Usage with too Many Args`() {
assertEquals(usage, processMain(arrayOf("foo", "bar", "test")).output, "too many args")
}
}

View file

@ -1,90 +1,12 @@
/*
* 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"
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",
)
include("lib")

View file

@ -1,67 +0,0 @@
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

@ -1,8 +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>MaxLineLength:UrlEncoder.kt$UrlEncoder$*</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,106 +0,0 @@
/*
* 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

@ -1,115 +0,0 @@
/*
* 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

@ -1,46 +0,0 @@
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

@ -1,15 +0,0 @@
<?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

@ -1,73 +0,0 @@
/*
* 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

@ -1,247 +0,0 @@
/*
* 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

@ -1,43 +0,0 @@
/*
* 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

@ -1,93 +0,0 @@
/*
* 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

@ -1,50 +0,0 @@
/*
* 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))
)
);
}
}