Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## [Unreleased]

## [0.2.0] - 2024-11-22

### Fixed

- Fixed Base64 decoding on Android by replacing the Apache Commons Codec dependency.

### Changed

- Internal dependency version bumps.

## [0.1.0] - 2024-02-02

### Fixed
Expand Down Expand Up @@ -58,7 +68,9 @@

## [0.0.1] - 2021-02-17

[Unreleased]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.1.0...HEAD
[Unreleased]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.2.0...HEAD

[0.2.0]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.1.0...0.2.0

[0.1.0]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.0.5...0.1.0

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,21 @@ Add the dependency:
<dependency>
<groupId>com.github.saasquatch</groupId>
<artifactId>saasquatch-java-sdk</artifactId>
<version>0.1.0</version>
<version>0.2.0</version>
</dependency>
```

### Gradle

```gradle
dependencies {
implementation 'com.github.saasquatch:saasquatch-java-sdk:0.1.0'
implementation 'com.github.saasquatch:saasquatch-java-sdk:0.2.0'
}
```

For more information and other built tools, [please refer to the JitPack page](https://jitpack.io/#saasquatch/saasquatch-java-sdk).

This library aims to abstract away the I/O layer and [Reactive Streams](https://www.reactive-streams.org/) implementations to be implementation agnostic. As of right now, this library depends on [RxJava 3](https://github.com/ReactiveX/RxJava), [Gson](https://github.com/google/gson), and [Apache HttpClient 5](https://hc.apache.org/httpcomponents-client-5.0.x/index.html), but never exposes library-specific interfaces other than Reactive Streams interfaces. **It is recommended that you explicitly import the transitive dependencies if you intend to use them**, since we may upgrade or switch to other I/O or Reactive Streams libraries in the future.
This library aims to abstract away the I/O layer and [Reactive Streams](https://www.reactive-streams.org/) implementations to be implementation agnostic. As of right now, this library depends on [RxJava 3](https://github.com/ReactiveX/RxJava), [Gson](https://github.com/google/gson), and [Apache HttpClient 5](https://hc.apache.org/httpcomponents-client-5.0.x/index.html), but never exposes library-specific interfaces other than Reactive Streams interfaces. **It is recommended that you explicitly import the transitive dependencies if you intend to use them directly**, since we may upgrade or switch to other I/O or Reactive Streams libraries in the future.

### Android

Expand Down
23 changes: 15 additions & 8 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,27 @@ tasks.javadoc.options.encoding = 'UTF-8'

repositories {
mavenCentral()
maven { url = uri('https://jitpack.io') }
}

dependencies {
api 'org.reactivestreams:reactive-streams:1.0.4'
implementation 'com.google.code.findbugs:jsr305:3.0.2'
implementation 'commons-codec:commons-codec:1.16.0'
implementation 'org.apache.httpcomponents.client5:httpclient5:5.3.1'
implementation 'io.reactivex.rxjava3:rxjava:3.1.8'
/*
* This is a lightweight Base64 implementation that works on both Android and the regular JRE.
* java.util.Base64 is not available on earlier versions of Android.
* android.util.Base64 does not work on regular JRE.
* Commons codec should be avoided because Android comes bundled with an early version.
* Apache client5 comes with a Base64 class but it's considered internal API.
*/
implementation 'net.iharder:base64:2.3.9'
implementation 'org.apache.httpcomponents.client5:httpclient5:5.4.1'
implementation 'io.reactivex.rxjava3:rxjava:3.1.9'
// Version 2.11.0 requires Android API level 21
implementation 'com.google.code.gson:gson:2.10.1'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
testImplementation 'com.google.guava:guava:33.0.0-jre'
testImplementation 'io.projectreactor:reactor-core:3.6.2'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.3'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.3'
testImplementation 'com.google.guava:guava:33.3.1-jre'
testImplementation 'io.projectreactor:reactor-core:3.7.0'
}

tasks.test {
Expand Down
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
7 changes: 5 additions & 2 deletions gradlew
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#

##############################################################################
#
Expand Down Expand Up @@ -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/.
Expand Down Expand Up @@ -84,7 +86,8 @@ done
# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
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
Expand Down
22 changes: 12 additions & 10 deletions gradlew.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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 ##########################################################################
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion src/main/java/com/saasquatch/sdk/auth/AuthMethod.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ static AuthMethod noAuth() {
/**
* Basic authentication with username and password
*/
@Beta
static AuthMethod ofBasic(@Nonnull String username, @Nonnull String password) {
return new BasicAuth(Objects.requireNonNull(username, "username"),
Objects.requireNonNull(password, "password"));
Expand Down
5 changes: 3 additions & 2 deletions src/main/java/com/saasquatch/sdk/auth/BasicAuth.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.saasquatch.sdk.auth;

import static java.nio.charset.StandardCharsets.UTF_8;
import org.apache.commons.codec.binary.Base64;

import net.iharder.Base64;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
import org.apache.hc.core5.http.HttpHeaders;

Expand All @@ -18,7 +19,7 @@ final class BasicAuth implements AuthMethod {
@Override
public void mutateRequest(SimpleRequestBuilder requestBuilder) {
requestBuilder.setHeader(HttpHeaders.AUTHORIZATION,
"Basic " + Base64.encodeBase64String((username + ':' + password).getBytes(UTF_8)));
"Basic " + Base64.encodeBytes((username + ':' + password).getBytes(UTF_8)));
}

}
51 changes: 34 additions & 17 deletions src/main/java/com/saasquatch/sdk/internal/InternalUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
import static com.saasquatch.sdk.internal.json.GsonUtils.gson;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import com.saasquatch.sdk.exceptions.SaaSquatchApiException;
import com.saasquatch.sdk.exceptions.SaaSquatchUnhandledApiException;
import com.saasquatch.sdk.input.UserIdInput;
Expand All @@ -20,6 +18,7 @@
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.util.AbstractMap.SimpleImmutableEntry;
Expand All @@ -42,8 +41,7 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.WillNotClose;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.net.URLCodec;
import net.iharder.Base64;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
Expand Down Expand Up @@ -244,7 +242,12 @@ public static <K, V> Map<K, V> unmodifiableMap(@Nonnull Map<K, V> map) {
* RFC3986 URL encode
*/
public static String urlEncode(@Nonnull String s) {
return new String(URLCodec.encodeUrl(RFC_3986_SAFE_CHARS, s.getBytes(UTF_8)), UTF_8);
try {
return URLEncoder.encode(s, UTF_8.name())
.replace("+", "%20").replace("*", "%2A").replace("%7E", "~");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e); // Won't happen
}
}

/**
Expand Down Expand Up @@ -362,13 +365,23 @@ public static Object getNestedMapValue(Map<String, Object> map, String... keys)
if (result == null) {
return null;
}
@SuppressWarnings("unchecked") final Map<String, Object> resultAsMap =
(Map<String, Object>) result;
result = resultAsMap.get(key);
//noinspection unchecked
result = ((Map<String, Object>) result).get(key);
}
return result;
}

public static String addBase64Padding(String base64) {
if (base64.length() % 4 == 0) {
return base64; // Avoid unnecessary copying with StringBuilder
}
final StringBuilder base64Builder = new StringBuilder(base64);
do {
base64Builder.append('=');
} while (base64Builder.length() % 4 != 0);
return base64Builder.toString();
}

/**
* Extract the payload as a JSON object. This method does NOT do a full JWT validation.
*/
Expand All @@ -378,15 +391,19 @@ public static Map<String, Object> getJwtPayload(String jwt) {
throw new IllegalArgumentException("Invalid JWT");
}
final String payloadPart = jwtParts[1];
// Do not use the overload that takes a String. It does not work on Android.
final byte[] payloadBytes = Base64.decodeBase64(payloadPart.getBytes(UTF_8));
final JsonElement jsonElement = JsonParser.parseString(new String(payloadBytes, UTF_8));
if (!(jsonElement instanceof JsonObject)) {
throw new IllegalArgumentException("JWT payload is not a JSON object");
final byte[] payloadBytes;
// This Base64 library expects the base64 string to have proper padding
try {
payloadBytes = Base64.decode(addBase64Padding(payloadPart), Base64.URL_SAFE);
} catch (IOException e) {
throw new IllegalArgumentException("Invalid JWT payload", e);
}
try {
//noinspection unchecked
return gson.fromJson(new String(payloadBytes, UTF_8), Map.class);
} catch (JsonSyntaxException e) {
throw new IllegalArgumentException("Invalid JWT payload", e);
}
@SuppressWarnings("unchecked") final Map<String, Object> payloadMap =
gson.fromJson(jsonElement, Map.class);
return payloadMap;
}

@Nonnull
Expand Down
24 changes: 24 additions & 0 deletions src/test/java/com/saasquatch/sdk/InternalUtilsTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.saasquatch.sdk;

import static com.saasquatch.sdk.internal.InternalUtils.addBase64Padding;
import static com.saasquatch.sdk.internal.InternalUtils.defaultIfNull;
import static com.saasquatch.sdk.internal.InternalUtils.entryOf;
import static com.saasquatch.sdk.internal.InternalUtils.getJwtPayload;
Expand All @@ -8,6 +9,7 @@
import static com.saasquatch.sdk.internal.InternalUtils.isBlank;
import static com.saasquatch.sdk.internal.InternalUtils.requireNotBlank;
import static com.saasquatch.sdk.internal.InternalUtils.unmodifiableList;
import static com.saasquatch.sdk.internal.InternalUtils.urlEncode;
import static com.saasquatch.sdk.internal.json.GsonUtils.gson;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
Expand Down Expand Up @@ -111,6 +113,11 @@ public void testUnmodifiableList() {
assertEquals("SingletonList", unmodifiableList(Arrays.asList(1)).getClass().getSimpleName());
}

@Test
public void testUrlEncode() throws Exception {
assertEquals("foo%20%2B%2A~bar", urlEncode("foo +*~bar"));
}

@Test
public void testRequireNotBlank() {
assertThrows(NullPointerException.class, () -> requireNotBlank(null, ""));
Expand Down Expand Up @@ -162,6 +169,16 @@ public void testGetNestedMapValue() {
}
}

@Test
public void testBase64Padding() {
assertEquals("", addBase64Padding(""));
assertEquals("a===", addBase64Padding("a"));
assertEquals("aa==", addBase64Padding("aa"));
assertEquals("aaa=", addBase64Padding("aaa"));
assertEquals("aaaa", addBase64Padding("aaaa"));
assertEquals("aaaaa===", addBase64Padding("aaaaa"));
}

@Test
public void testGetJwtPayload() {
//noinspection ConstantConditions
Expand All @@ -173,6 +190,13 @@ public void testGetJwtPayload() {
assertEquals(ImmutableMap.of(), getJwtPayload("a.e30.c"));
}

@Test
public void testGetJwtPayloadWithPadding() {
final Map<String, Object> map = assertDoesNotThrow(() -> getJwtPayload(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IklSWThGZHI2aXVpVzU2NTI1NTVyclk2TG9hUFVKQzg0WjEifQ.eyJ1c2VyIjp7ImVtYWlsIjoiZm9vQGV4YW1wbGUuY29tIiwiZmlyc3ROYW1lIjoiRklSU1ROQU1FIiwiaWQiOiJhYWEiLCJhY2NvdW50SWQiOiJhYWEiLCJsYXN0TmFtZSI6ImFhYSJ9fQ.6XoB_nYJy2Vged-Dm9yuf2X9t8XqfqZDQziby0jIDeY"));
assertEquals("FIRSTNAME", getNestedMapValue(map, "user", "firstName"));
}

@Test
public void testJwtToUserIdInput() {
final UserIdInput userIdInput = getUserIdInputFromUserJwt(
Expand Down