Skip to content

Commit 79a9799

Browse files
committed
feat: Publishing JSON body.
1 parent 7a772aa commit 79a9799

File tree

15 files changed

+462
-30
lines changed

15 files changed

+462
-30
lines changed

buildSrc/src/main/kotlin/io.github.nstdio.http.ext.test-conventions.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ val brotli4JVersion = "1.8.0"
8181
val brotliOrgVersion = "0.1.2"
8282
val gsonVersion = "2.9.1"
8383
val equalsverifierVersion = "3.10.1"
84+
val coroutinesVersion = "1.6.4"
8485

8586
val jsonLibs = mapOf(
8687
"jackson" to "com.fasterxml.jackson.core",
@@ -97,6 +98,8 @@ val spiDeps = listOf(
9798
dependencies {
9899
spiDeps.forEach { compileOnly(it) }
99100

101+
testImplementation(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutinesVersion"))
102+
100103
/** AssertJ & Friends */
101104
testImplementation("org.assertj:assertj-core:$assertJVersion")
102105
testImplementation("io.kotest:kotest-assertions-core:$kotestAssertionsVersion")
@@ -116,6 +119,9 @@ dependencies {
116119
testImplementation("nl.jqno.equalsverifier:equalsverifier:$equalsverifierVersion")
117120
testImplementation("com.tngtech.archunit:archunit-junit5:1.0.0-rc1")
118121

122+
/** Kotlin Coroutines */
123+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
124+
119125
spiDeps.forEach { spiTestImplementation(it) }
120126
spiTestImplementation("com.aayushatharva.brotli4j:native-${getArch()}:$brotli4JVersion")
121127
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright (C) 2022 Edgar Asatryan
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.github.nstdio.http.ext;
18+
19+
import io.github.nstdio.http.ext.spi.JsonMappingProvider;
20+
21+
import java.io.ByteArrayOutputStream;
22+
import java.net.http.HttpRequest.BodyPublisher;
23+
import java.nio.ByteBuffer;
24+
import java.util.Optional;
25+
import java.util.concurrent.Executor;
26+
import java.util.concurrent.Flow;
27+
import java.util.concurrent.ForkJoinPool;
28+
import java.util.function.Supplier;
29+
30+
/**
31+
* Implementations of various useful {@link BodyPublisher}s.
32+
*/
33+
public final class BodyPublishers {
34+
private BodyPublishers() {
35+
}
36+
37+
/**
38+
* Returns a request body publisher whose body is JSON representation of {@code body}. The conversion will be done
39+
* using {@code JsonMappingProvider} default provider retrieved using {@link JsonMappingProvider#provider()}.
40+
*
41+
* @param body The body.
42+
*
43+
* @return a BodyPublisher
44+
*/
45+
public static BodyPublisher ofJson(Object body) {
46+
return ofJson(body, JsonMappingProvider.provider());
47+
}
48+
49+
/**
50+
* Returns a request body publisher whose body is JSON representation of {@code body}. The conversion will be done
51+
* using {@code jsonProvider}.
52+
*
53+
* @param body The body.
54+
* @param jsonProvider The JSON mapping provider to use when creating JSON presentation of {@code body}.
55+
*
56+
* @return a BodyPublisher
57+
*/
58+
public static BodyPublisher ofJson(Object body, JsonMappingProvider jsonProvider) {
59+
return ofJson(body, jsonProvider, null);
60+
}
61+
62+
/**
63+
* Returns a request body publisher whose body is JSON representation of {@code body}. The conversion will be done
64+
* using {@code JsonMappingProvider} default provider retrieved using {@link JsonMappingProvider#provider()}.
65+
*
66+
* @param body The body.
67+
* @param executor The scheduler to use to publish body to subscriber. If {@code null} the *
68+
* {@link ForkJoinPool#commonPool()} will be used.
69+
*
70+
* @return a BodyPublisher
71+
*/
72+
public static BodyPublisher ofJson(Object body, Executor executor) {
73+
return ofJson(body, JsonMappingProvider.provider(), executor);
74+
}
75+
76+
/**
77+
* Returns a request body publisher whose body is JSON representation of {@code body}. The conversion will be done *
78+
* using {@code jsonProvider}.
79+
*
80+
* @param body The body.
81+
* @param jsonProvider The JSON mapping provider to use when creating JSON presentation of {@code body}.
82+
* @param executor The scheduler to use to publish body to subscriber. If {@code null} the
83+
* {@link ForkJoinPool#commonPool()} will be used.
84+
*
85+
* @return a BodyPublisher
86+
*/
87+
public static BodyPublisher ofJson(Object body, JsonMappingProvider jsonProvider, Executor executor) {
88+
return new JsonPublisher(body, jsonProvider, Optional.ofNullable(executor).orElseGet(ForkJoinPool::commonPool));
89+
}
90+
91+
/**
92+
* The {@code BodyPublisher} that converts objects to JSON.
93+
*/
94+
static final class JsonPublisher implements BodyPublisher {
95+
private final Object body;
96+
private final JsonMappingProvider provider;
97+
private final Executor executor;
98+
99+
JsonPublisher(Object body, JsonMappingProvider provider, Executor executor) {
100+
this.body = body;
101+
this.provider = provider;
102+
this.executor = executor;
103+
}
104+
105+
@Override
106+
public void subscribe(Flow.Subscriber<? super ByteBuffer> subscriber) {
107+
var subscription = ByteArraySubscription.ofByteBuffer(subscriber, bytesSupplier(), executor);
108+
109+
subscriber.onSubscribe(subscription);
110+
}
111+
112+
private Supplier<byte[]> bytesSupplier() {
113+
return () -> {
114+
var os = new ByteArrayOutputStream();
115+
try {
116+
provider.get().write(body, os);
117+
} catch (Throwable e) {
118+
Throwables.sneakyThrow(e);
119+
}
120+
121+
return os.toByteArray();
122+
};
123+
}
124+
125+
@Override
126+
public long contentLength() {
127+
return -1;
128+
}
129+
}
130+
131+
}

src/main/java/io/github/nstdio/http/ext/ByteArraySubscription.java

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,42 +18,84 @@
1818

1919
import java.nio.ByteBuffer;
2020
import java.util.List;
21+
import java.util.concurrent.Executor;
22+
import java.util.concurrent.ExecutorService;
2123
import java.util.concurrent.Flow.Subscriber;
2224
import java.util.concurrent.Flow.Subscription;
25+
import java.util.concurrent.Future;
2326
import java.util.concurrent.atomic.AtomicBoolean;
27+
import java.util.function.Function;
28+
import java.util.function.Supplier;
29+
30+
class ByteArraySubscription<T> implements Subscription {
31+
private final Subscriber<T> subscriber;
32+
private final Executor executor;
33+
34+
private final Function<byte[], T> mapper;
35+
private final Supplier<byte[]> bytes;
2436

25-
class ByteArraySubscription implements Subscription {
26-
private final Subscriber<List<ByteBuffer>> subscriber;
2737
private final AtomicBoolean completed = new AtomicBoolean(false);
28-
private final byte[] bytes;
38+
private Future<?> result;
2939

30-
ByteArraySubscription(Subscriber<List<ByteBuffer>> subscriber, byte[] bytes) {
40+
ByteArraySubscription(Subscriber<T> subscriber, Executor executor, Supplier<byte[]> bytes, Function<byte[], T> mapper) {
3141
this.subscriber = subscriber;
42+
this.executor = executor;
3243
this.bytes = bytes;
44+
this.mapper = mapper;
45+
}
46+
47+
static ByteArraySubscription<List<ByteBuffer>> ofByteBufferList(Subscriber<List<ByteBuffer>> subscriber, byte[] bytes) {
48+
return new ByteArraySubscription<>(subscriber, DirectExecutor.INSTANCE, () -> bytes, o -> List.of(ByteBuffer.wrap(o).asReadOnlyBuffer()));
49+
}
50+
51+
static ByteArraySubscription<? super ByteBuffer> ofByteBuffer(Subscriber<? super ByteBuffer> subscriber, Supplier<byte[]> bytes, Executor executor) {
52+
return new ByteArraySubscription<>(subscriber, executor, bytes, ByteBuffer::wrap);
3353
}
3454

3555
@Override
3656
public void request(long n) {
37-
if (completed.get()) {
38-
return;
39-
}
57+
if (!completed.getAndSet(true)) {
58+
if (n > 0) {
59+
submit(() -> {
60+
try {
61+
T item = mapper.apply(bytes.get());
4062

41-
if (n <= 0) {
42-
subscriber.onError(new IllegalArgumentException("n <= 0"));
43-
return;
63+
subscriber.onNext(item);
64+
subscriber.onComplete();
65+
} catch (Throwable th) {
66+
subscriber.onError(th);
67+
}
68+
});
69+
} else {
70+
var e = new IllegalArgumentException("n <= 0");
71+
submit(() -> subscriber.onError(e));
72+
}
4473
}
74+
}
4575

76+
@Override
77+
public void cancel() {
4678
completed.set(true);
4779

48-
ByteBuffer buffer = ByteBuffer.wrap(bytes).asReadOnlyBuffer();
49-
List<ByteBuffer> item = List.of(buffer);
80+
if (result != null) {
81+
result.cancel(false);
82+
}
83+
}
5084

51-
subscriber.onNext(item);
52-
subscriber.onComplete();
85+
private void submit(Runnable r) {
86+
if (executor instanceof ExecutorService) {
87+
result = ((ExecutorService) executor).submit(r);
88+
} else {
89+
executor.execute(r);
90+
}
5391
}
5492

55-
@Override
56-
public void cancel() {
57-
completed.set(true);
93+
private enum DirectExecutor implements Executor {
94+
INSTANCE;
95+
96+
@Override
97+
public void execute(Runnable command) {
98+
command.run();
99+
}
58100
}
59101
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (C) 2022 Edgar Asatryan
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.github.nstdio.http.ext;
18+
19+
import io.github.nstdio.http.ext.BodyPublishers.JsonPublisher;
20+
21+
import java.net.http.HttpRequest.BodyPublisher;
22+
import java.util.Map;
23+
import java.util.Optional;
24+
25+
import static io.github.nstdio.http.ext.Headers.HEADER_CONTENT_TYPE;
26+
import static java.util.function.Predicate.not;
27+
28+
class ContentTypeInterceptor implements Interceptor {
29+
private final Interceptor headersAdding;
30+
31+
ContentTypeInterceptor(String contentType) {
32+
headersAdding = new HeadersAddingInterceptor(Map.of(HEADER_CONTENT_TYPE, contentType));
33+
}
34+
35+
@Override
36+
public <T> Chain<T> intercept(Chain<T> in) {
37+
if (!isJsonPublisher(in.request().bodyPublisher())) {
38+
return in;
39+
}
40+
41+
return headersAdding.intercept(in);
42+
}
43+
44+
private static boolean isJsonPublisher(Optional<BodyPublisher> bodyPublisher) {
45+
return bodyPublisher.filter(not(JsonPublisher.class::isInstance)).isEmpty();
46+
}
47+
}

src/main/java/io/github/nstdio/http/ext/ExtendedHttpClient.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public class ExtendedHttpClient extends HttpClient {
4747
private final CompressionInterceptor compressionInterceptor;
4848
private final CachingInterceptor cachingInterceptor;
4949
private final HeadersAddingInterceptor headersAddingInterceptor;
50+
private final ContentTypeInterceptor contentTypeInterceptor;
5051

5152
private final HttpClient delegate;
5253
private final boolean allowInsecure;
@@ -68,6 +69,7 @@ private ExtendedHttpClient(CompressionInterceptor compressionInterceptor,
6869
this.compressionInterceptor = compressionInterceptor;
6970
this.cachingInterceptor = cachingInterceptor;
7071
this.headersAddingInterceptor = headersAddingInterceptor;
72+
this.contentTypeInterceptor = new ContentTypeInterceptor("application/json");
7173
this.delegate = delegate;
7274
this.allowInsecure = allowInsecure;
7375
}
@@ -188,6 +190,7 @@ private <T> Chain<T> buildAndExecute(RequestContext ctx) {
188190
chain = possiblyApply(compressionInterceptor, chain);
189191
chain = possiblyApply(cachingInterceptor, chain);
190192
chain = possiblyApply(headersAddingInterceptor, chain);
193+
chain = possiblyApply(contentTypeInterceptor, chain);
191194

192195
return chain;
193196
}

src/main/java/io/github/nstdio/http/ext/Headers.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Headers {
4141
static final String HEADER_VARY = "Vary";
4242
static final String HEADER_CONTENT_ENCODING = "Content-Encoding";
4343
static final String HEADER_CONTENT_LENGTH = "Content-Length";
44+
static final String HEADER_CONTENT_TYPE = "Content-Type";
4445
static final String HEADER_IF_MODIFIED_SINCE = "If-Modified-Since";
4546
static final String HEADER_IF_NONE_MATCH = "If-None-Match";
4647
static final String HEADER_CACHE_CONTROL = "Cache-Control";

src/main/java/io/github/nstdio/http/ext/HeadersAddingInterceptor.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ class HeadersAddingInterceptor implements Interceptor {
3030
this.headers = headers;
3131
this.resolvableHeaders = resolvableHeaders;
3232
}
33+
34+
HeadersAddingInterceptor(Map<String, String> headers) {
35+
this(headers, Map.of());
36+
}
3337

3438
@Override
3539
public <T> Chain<T> intercept(Chain<T> in) {

src/main/java/io/github/nstdio/http/ext/InMemoryCache.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public long bodySize() {
8989

9090
@Override
9191
public void subscribeTo(Flow.Subscriber<List<ByteBuffer>> sub) {
92-
Flow.Subscription subscription = new ByteArraySubscription(sub, body);
92+
Flow.Subscription subscription = ByteArraySubscription.ofByteBufferList(sub, body);
9393
sub.onSubscribe(subscription);
9494
}
9595

src/main/java/io/github/nstdio/http/ext/spi/GsonJsonMapping.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import java.io.IOException;
2424
import java.io.InputStream;
2525
import java.io.InputStreamReader;
26+
import java.io.OutputStream;
27+
import java.io.OutputStreamWriter;
2628
import java.lang.reflect.Type;
2729
import java.util.Objects;
2830

@@ -67,4 +69,11 @@ public <T> T read(byte[] bytes, Class<T> targetType) throws IOException {
6769
public <T> T read(byte[] bytes, Type targetType) throws IOException {
6870
return read(new ByteArrayInputStream(bytes), targetType);
6971
}
72+
73+
@Override
74+
public void write(Object o, OutputStream os) throws IOException {
75+
try (var writer = new OutputStreamWriter(os, UTF_8)) {
76+
gson.toJson(o, writer);
77+
}
78+
}
7079
}

0 commit comments

Comments
 (0)