Skip to content

Commit d27c904

Browse files
committed
Rework client impl to handle errors
1 parent aba85da commit d27c904

File tree

5 files changed

+112
-67
lines changed

5 files changed

+112
-67
lines changed

rest/.jvm/src/test/resources/StreamingRestTestApi.json

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,38 @@
66
"description": "Some test REST API"
77
},
88
"paths": {
9+
"/binaryStream": {
10+
"post": {
11+
"operationId": "binaryStream",
12+
"responses": {
13+
"200": {
14+
"description": "Success",
15+
"content": {
16+
"application/octet-stream": {
17+
"schema": {
18+
"type": "string",
19+
"format": "binary"
20+
}
21+
}
22+
}
23+
}
24+
}
25+
}
26+
},
927
"/errorStream": {
1028
"post": {
1129
"operationId": "errorStream",
12-
"requestBody": {
13-
"content": {
14-
"application/json": {
15-
"schema": {
16-
"type": "object",
17-
"properties": {
18-
"immediate": {
19-
"type": "boolean"
20-
}
21-
},
22-
"required": [
23-
"immediate"
24-
]
25-
}
30+
"parameters": [
31+
{
32+
"name": "immediate",
33+
"in": "query",
34+
"required": true,
35+
"explode": false,
36+
"schema": {
37+
"type": "boolean"
2638
}
27-
},
28-
"required": true
29-
},
39+
}
40+
],
3041
"responses": {
3142
"200": {
3243
"description": "Success",

rest/jetty/src/main/scala/io/udash/rest/jetty/JettyRestClient.scala

Lines changed: 59 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import io.udash.rest.raw.*
88
import io.udash.rest.util.Utils
99
import io.udash.utils.URLEncoder
1010
import monix.eval.Task
11-
import monix.execution.Callback
12-
import monix.reactive.Observable
11+
import monix.execution.{Callback, Scheduler}
12+
import monix.reactive.OverflowStrategy.Unbounded
13+
import monix.reactive.{MulticastStrategy, Observable}
14+
import monix.reactive.subjects.{ConcurrentSubject, PublishToOneSubject}
1315
import org.eclipse.jetty.client.*
1416
import org.eclipse.jetty.http.{HttpCookie, HttpHeader, MimeTypes}
17+
import org.eclipse.jetty.io.Content
1518

1619
import java.nio.charset.Charset
1720
import scala.concurrent.CancellationException
@@ -48,36 +51,35 @@ final class JettyRestClient(
4851

4952
override def handleRequestStream(request: RestRequest): Task[StreamedRestResponse] =
5053
prepareRequest(baseUrl, timeout, request).flatMap { httpReq =>
51-
Task.async { (callback: Callback[Throwable, StreamedRestResponse]) =>
52-
val listener = new InputStreamResponseListener {
54+
Task.async0 { (scheduler: Scheduler, callback: Callback[Throwable, StreamedRestResponse]) =>
55+
val listener = new BufferingResponseListener(maxResponseLength) {
56+
private var collectToBuffer: Boolean = true
57+
private lazy val publishSubject = PublishToOneSubject[Array[Byte]]()
58+
private lazy val rawContentSubject = ConcurrentSubject.from(publishSubject, Unbounded)(scheduler)
59+
5360
override def onHeaders(response: Response): Unit = {
5461
super.onHeaders(response)
5562
// TODO streaming document content length behaviour
5663
val contentLength = response.getHeaders.getLongField(HttpHeader.CONTENT_LENGTH)
5764
if (contentLength == -1) {
5865
val contentTypeOpt = response.getHeaders.get(HttpHeader.CONTENT_TYPE).opt
5966
val mediaTypeOpt = contentTypeOpt.map(MimeTypes.getContentTypeWithoutCharset)
60-
val charsetOpt = contentTypeOpt.map(MimeTypes.getCharsetFromContentType)
61-
// TODO streaming error handling client-side ???
6267
val bodyOpt = mediaTypeOpt matchOpt {
6368
case Opt(HttpBody.OctetStreamType) =>
64-
// TODO streaming configure chunk size ???
65-
StreamedBody.RawBinary(Observable.fromInputStream(Task.eval(getInputStream)))
69+
StreamedBody.RawBinary(content = rawContentSubject)
6670
case Opt(HttpBody.JsonType) =>
67-
val charset = charsetOpt.getOrElse(HttpBody.Utf8Charset)
71+
val charset = contentTypeOpt.map(MimeTypes.getCharsetFromContentType).getOrElse(HttpBody.Utf8Charset)
6872
// suboptimal - maybe "online" parsing is possible using Jackson / other lib without waiting for full content ?
69-
val elements: Observable[JsonValue] =
70-
Observable
71-
.fromTask(Utils.mergeArrays(Observable.fromInputStream(Task.eval(getInputStream))))
73+
StreamedBody.JsonList(
74+
elements = Observable
75+
.fromTask(Utils.mergeArrays(rawContentSubject))
7276
.map(raw => new String(raw, charset))
7377
.flatMap { jsonStr =>
7478
val input = new JsonStringInput(new JsonReader(jsonStr))
7579
Observable
7680
.fromIterator(Task.eval(input.readList().iterator(_.asInstanceOf[JsonStringInput].readRawJson())))
7781
.map(JsonValue(_))
78-
}
79-
StreamedBody.JsonList(
80-
elements = elements,
82+
},
8183
charset = charset,
8284
)
8385
}
@@ -87,6 +89,7 @@ final class JettyRestClient(
8789
callback(Failure(new Exception(s"Unsupported content type $contentTypeOpt")))
8890
},
8991
body => {
92+
this.collectToBuffer = false
9093
val restResponse = StreamedRestResponse(
9194
code = response.getStatus,
9295
headers = parseHeaders(response),
@@ -99,42 +102,40 @@ final class JettyRestClient(
99102
}
100103
}
101104

102-
override def onComplete(result: Result): Unit = {
103-
super.onComplete(result)
105+
override def onContent(response: Response, chunk: Content.Chunk, demander: Runnable): Unit =
106+
if (collectToBuffer)
107+
super.onContent(response, chunk, demander)
108+
else
109+
if (chunk == Content.Chunk.EOF) {
110+
rawContentSubject.onComplete()
111+
} else {
112+
val buf = chunk.getByteBuffer
113+
val arr = new Array[Byte](buf.remaining)
114+
buf.get(arr)
115+
publishSubject.subscription // wait for subscription
116+
.flatMapNow(_ => rawContentSubject.onNext(arr))
117+
.mapNow(_ => demander.run())
118+
}
119+
120+
override def onComplete(result: Result): Unit =
104121
if (result.isSucceeded) {
105122
val httpResp = result.getResponse
106123
val contentLength = httpResp.getHeaders.getLongField(HttpHeader.CONTENT_LENGTH)
107124
if (contentLength != -1) {
108-
val contentTypeOpt = httpResp.getHeaders.get(HttpHeader.CONTENT_TYPE).opt
109-
val charsetOpt = contentTypeOpt.map(MimeTypes.getCharsetFromContentType)
110125
// TODO streaming client-side handle errors ?
111-
val rawBody = getInputStream.readAllBytes()
112-
val body = (contentTypeOpt, charsetOpt) match {
113-
case (Opt(contentType), Opt(charset)) =>
114-
StreamedBody.fromHttpBody(
115-
HttpBody.textual(
116-
content = new String(rawBody, charset),
117-
mediaType = MimeTypes.getContentTypeWithoutCharset(contentType),
118-
charset = charset,
119-
)
120-
)
121-
case (Opt(contentType), Opt.Empty) =>
122-
StreamedBody.fromHttpBody(HttpBody.binary(rawBody, contentType))
123-
case _ =>
124-
StreamedBody.Empty
125-
}
126126
val restResponse = StreamedRestResponse(
127127
code = httpResp.getStatus,
128128
headers = parseHeaders(httpResp),
129-
body = body,
129+
body = StreamedBody.fromHttpBody(parseHttpBody(httpResp, this)),
130130
batchSize = 1,
131131
)
132132
callback(Success(restResponse))
133+
} else {
134+
rawContentSubject.onComplete()
133135
}
134136
} else {
135137
callback(Failure(result.getFailure))
136138
}
137-
}
138139
}
139140
httpReq.send(listener)
140141
}.doOnCancel(Task(httpReq.abort(new CancellationException("Request cancelled"))))
@@ -193,17 +194,11 @@ final class JettyRestClient(
193194
override def onComplete(result: Result): Unit =
194195
if (result.isSucceeded) {
195196
val httpResp = result.getResponse
196-
val contentTypeOpt = httpResp.getHeaders.get(HttpHeader.CONTENT_TYPE).opt
197-
val charsetOpt = contentTypeOpt.map(MimeTypes.getCharsetFromContentType)
198-
val body = (contentTypeOpt, charsetOpt) match {
199-
case (Opt(contentType), Opt(charset)) =>
200-
HttpBody.textual(getContentAsString, MimeTypes.getContentTypeWithoutCharset(contentType), charset)
201-
case (Opt(contentType), Opt.Empty) =>
202-
HttpBody.binary(getContent, contentType)
203-
case _ =>
204-
HttpBody.Empty
205-
}
206-
val response = RestResponse(httpResp.getStatus, parseHeaders(httpResp), body)
197+
val response = RestResponse(
198+
code = httpResp.getStatus,
199+
headers = parseHeaders(httpResp),
200+
body = parseHttpBody(httpResp, this),
201+
)
207202
callback(Success(response))
208203
} else {
209204
callback(Failure(result.getFailure))
@@ -212,6 +207,23 @@ final class JettyRestClient(
212207
}
213208
.doOnCancel(Task(httpReq.abort(new CancellationException("Request cancelled"))))
214209

210+
private def parseHttpBody(httpResp: Response, listener: BufferingResponseListener): HttpBody = {
211+
val contentTypeOpt = httpResp.getHeaders.get(HttpHeader.CONTENT_TYPE).opt
212+
val charsetOpt = contentTypeOpt.map(MimeTypes.getCharsetFromContentType)
213+
(contentTypeOpt, charsetOpt) match {
214+
case (Opt(contentType), Opt(charset)) =>
215+
HttpBody.textual(
216+
content = listener.getContentAsString,
217+
mediaType = MimeTypes.getContentTypeWithoutCharset(contentType),
218+
charset = charset,
219+
)
220+
case (Opt(contentType), Opt.Empty) =>
221+
HttpBody.binary(listener.getContent, contentType)
222+
case _ =>
223+
HttpBody.Empty
224+
}
225+
}
226+
215227
private def parseHeaders(httpResp: Response): IMapping[PlainValue] =
216228
IMapping(httpResp.getHeaders.asScala.iterator.map(h => (h.getName, PlainValue(h.getValue))).toList)
217229
}

rest/jetty/src/test/scala/io/udash/rest/jetty/JettyRestCallTest.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import io.udash.rest.{RestApiTestScenarios, ServletBasedRestApiTest, StreamingRe
66
import org.eclipse.jetty.client.HttpClient
77

88
final class JettyRestCallTest
9-
extends ServletBasedRestApiTest with RestApiTestScenarios with StreamingRestApiTestScenarios {
9+
extends ServletBasedRestApiTest
10+
with RestApiTestScenarios
11+
with StreamingRestApiTestScenarios {
1012

1113
/**
1214
* Similar to the default HttpClient, but with a connection timeout

rest/src/test/scala/io/udash/rest/RestApiTest.scala

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ abstract class RestApiTest extends AsyncUdashSharedTest with BeforeAndAfterEach
5555

5656
def testStream[T](call: StreamingRestTestApi => Observable[T])(implicit pos: Position): Future[Assertion] =
5757
(call(streamingProxy).toListL.materialize, call(streamingImpl).toListL.materialize).mapN { (proxyResult, implResult) =>
58-
assert(proxyResult.map(mkDeep) == implResult.map(mkDeep))
58+
assert(proxyResult.map(_.map(mkDeep)) == implResult.map(_.map(mkDeep)))
5959
}.runToFuture
6060

6161
def mkDeep(value: Any): Any = value match {
@@ -159,4 +159,17 @@ trait StreamingRestApiTestScenarios extends RestApiTest {
159159
"json GET stream" in {
160160
testStream(_.jsonStream)
161161
}
162+
163+
"binary stream" in {
164+
testStream(_.binaryStream())
165+
}
166+
167+
"immediate stream error" in {
168+
testStream(_.errorStream(immediate = true))
169+
}
170+
171+
// TODO streaming - does not work on client side
172+
"mid-stream error" ignore {
173+
testStream(_.errorStream(immediate = false))
174+
}
162175
}

rest/src/test/scala/io/udash/rest/StreamingRestTestApi.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import io.udash.rest.raw.HttpErrorException
55
import monix.execution.Scheduler
66
import monix.reactive.Observable
77

8+
import scala.concurrent.duration._
9+
810
trait StreamingRestTestApi {
911
@GET def simpleStream(size: Int): Observable[Int]
1012

1113
@GET def jsonStream: Observable[RestEntity]
1214

13-
@POST def errorStream(immediate: Boolean): Observable[RestEntity]
15+
@POST def binaryStream(): Observable[Array[Byte]]
16+
17+
@POST def errorStream(@Query immediate: Boolean): Observable[RestEntity]
1418
}
1519
object StreamingRestTestApi extends DefaultRestApiCompanion[StreamingRestTestApi] {
1620

@@ -27,6 +31,9 @@ object StreamingRestTestApi extends DefaultRestApiCompanion[StreamingRestTestApi
2731
RestEntity(RestEntityId("3"), "third")
2832
)
2933

34+
override def binaryStream(): Observable[Array[Byte]] =
35+
Observable("abc".getBytes, "xyz".getBytes)
36+
3037
override def errorStream(immediate: Boolean): Observable[RestEntity] =
3138
if (immediate)
3239
Observable.raiseError(HttpErrorException.plain(400, "bad"))

0 commit comments

Comments
 (0)