Skip to content

Commit 4472d79

Browse files
committed
feat: Add Scala Native support infrastructure
- Create platform abstraction layer (HttpBackend trait) - Extract JVM implementation into JvmHttpBackend using java.net.http.HttpClient - Add placeholder NativeHttpBackend for Scala Native - Refactor Requester to delegate to platform-specific backends - Configure build.mill for cross-compilation (JVM and Native) - Add platform-specific source directories (src-jvm, src-native) - Enable Scala Native 0.5.6 with scala-native-crypto dependency This provides the foundation for Scala Native support (#156). The Native backend implementation requires: - java.net.http.HttpClient for Scala Native - java.net.HttpCookie for Scala Native - Full javax.net.ssl.SSLContext support All JVM functionality remains backward compatible and fully functional.
1 parent dd17681 commit 4472d79

File tree

5 files changed

+476
-267
lines changed

5 files changed

+476
-267
lines changed

build.mill

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,21 @@ trait RequestsPublishModule extends PublishModule with MimaCheck {
4646

4747
trait RequestsCrossScalaModule extends CrossScalaModule with ScalaModule {
4848
def moduleDir = build.moduleDir / "requests"
49+
50+
// Common sources shared between JVM and Native
4951
def sources = Task.Sources("src")
5052
}
5153

54+
trait RequestsJvmSources extends RequestsCrossScalaModule {
55+
// JVM-specific sources
56+
override def sources = Task.Sources("src", "src-jvm")
57+
}
58+
59+
trait RequestsNativeSources extends RequestsCrossScalaModule {
60+
// Native-specific sources
61+
override def sources = Task.Sources("src", "src-native")
62+
}
63+
5264
trait RequestsTestModule extends TestModule.Utest {
5365
def mvnDeps = Seq(
5466
mvn"com.lihaoyi::utest::0.7.10",
@@ -58,18 +70,20 @@ trait RequestsTestModule extends TestModule.Utest {
5870
}
5971

6072
object requests extends Module {
61-
trait RequestsJvmModule extends RequestsCrossScalaModule with RequestsPublishModule {
73+
trait RequestsJvmModule extends RequestsJvmSources with RequestsPublishModule {
6274
object test extends ScalaTests with RequestsTestModule
6375
}
6476
object jvm extends Cross[RequestsJvmModule](scalaVersions)
6577

66-
// trait RequestsNativeModule extends ScalaNativeModule with RequestsPublishModule {
67-
// override def scalaNativeVersion = scalaNativeVer
68-
//
69-
// def mvnDeps =
70-
// super.mvnDeps() ++ Seq(mvn"com.github.lolgab::scala-native-crypto::0.1.0")
71-
//
72-
// object test extends ScalaNativeTests with RequestsTestModule
73-
// }
74-
// object native extends Cross[RequestsNativeModule](scalaVersions)
78+
trait RequestsNativeModule extends RequestsNativeSources with ScalaNativeModule with RequestsPublishModule {
79+
override def scalaNativeVersion = scalaNativeVer
80+
81+
def mvnDeps =
82+
super.mvnDeps() ++ Seq(
83+
mvn"com.github.lolgab::scala-native-crypto::0.1.0"
84+
)
85+
86+
object test extends ScalaNativeTests with RequestsTestModule
87+
}
88+
object native extends Cross[RequestsNativeModule](scalaVersions)
7589
}
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
package requests
2+
3+
import java.io._
4+
import java.net.http._
5+
import java.net._
6+
import java.nio.ByteBuffer
7+
import java.time.Duration
8+
import java.util.concurrent.Flow
9+
import java.util.function.Supplier
10+
import java.util.zip.{GZIPInputStream, InflaterInputStream}
11+
import javax.net.ssl.SSLContext
12+
13+
import scala.collection.JavaConverters._
14+
import scala.collection.immutable.ListMap
15+
import scala.concurrent.ExecutionException
16+
17+
/**
18+
* JVM implementation of HttpBackend using java.net.http.HttpClient (Java 11+).
19+
*/
20+
private[requests] class JvmHttpBackend extends HttpBackend {
21+
22+
def execute(
23+
verb: String,
24+
url: String,
25+
auth: RequestAuth,
26+
params: Iterable[(String, String)],
27+
headers: Map[String, String],
28+
data: RequestBlob,
29+
readTimeout: Int,
30+
connectTimeout: Int,
31+
proxy: (String, Int),
32+
cert: Cert,
33+
sslContext: SSLContext,
34+
cookies: Map[String, HttpCookie],
35+
cookieValues: Map[String, String],
36+
maxRedirects: Int,
37+
verifySslCerts: Boolean,
38+
autoDecompress: Boolean,
39+
compress: Compress,
40+
keepAlive: Boolean,
41+
check: Boolean,
42+
chunkedUpload: Boolean,
43+
redirectedFrom: Option[Response],
44+
onHeadersReceived: StreamHeaders => Unit,
45+
sess: BaseSession,
46+
): geny.Readable = new geny.Readable {
47+
def readBytesThrough[T](f: java.io.InputStream => T): T = {
48+
val upperCaseVerb = verb.toUpperCase
49+
val blobHeaders = data.headers
50+
51+
val url0 = new java.net.URL(url)
52+
53+
val url1 = if (params.nonEmpty) {
54+
val encodedParams = Util.urlEncode(params)
55+
val firstSep = if (url0.getQuery != null) "&" else "?"
56+
new java.net.URL(url + firstSep + encodedParams)
57+
} else url0
58+
59+
val httpClient: HttpClient =
60+
HttpClient
61+
.newBuilder()
62+
.followRedirects(HttpClient.Redirect.NEVER)
63+
.proxy(proxy match {
64+
case null => ProxySelector.getDefault
65+
case (ip, port) => ProxySelector.of(new InetSocketAddress(ip, port))
66+
})
67+
.sslContext(
68+
if (cert != null)
69+
Util.clientCertSSLContext(cert, verifySslCerts)
70+
else if (sslContext != null)
71+
sslContext
72+
else if (!verifySslCerts)
73+
Util.noVerifySSLContext
74+
else
75+
SSLContext.getDefault,
76+
)
77+
.connectTimeout(Duration.ofMillis(connectTimeout))
78+
.build()
79+
80+
val sessionCookieValues = for {
81+
c <- (sess.cookies ++ cookies).valuesIterator
82+
if !c.hasExpired
83+
if c.getDomain == null || c.getDomain == url1.getHost
84+
if c.getPath == null || url1.getPath.startsWith(c.getPath)
85+
} yield (c.getName, c.getValue)
86+
87+
val allCookies = sessionCookieValues ++ cookieValues
88+
89+
val (contentLengthHeader, otherBlobHeaders) =
90+
blobHeaders.partition(_._1.equalsIgnoreCase("Content-Length"))
91+
92+
val allHeaders =
93+
otherBlobHeaders ++
94+
headers ++
95+
compress.headers ++
96+
auth.header.map("Authorization" -> _) ++
97+
(if (allCookies.isEmpty) None
98+
else
99+
Some(
100+
"Cookie" -> allCookies
101+
.map { case (k, v) => s"""$k="$v"""" }
102+
.mkString("; "),
103+
))
104+
val lastOfEachHeader =
105+
allHeaders.foldLeft(ListMap.empty[String, (String, String)]) {
106+
case (acc, (k, v)) =>
107+
acc.updated(k.toLowerCase, k -> v)
108+
}
109+
val headersKeyValueAlternating = lastOfEachHeader.values.toList.flatMap {
110+
case (k, v) => Seq(k, v)
111+
}
112+
113+
val requestBodyInputStream = new PipedInputStream()
114+
val requestBodyOutputStream = new PipedOutputStream(requestBodyInputStream)
115+
116+
val bodyPublisher: HttpRequest.BodyPublisher =
117+
HttpRequest.BodyPublishers.ofInputStream(new Supplier[InputStream] {
118+
override def get() = requestBodyInputStream
119+
})
120+
121+
val requestBuilder =
122+
HttpRequest
123+
.newBuilder()
124+
.uri(url1.toURI)
125+
.timeout(Duration.ofMillis(readTimeout))
126+
.headers(headersKeyValueAlternating: _*)
127+
.method(
128+
upperCaseVerb,
129+
(contentLengthHeader.headOption.map(_._2), compress) match {
130+
case (Some("0"), _) => HttpRequest.BodyPublishers.noBody()
131+
case (Some(n), Compress.None) =>
132+
HttpRequest.BodyPublishers.fromPublisher(bodyPublisher, n.toInt)
133+
case _ => bodyPublisher
134+
},
135+
)
136+
137+
val fut = httpClient.sendAsync(
138+
requestBuilder.build(),
139+
HttpResponse.BodyHandlers.ofInputStream(),
140+
)
141+
142+
usingOutputStream(compress.wrap(requestBodyOutputStream)) { os => data.write(os) }
143+
144+
val response =
145+
try
146+
fut.get()
147+
catch {
148+
case e: ExecutionException =>
149+
throw e.getCause match {
150+
case e: javax.net.ssl.SSLHandshakeException => new InvalidCertException(url, e)
151+
case _: HttpConnectTimeoutException | _: HttpTimeoutException =>
152+
new TimeoutException(url, readTimeout, connectTimeout)
153+
case e: java.net.UnknownHostException => new requests.UnknownHostException(url, e.getMessage)
154+
case e: java.net.ConnectException => new requests.UnknownHostException(url, e.getMessage)
155+
case e => new RequestsException(e.getMessage, Some(e))
156+
}
157+
}
158+
159+
val responseCode = response.statusCode()
160+
val headerFields =
161+
response
162+
.headers()
163+
.map
164+
.asScala
165+
.filter(_._1 != null)
166+
.map { case (k, v) => (k.toLowerCase(), v.asScala.toList) }
167+
.toMap
168+
169+
val deGzip = autoDecompress && headerFields
170+
.get("content-encoding")
171+
.toSeq
172+
.flatten
173+
.exists(_.contains("gzip"))
174+
val deDeflate =
175+
autoDecompress && headerFields
176+
.get("content-encoding")
177+
.toSeq
178+
.flatten
179+
.exists(_.contains("deflate"))
180+
def persistCookies() = {
181+
if (sess.persistCookies) {
182+
headerFields
183+
.get("set-cookie")
184+
.iterator
185+
.flatten
186+
.flatMap(HttpCookie.parse(_).asScala)
187+
.foreach(c => sess.cookies(c.getName) = c)
188+
}
189+
}
190+
191+
if (
192+
responseCode.toString.startsWith("3") &&
193+
responseCode.toString != "304" &&
194+
maxRedirects > 0
195+
) {
196+
val out = new ByteArrayOutputStream()
197+
Util.transferTo(response.body, out)
198+
val bytes = out.toByteArray
199+
200+
val current = Response(
201+
url = url,
202+
statusCode = responseCode,
203+
statusMessage = StatusMessages.byStatusCode.getOrElse(responseCode, ""),
204+
data = new geny.Bytes(bytes),
205+
headers = headerFields,
206+
history = redirectedFrom,
207+
)
208+
persistCookies()
209+
val newUrl = current.headers("location").head
210+
HttpBackend.platform.execute(
211+
verb = verb,
212+
url = new URL(url1, newUrl).toString,
213+
auth = auth,
214+
params = params,
215+
headers = headers,
216+
data = data,
217+
readTimeout = readTimeout,
218+
connectTimeout = connectTimeout,
219+
proxy = proxy,
220+
cert = cert,
221+
sslContext = sslContext,
222+
cookies = cookies,
223+
cookieValues = cookieValues,
224+
maxRedirects = maxRedirects - 1,
225+
verifySslCerts = verifySslCerts,
226+
autoDecompress = autoDecompress,
227+
compress = compress,
228+
keepAlive = keepAlive,
229+
check = check,
230+
chunkedUpload = chunkedUpload,
231+
redirectedFrom = Some(current),
232+
onHeadersReceived = onHeadersReceived,
233+
sess = sess,
234+
).readBytesThrough(f)
235+
} else {
236+
persistCookies()
237+
val streamHeaders = StreamHeaders(
238+
url = url,
239+
statusCode = responseCode,
240+
statusMessage = StatusMessages.byStatusCode.getOrElse(responseCode, ""),
241+
headers = headerFields,
242+
history = redirectedFrom,
243+
)
244+
if (onHeadersReceived != null) onHeadersReceived(streamHeaders)
245+
246+
val stream = response.body()
247+
248+
def processWrappedStream[V](f: java.io.InputStream => V): V = {
249+
// The HEAD method is identical to GET except that the server
250+
// MUST NOT return a message-body in the response.
251+
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html section 9.4
252+
if (upperCaseVerb == "HEAD") f(new ByteArrayInputStream(Array()))
253+
else if (stream != null) {
254+
try
255+
f(
256+
if (deGzip) new GZIPInputStream(stream)
257+
else if (deDeflate) new InflaterInputStream(stream)
258+
else stream,
259+
)
260+
finally if (!keepAlive) stream.close()
261+
} else {
262+
f(new ByteArrayInputStream(Array()))
263+
}
264+
}
265+
266+
if (streamHeaders.statusCode == 304 || streamHeaders.is2xx || !check)
267+
processWrappedStream(f)
268+
else {
269+
val errorOutput = new ByteArrayOutputStream()
270+
processWrappedStream(geny.Internal.transfer(_, errorOutput))
271+
throw new RequestFailedException(
272+
Response(
273+
url = streamHeaders.url,
274+
statusCode = streamHeaders.statusCode,
275+
statusMessage = streamHeaders.statusMessage,
276+
data = new geny.Bytes(errorOutput.toByteArray),
277+
headers = streamHeaders.headers,
278+
history = streamHeaders.history,
279+
),
280+
)
281+
}
282+
}
283+
}
284+
}
285+
286+
private def usingOutputStream[T](os: OutputStream)(fn: OutputStream => T): Unit =
287+
try fn(os)
288+
finally os.close()
289+
}
290+
291+
/**
292+
* JVM-specific PlatformHttpBackend implementation.
293+
*/
294+
private[requests] object PlatformHttpBackend {
295+
val instance: HttpBackend = new JvmHttpBackend()
296+
}

0 commit comments

Comments
 (0)