Skip to content

Commit

Permalink
feat(comp): SPI for compression.
Browse files Browse the repository at this point in the history
  • Loading branch information
nstdio committed Mar 20, 2022
1 parent 02f0608 commit 3eb9061
Show file tree
Hide file tree
Showing 27 changed files with 888 additions and 111 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ HttpRequest request = HttpRequest.newBuilder(uri)

HttpResponse<String> response = client.send(request, BodyHandlers.ofDecompressing(ofString()));
```
Out of the box support for `gzip` and `deflate` is provided by JDK itself. For `br` (brotli) compression please add
one of following dependencies to you project:

- [org.brotli:dec](https://mvnrepository.com/artifact/org.brotli/dec/0.1.2)
- [Brotli4j](https://github.com/hyperxpro/Brotli4j)

service loader will pick up correct dependency. If none of these preferred there is always an options to extend via [SPI](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ServiceLoader.html)
by providing [CompressionFactory](https://github.com/nstdio/http-client-ext/blob/main/src/main/java/io/github/nstdio/http/ext/spi/CompressionFactory.java)

### JSON
We are using [Jackson](https://github.com/FasterXML/jackson-databind) to create Java objects from JSON.
Expand Down
82 changes: 78 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import com.vdurmont.semver4j.Semver
import com.vdurmont.semver4j.Semver.SemverType
import io.github.nstdio.http.ext.ReadmeUpdateTask
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform

buildscript {
dependencies {
Expand All @@ -16,6 +17,7 @@ plugins {
id 'org.sonarqube' version '3.3'
id "io.github.gradle-nexus.publish-plugin" version "1.1.0"
id "net.researchgate.release" version "2.8.1"
id "de.jjohannes.extra-java-module-info"
}

group 'io.github.nstdio'
Expand All @@ -36,6 +38,38 @@ ext {
jsonPathAssertVersion = '2.7.0'
slf4jVersion = '1.7.36'
jacksonVersion = '2.13.2'
brotli4JVersion = "1.6.0"
brotliOrgVersion = "0.1.2"

spiDeps = ["org.brotli:dec:$brotliOrgVersion", "com.aayushatharva.brotli4j:brotli4j:$brotli4JVersion"]
}

java {
sourceSets {
spiTest {
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
java {}
}
}
}

configurations {
spiTestImplementation.extendsFrom testImplementation
spiTestRuntimeOnly.extendsFrom testRuntimeOnly

testRuntimeClasspath {
attributes { attribute(Attribute.of("javaModule", Boolean), false) }
}
testCompileClasspath {
attributes { attribute(Attribute.of("javaModule", Boolean), false) }
}
spiTestRuntimeClasspath {
attributes { attribute(Attribute.of("javaModule", Boolean), false) }
}
spiTestCompileClasspath {
attributes { attribute(Attribute.of("javaModule", Boolean), false) }
}
}

dependencies {
Expand All @@ -44,6 +78,8 @@ dependencies {
compileOnly 'org.projectlombok:lombok:1.18.22'
annotationProcessor 'org.projectlombok:lombok:1.18.22'

compileOnly spiDeps

/** AssertJ & Friends */
testImplementation "org.assertj:assertj-core:$assertJVersion"
testImplementation "com.jayway.jsonpath:json-path-assert:$jsonPathAssertVersion"
Expand All @@ -59,8 +95,20 @@ dependencies {
testImplementation 'org.mockito:mockito-core:4.4.0'

testImplementation "com.github.tomakehurst:wiremock-jre8:2.32.0"

testImplementation 'com.tngtech.archunit:archunit-junit5:0.23.1'

spiTestImplementation spiDeps
spiTestImplementation("com.aayushatharva.brotli4j:native-${getArch()}:$brotli4JVersion")
}

static def getArch() {
def operatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()

if (operatingSystem.isWindows()) "windows-x86_64"
else if (operatingSystem.isMacOsX()) "osx-x86_64"
else if (operatingSystem.isLinux())
if (DefaultNativePlatform.getCurrentArchitecture().isArm()) "linux-aarch64"
else "linux-x86_64"
}

sonarqube {
Expand Down Expand Up @@ -129,6 +177,8 @@ nexusPublishing {
}

signing {
required { !version.endsWith("SNAPSHOT") }

def signingKey = findProperty("signingKey")
def signingPassword = findProperty("signingPassword")
useInMemoryPgpKeys(signingKey, signingPassword)
Expand All @@ -151,8 +201,9 @@ afterEvaluate {
afterReleaseBuild.dependsOn publishToSonatype, closeAndReleaseSonatypeStagingRepository, updateReadme
}

test {
tasks.withType(Test) {
useJUnitPlatform()

testLogging {
events "skipped", "failed"
exceptionFormat "full"
Expand All @@ -161,12 +212,35 @@ test {
finalizedBy jacocoTestReport
}

task spiTest(type: Test) {
description = "Run SPI tests"
group = "verification"
testClassesDirs = sourceSets.spiTest.output.classesDirs
classpath = sourceSets.spiTest.runtimeClasspath
}

check.dependsOn spiTest
build.dependsOn spiTest

extraJavaModuleInfo {
module("brotli4j-${brotli4JVersion}.jar", 'com.aayushatharva.brotli4j', brotli4JVersion) {
exports('com.aayushatharva.brotli4j')
exports('com.aayushatharva.brotli4j.common')
exports('com.aayushatharva.brotli4j.decoder')
exports('com.aayushatharva.brotli4j.encoder')
}

module("dec-${brotliOrgVersion}.jar", 'org.brotli.dec', brotliOrgVersion) {
exports('org.brotli.dec')
}
}

jacocoTestReport {
reports {
xml.required = isCI
}

dependsOn test
dependsOn test, spiTest
}

jacoco {
Expand All @@ -187,7 +261,7 @@ task checkGradleVersion {
}

wrapper {
gradleVersion = '7.4'
gradleVersion = '7.4.1'
}

if (hasProperty('buildScan')) {
Expand Down
7 changes: 7 additions & 0 deletions buildSrc/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
repositories {
gradlePluginPortal()
}

dependencies {
implementation("de.jjohannes.gradle:extra-java-module-info:0.11")
}
13 changes: 13 additions & 0 deletions src/main/java/io/github/nstdio/http/ext/ByteBufferInputStream.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.Objects;

class ByteBufferInputStream extends InputStream {
Expand Down Expand Up @@ -187,4 +188,16 @@ void add(ByteBuffer b) {
}
}
}

List<ByteBuffer> drainToList() {
var buffs = buffers;
if (buffs.isEmpty()) {
return List.of();
}

var l = List.copyOf(buffs);
buffs.clear();

return l;
}
}
62 changes: 62 additions & 0 deletions src/main/java/io/github/nstdio/http/ext/CompressionFactories.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright (C) 2022 Edgar Asatryan
*
* 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 io.github.nstdio.http.ext;

import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.collectingAndThen;
import static java.util.stream.Collectors.toCollection;

import io.github.nstdio.http.ext.spi.CompressionFactory;

import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ServiceLoader;
import java.util.ServiceLoader.Provider;
import java.util.stream.Stream;

class CompressionFactories {
private static final ServiceLoader<CompressionFactory> loader = ServiceLoader.load(CompressionFactory.class);
private static final List<String> allSupported;
private static final String SPI_PACKAGE = "io.github.nstdio.http.ext.spi";
static final Comparator<String> USERS_FIRST_COMPARATOR = comparingInt(o -> o.startsWith(SPI_PACKAGE) ? 1 : 0);
private static final Comparator<Provider<CompressionFactory>> PROVIDER_COMPARATOR = (o1, o2) -> USERS_FIRST_COMPARATOR.compare(o1.type().getName(), o2.type().getName());

static {
allSupported = factory()
.flatMap(factory -> factory.supported().stream())
.collect(collectingAndThen(toCollection(LinkedHashSet::new), List::copyOf));
}

private CompressionFactories() {
}

static CompressionFactory firstSupporting(String directive) {
return factory()
.filter(factory -> factory.supported().contains(directive))
.findFirst()
.orElse(null);
}

private static Stream<CompressionFactory> factory() {
return loader.stream().sorted(PROVIDER_COMPARATOR).map(Provider::get);
}

static List<String> allSupported() {
return allSupported;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,19 @@ public <T> Chain<T> intercept(Chain<T> in) {
}

private HttpRequest preProcessRequest(HttpRequest request) {
var supported = CompressionFactories.allSupported()
.stream()
.filter(not("identity"::equals))
.filter(not("x-gzip"::equals))
.filter(not("x-compress"::equals))
.collect(joining(","));

if (supported.isBlank()) {
return request;
}

HttpRequest.Builder builder = toBuilder(request);
builder.setHeader("Accept-Encoding", "gzip,deflate");
builder.setHeader("Accept-Encoding", supported);

return builder.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.function.UnaryOperator;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;

class DecompressingBodyHandler<T> implements BodyHandler<T> {
private static final String UNSUPPORTED_DIRECTIVE = "Compression directive '%s' is not supported";
private static final String UNKNOWN_DIRECTIVE = "Unknown compression directive '%s'";
private static final UnaryOperator<InputStream> IDENTITY = UnaryOperator.identity();
private static final Set<String> WELL_KNOWN_DIRECTIVES = Set.of("gzip", "x-gzip", "br", "compress", "deflate", "identity");

private final BodyHandler<T> original;
private final Options options;
private final boolean direct;
Expand All @@ -67,31 +68,28 @@ private UnaryOperator<InputStream> chain(UnaryOperator<InputStream> u1, UnaryOpe
}

UnaryOperator<InputStream> decompressionFn(String directive) {
switch (directive) {
case "x-gzip":
case "gzip":
return in -> {
try {
return new GZIPInputStream(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
case "deflate":
return InflaterInputStream::new;
case "compress":
case "br":
if (options.failOnUnsupportedDirectives()) {
throw new UnsupportedOperationException(String.format(UNSUPPORTED_DIRECTIVE, directive));
}
return IDENTITY;
default:
if (options.failOnUnknownDirectives()) {
throw new IllegalArgumentException(String.format(UNKNOWN_DIRECTIVE, directive));
var factory = CompressionFactories.firstSupporting(directive);

if (factory != null) {
return in -> {
try {
return factory.decompressing(in, directive);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}

return IDENTITY;
boolean wellKnown = WELL_KNOWN_DIRECTIVES.contains(directive);
if (wellKnown && options.failOnUnsupportedDirectives()) {
throw new UnsupportedOperationException(String.format(UNSUPPORTED_DIRECTIVE, directive));
}

if (!wellKnown && options.failOnUnknownDirectives()) {
throw new IllegalArgumentException(String.format(UNKNOWN_DIRECTIVE, directive));
}

return IDENTITY;
}

@Override
Expand Down
Loading

0 comments on commit 3eb9061

Please sign in to comment.