├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.sbt ├── js └── src │ └── main │ └── scala │ └── fr │ └── hmil │ └── roshttp │ ├── BrowserDriver.scala │ ├── ByteBufferChopper.scala │ ├── Converters.scala │ ├── CrossPlatformUtils.scala │ ├── HttpDriver.scala │ ├── JsEnvUtils.scala │ ├── NodeDriver.scala │ └── node │ ├── Global.scala │ ├── Helpers.scala │ ├── LICENSE.txt │ ├── Module.scala │ ├── Modules.scala │ ├── buffer │ └── Buffer.scala │ ├── events │ └── EventEmitter.scala │ ├── http │ ├── Agent.scala │ ├── AgentOptions.scala │ ├── ClientRequest.scala │ ├── Http.scala │ ├── Https.scala │ ├── IncomingMessage.scala │ └── RequestOptions.scala │ └── net │ └── SocketOptions.scala ├── jvm └── src │ └── main │ └── scala │ └── fr │ └── hmil │ └── roshttp │ ├── CrossPlatformUtils.scala │ ├── HttpDriver.scala │ └── JsEnvUtils.scala ├── project ├── InBrowserTesting.scala ├── build.properties └── plugins.sbt ├── release.sh ├── scalastyle-config.xml ├── shared └── src │ ├── main │ ├── scala-2.12 │ │ └── fr │ │ │ └── hmil │ │ │ └── roshttp │ │ │ └── util │ │ │ └── HeaderMap.scala │ └── scala │ │ └── fr │ │ └── hmil │ │ └── roshttp │ │ ├── BackendConfig.scala │ │ ├── ByteBufferQueue.scala │ │ ├── DriverTrait.scala │ │ ├── HttpRequest.scala │ │ ├── Method.scala │ │ ├── Protocol.scala │ │ ├── body │ │ ├── BodyPart.scala │ │ ├── BulkBodyPart.scala │ │ ├── ByteBufferBody.scala │ │ ├── Implicits.scala │ │ ├── JSONBody.scala │ │ ├── MultiPartBody.scala │ │ ├── PlainTextBody.scala │ │ ├── StreamBody.scala │ │ └── URLEncodedBody.scala │ │ ├── exceptions │ │ ├── HttpException.scala │ │ ├── RequestException.scala │ │ ├── ResponseException.scala │ │ ├── TimeoutException.scala │ │ └── UploadStreamException.scala │ │ ├── response │ │ ├── HttpResponse.scala │ │ ├── HttpResponseFactory.scala │ │ ├── HttpResponseHeader.scala │ │ ├── SimpleHttpResponse.scala │ │ └── StreamHttpResponse.scala │ │ ├── tools │ │ └── io │ │ │ └── IO.scala │ │ └── util │ │ ├── HeaderMap.scala │ │ └── Utils.scala │ └── test │ └── scala │ └── fr │ └── hmil │ ├── roshttp │ ├── HttpRequestSpec.scala │ └── StreamingPressureTest.scala │ └── roshttp_test │ └── ReadmeSanityCheck.scala └── test ├── resources ├── README └── icon.png ├── server ├── .gitignore ├── README ├── index.js ├── npm-shrinkwrap.json └── package.json └── update-readme-sanity-check.sh /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | project/target/ 3 | project/project/ 4 | .idea/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | jdk: 4 | - openjdk8 5 | 6 | scala: 7 | - 2.12.11 8 | - 2.13.2 9 | 10 | install: 11 | - nvm list 12 | - nvm install 5.10.0 13 | - nvm use 5.10.0 14 | #services: 15 | #- xvfb 16 | before_script: 17 | #- export DISPLAY=:99.0 18 | # Start test server 19 | - cd test/server && npm install && node index.js & 20 | 21 | script: 22 | - sbt ++$TRAVIS_SCALA_VERSION scalastyle 23 | - test/update-readme-sanity-check.sh --fatalDiff 24 | - sbt ++$TRAVIS_SCALA_VERSION scalaHttpJS/test 25 | - sbt ++$TRAVIS_SCALA_VERSION scalaHttpJVM/test 26 | # - sbt ++$TRAVIS_SCALA_VERSION firefox:test 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 4 | This repository is far from underwhelmed by requests so feel free to file an issue 5 | for any of the cases mentioned below. 6 | 7 | **Want to help?** Issues marked as [open](https://github.com/hmil/RosHTTP/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+open%22) are open to contributions. 8 | Post a comment stating that you would like to work on the issue and do feel free 9 | to ask for more details and discuss possible implementations at any time. 10 | 11 | ## Branches 12 | 13 | **Always base your pull requests on the latest release branch (ie. v2.x.x)**. 14 | master always reflects the latest published version of the library and therefore is 15 | not a suitable target for pull requests. 16 | 17 | ## Reporting bugs 18 | 19 | If you think you found a bug, please file an issue and try to provide a piece of 20 | code that triggers the bug. Indicate in which environment this bug happens and give 21 | some details on each affected environments (java version, jdk used, node version, 22 | browser + version). 23 | 24 | For bug fixes, file an issue as instructed above and create a pull request referencing 25 | the issue. Always try to provide a test case with your bug fix. If for some reason you 26 | can really not test your bug, let me know in your pull request comment. 27 | 28 | ## Feature requests 29 | 30 | Feel free to file an issue to request a feature, or even just to start a conversation 31 | related to the project. It is advised that you discuss any feature you would like 32 | to implement before starting working on it. 33 | 34 | New features **must be tested**. 35 | 36 | ## Development 37 | 38 | This project is built with sbt. While any IDE with decent sbt support would work, 39 | I recommend using _idea_ with the scala plugin. 40 | 41 | The sbt project contains two subprojects: `scalaHttpJS` and `scalaHttpJVM`. 42 | Run one of `sbt scalaHttpJS/console` or `sbt scalaHttpJVM/console` to start an interactive 43 | console with library code in the classpath. 44 | 45 | You can run tests in 4 different environment using the following commands: 46 | ``` 47 | sbt scalaHttpJVM/test # Runs on your current JVM (default environment for Scala) 48 | sbt scalaHttpJS/test # Runs in Node.js 49 | sbt chrome:test # Run in chrome* 50 | sbt firefox:test # Runs in firefox* 51 | ``` 52 | *Browser testing may require additional software on your computer. See 53 | [https://github.com/scala-js/scala-js-env-selenium#scalajs-env-selenium] for more details. 54 | 55 | The testing server needs to run on your machine while running the test commands. 56 | 57 | ## Testing 58 | 59 | All features are tested in [HttpRequestSpec](https://github.com/hmil/RosHTTP/blob/master/shared/src/test/scala/fr/hmil/roshttp/client/HttpRequestSpec.scala). 60 | In order to test this library, the testing server needs to run on the testing machine. 61 | This server enables us to run tests in a reproducible environment and to test edge cases. 62 | 63 | To start the testing server, go to `test/server` and install the dependencies with 64 | `npm install`. This command must be run when there has been some updates in the 65 | test server dependencies. 66 | Run the test server with `node index.js`. 67 | 68 | If Node.js is not installed on your machine, do the following: 69 | - For Windows and Mac, download the latest LTS release from the [official website](https://nodejs.org). 70 | - For Linux users, I advise downloading Node.js through [NVM](https://github.com/creationix/nvm) 71 | 72 | ## Code style 73 | 74 | The code in this repository should follow Scala.js 75 | [coding style guide](https://github.com/scala-js/scala-js/blob/master/CODINGSTYLE.md) 76 | as much as possible. 77 | 78 | ## Other things you need to know 79 | 80 | Environment-specific code lives in jvm/ and js/. The `DriverTrait` is the 81 | low-level interface between shared and specific code. Shared code is linked to the 82 | correct `HttpDriver` class by the Scala.js compiler. 83 | 84 | All code that gets merged in this code base falls under the terms of the MIT license. 85 | By submitting a pull request to this repository, you consent that your code may be 86 | used, copied, and distributed by anyone as per the terms of the MIT license and 87 | that you are not responsible for the usage other parties may make of your code. 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Hadrien Milano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RösHTTP 2 | [![Build Status](https://travis-ci.org/hmil/RosHTTP.svg?branch=master)](https://travis-ci.org/hmil/RosHTTP) 3 | [![Latest version on jcenter](https://img.shields.io/maven-metadata/v/https/jcenter.bintray.com/fr/hmil/roshttp_2.12/maven-metadata.xml.svg?label=latest+version)](https://jcenter.bintray.com/fr/hmil/roshttp_2.12/) 4 | [![Scala.js](https://www.scala-js.org/assets/badges/scalajs-0.6.8.svg)](https://www.scala-js.org) 5 | 6 | 7 | A human-readable scala http client API compatible with: 8 | 9 | - vanilla jvm **scala** 10 | - most **browsers** (_via_ [scala-js](https://github.com/scala-js/scala-js)) 11 | - **node.js** (_via_ [scala-js](https://github.com/scala-js/scala-js)) 12 | 13 | ## THIS PACKAGE IS NO LONGER MAINTAINED 14 | 15 | I moved on to different ventures and I can no longer afford the time to maintain this package. Feel free to use it as-is, or drop a comment in [#58](https://github.com/hmil/RosHTTP/issues/58) if you would like me to endorse your fork. 16 | 17 | 18 | # Installation 19 | 20 | Add a dependency in your build.sbt: 21 | 22 | ``` 23 | resolvers += "hmil.fr" at "https://files.hmil.fr/maven/" 24 | 25 | libraryDependencies += "fr.hmil" %%% "roshttp" % "3.0.0" 26 | ``` 27 | 28 | # Usage 29 | 30 | The following is a simplified usage guide. You may find useful information in 31 | the [API doc](http://hmil.github.io/RosHTTP/docs/index.html) too. 32 | 33 | ## Basic usage 34 | 35 | ```scala 36 | import fr.hmil.roshttp.HttpRequest 37 | import monix.execution.Scheduler.Implicits.global 38 | import scala.util.{Failure, Success} 39 | import fr.hmil.roshttp.response.SimpleHttpResponse 40 | 41 | // Runs consistently on the jvm, in node.js and in the browser! 42 | val request = HttpRequest("https://schema.org/WebPage") 43 | 44 | request.send().onComplete({ 45 | case res:Success[SimpleHttpResponse] => println(res.get.body) 46 | case e: Failure[SimpleHttpResponse] => println("Houston, we got a problem!") 47 | }) 48 | ``` 49 | 50 | ## Configuring requests 51 | 52 | [HttpRequests](http://hmil.github.io/RosHTTP/docs/index.html#fr.hmil.roshttp.HttpRequest) 53 | are immutable objects. They expose methods named `.withXXX` which can be used to 54 | create more complex requests. 55 | 56 | ### URI 57 | 58 | The URI can be passed as argument of the request constructor or `.withURI`. 59 | The URI can be built using `.withProtocol`, `.withHost`, `.withPort`, 60 | `.withPath`, and `.withQuery...`. The latter is a bit more complex and 61 | is detailed below. 62 | 63 | ```scala 64 | import fr.hmil.roshttp.Protocol.HTTP 65 | 66 | HttpRequest() 67 | .withProtocol(HTTP) 68 | .withHost("localhost") 69 | .withPort(3000) 70 | .withPath("/weather") 71 | .withQueryParameter("city", "London") 72 | .send() // GET http://localhost:3000/weather?city=London 73 | ``` 74 | 75 | #### `.withQueryString` 76 | The whole querystring can be set to a custom value like this: 77 | 78 | ```scala 79 | // Sets the query string such that the target url ends in "?hello%20world" 80 | request.withQueryString("hello world") 81 | ``` 82 | 83 | `.withQueryString(string)` urlencodes string and replaces the whole query string 84 | with the result. 85 | To bypass encoding, use `.withQueryStringRaw(rawString)`. 86 | 87 | #### `.withQueryParameter` 88 | Most of the time, the query string is used to pass key/value pairs in the 89 | `application/x-www-form-urlencoded` format. 90 | [HttpRequest](http://hmil.github.io/RosHTTP/docs/index.html#fr.hmil.roshttp.HttpRequest) 91 | offers an API to add, update and delete keys in the query string. 92 | 93 | ```scala 94 | request 95 | .withQueryParameter("foo", "bar") 96 | .withQuerySeqParameter("table", Seq("a", "b", "c")) 97 | .withQueryObjectParameter("map", Seq( 98 | "d" -> "dval", 99 | "e" -> "e value" 100 | )) 101 | .withQueryParameters( 102 | "license" -> "MIT", 103 | "copy" -> "© 2016" 104 | ) 105 | /* Query is now: 106 | foo=bar&table=a&table=b&table=c&map[d]=dval&map[e]=e%20value&license=MIT©=%C2%A9%202016 107 | */ 108 | ``` 109 | 110 | ### HTTP Method 111 | 112 | ```scala 113 | import fr.hmil.roshttp.Method.PUT 114 | 115 | request.withMethod(PUT).send() 116 | ``` 117 | 118 | ### Headers 119 | 120 | Set individual headers using `.withHeader` 121 | ```scala 122 | request.withHeader("Accept", "text/html") 123 | ``` 124 | Or multiple headers at once using `.withHeaders` 125 | ```scala 126 | request.withHeaders( 127 | "Accept" -> "text/html", 128 | "User-Agent" -> "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" 129 | ) 130 | ``` 131 | 132 | ### Backend configuration 133 | 134 | Some low-level configuration settings are available in [BackendConfig](http://hmil.github.io/RosHTTP/docs/index.html#fr.hmil.roshttp.BackendConfig). 135 | Each request can use a specific backend configuration using `.withBackendConfig`. 136 | 137 | example: 138 | ```scala 139 | import fr.hmil.roshttp.BackendConfig 140 | 141 | HttpRequest("long.source.of/data") 142 | .withBackendConfig(BackendConfig( 143 | // Uses stream chunks of at most 1024 bytes 144 | maxChunkSize = 1024 145 | )) 146 | .stream() 147 | ``` 148 | 149 | ### Cross-domain authorization information 150 | 151 | For security reasons, cross-domain requests are not sent with authorization headers or cookies. If 152 | despite security concerns, this feature is needed, it can be enabled using `withCrossDomainCookies`, 153 | which internally uses the 154 | [`XMLHttpRequest.withCredentials`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials) 155 | method, but has no effect in non-browser environments. Also for same-site requests, setting it to 156 | `true` has no effect either. 157 | ```scala 158 | request.withCrossDomainCookies(true) 159 | ``` 160 | 161 | ## Response headers 162 | 163 | A map of response headers is available on the `HttpResponse` object: 164 | ```scala 165 | request.send().map({res => 166 | println(res.headers("Set-Cookie")) 167 | }) 168 | ``` 169 | 170 | ## Sending data 171 | 172 | An HTTP request can send data wrapped in an implementation of `BodyPart`. The most common 173 | formats are already provided but you can create your own as well. 174 | A set of implicit conversions is provided in `body.Implicits` for convenience. 175 | 176 | You can `post` or `put` some data with your favorite encoding. 177 | ```scala 178 | import fr.hmil.roshttp.body.Implicits._ 179 | import fr.hmil.roshttp.body.URLEncodedBody 180 | 181 | val urlEncodedData = URLEncodedBody( 182 | "answer" -> "42", 183 | "platform" -> "jvm" 184 | ) 185 | request.post(urlEncodedData) 186 | // or 187 | request.put(urlEncodedData) 188 | ``` 189 | 190 | Create JSON requests easily using implicit conversions. 191 | ```scala 192 | import fr.hmil.roshttp.body.Implicits._ 193 | import fr.hmil.roshttp.body.JSONBody._ 194 | 195 | val jsonData = JSONObject( 196 | "answer" -> 42, 197 | "platform" -> "node" 198 | ) 199 | request.post(jsonData) 200 | ``` 201 | 202 | ### File upload 203 | 204 | To send file data you must turn a file into a ByteBuffer and then send it in a 205 | ByteBufferBody. For instance, on the jvm you could do: 206 | ```scala 207 | import java.nio.ByteBuffer 208 | import fr.hmil.roshttp.body.ByteBufferBody 209 | 210 | val buffer = ByteBuffer.wrap( 211 | List[Byte](0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x0a) 212 | .toArray) 213 | request.post(ByteBufferBody(buffer)) 214 | ``` 215 | Note that the codec argument is important to read the file as-is and avoid side-effects 216 | due to character interpretation. 217 | 218 | ### Multipart 219 | 220 | Use the `MultiPartBody` to compose request bodies arbitrarily. It allows for instance 221 | to send binary data with some textual data. 222 | 223 | The following example illustrates how you could send a form to update a user profile 224 | made of a variety of data types. 225 | ```scala 226 | import fr.hmil.roshttp.body.Implicits._ 227 | import fr.hmil.roshttp.body.JSONBody._ 228 | import fr.hmil.roshttp.body._ 229 | 230 | request.post(MultiPartBody( 231 | // The name part is sent as plain text 232 | "name" -> PlainTextBody("John"), 233 | // The skills part is a complex nested structure sent as JSON 234 | "skills" -> JSONObject( 235 | "programming" -> JSONObject( 236 | "C" -> 3, 237 | "PHP" -> 1, 238 | "Scala" -> 5 239 | ), 240 | "design" -> true 241 | ), 242 | "hobbies" -> JSONArray( 243 | "programming", 244 | "stargazing" 245 | ), 246 | // The picture is sent using a ByteBufferBody, assuming buffer is a ByteBuffer 247 | // containing the image data 248 | "picture" -> ByteBufferBody(buffer, "image/jpeg") 249 | )) 250 | ``` 251 | 252 | ## Streaming 253 | 254 | **Warning:** Even though the streaming API works flawlessly on the JVM, it is an 255 | experimental feature as the JS implementation may leak memory or buffer things 256 | in the background. 257 | 258 | ### Download streams 259 | 260 | Streaming a response is as simple as calling `.stream()` instead of `.send()`. 261 | `HttpRequest#stream()` returns a Future of `StreamHttpResponse`. A `StreamHttpResponse` 262 | is just like a `SimpleHttpResponse` except that its `body` property is an 263 | [Observable](https://monix.io/api/2.0/#monix.reactive.Observable). 264 | The observable will spit out a stream of `ByteBuffer`s as shown in this example: 265 | 266 | ```scala 267 | import fr.hmil.roshttp.util.Utils._ 268 | 269 | request 270 | .stream() 271 | .map({ r => 272 | r.body.foreach(buffer => println(getStringFromBuffer(buffer, "UTF-8"))) 273 | }) 274 | ``` 275 | _Note that special care should be taken when converting chunks into strings because 276 | multibyte characters may span multiple chunks._ 277 | _In general streaming is used for binary data and any reasonable quantity 278 | of text can safely be handled by the non-streaming API._ 279 | 280 | #### HTTP methods 281 | 282 | There is no shortcut method such as `.post` to get a streaming response. You can 283 | still achieve that by using the constructor methods as shown below: 284 | ```scala 285 | import fr.hmil.roshttp.Method.POST 286 | 287 | request 288 | .withMethod(POST) 289 | .withBody(PlainTextBody("My upload data")) 290 | .stream() 291 | // The response will be streamed 292 | ``` 293 | 294 | ### Upload Streams 295 | 296 | There are cases where you want to upload some very large data with minimal memory 297 | consumption. We've got you covered! The [StreamBody](http://hmil.github.io/RosHTTP/docs/index.html#fr.hmil.roshttp.body.StreamBody) takes an 298 | [Observable](https://monix.io/api/2.0/#monix.reactive.Observable)[ByteBuffer] 299 | and streams its contents to the server. You can also pass an InputStream directly 300 | using RösHTTP's implicit converters: 301 | 306 | ```scala 307 | import fr.hmil.roshttp.body.Implicits._ 308 | 309 | // On the JVM: 310 | // val inputStream = new java.io.FileInputStream("video.avi") 311 | request 312 | .post(inputStream) 313 | .onComplete({ 314 | case _:Success[SimpleHttpResponse] => println("Data successfully uploaded") 315 | case _:Failure[SimpleHttpResponse] => println("Error: Could not upload stream") 316 | }) 317 | ``` 318 | 319 | ## Error handling 320 | 321 | Have you ever been frustrated when an application fails silently or gives you a 322 | vague and insignificant error message? RösHTTP comes with a powerful error handling 323 | API which allows you to deal with exceptions at the granularity level of your choice! 324 | 325 | ### Quick and easy error handling 326 | 327 | Most applications only need to distinguish two failure cases: Application-level failures 328 | and lower-level failures. 329 | 330 | Application-level errors occur when a bad status code is received. For instance: 331 | - The request contained invalid data (400) 332 | - The requested resource does not exist (404) 333 | - The server encountered an error (500) 334 | - _etc..._ 335 | 336 | Lower-level errors include timeouts, tcp and dns failures. They are beyond the 337 | scope of most applications and should be treated separately from application-level 338 | errors, especially in code tied to user interfaces. 339 | 340 | 341 | ```scala 342 | import fr.hmil.roshttp.exceptions.HttpException 343 | import java.io.IOException 344 | request.send() 345 | .recover { 346 | case HttpException(e: SimpleHttpResponse) => 347 | // Here we may have some detailed application-level insight about the error 348 | println("There was an issue with your request." + 349 | " Here is what the application server says: " + e.body) 350 | case e: IOException => 351 | // By handling transport issues separately, you get a chance to apply 352 | // your own recovery strategy. Should you report to the user? Log the error? 353 | // Retry the request? Send an alert to your ops team? 354 | println("There was a network issue, please try again") 355 | } 356 | ``` 357 | 358 | note that `HttpException` is a case class which either contains a `SimpleHttpResponse` 359 | or a `StreamHttpResponse` depending on what you expect your response to be (see 360 | [Streaming](https://github.com/hmil/RosHTTP#streaming)). 361 | 362 | ### Fine-grain error handling 363 | 364 | If you ever need very specific error details, here is the list of exceptions 365 | which can occur in the Future. 366 | 367 | - IOException All RösHTTP exceptions inherit from `java.io.IOException` 368 | - TimeoutException Receiving the response took longer than the configured response timeout threshold. 369 | Note that in this case the headers were already received and you can access them if needed (mainly for debugging purposes). 370 | - RequestException A transport error occurred while sending the request (eg. DNS resolution failure). 371 | - ResponseException A transport error occurred while receiving the response (for buffered responses). 372 | Note that in this case the headers were already received and you can access them if needed (mainly for debugging purposes). 373 | - UploadStreamException The stream used as a data source for the request body failed. 374 | - HttpException Application-level errors (ie. status codes >= 400) 375 | 376 | --- 377 | 378 | Watch the [issues](https://github.com/hmil/RosHTTP/issues) 379 | for upcoming features. Feedback is very welcome so feel free to file an issue if you 380 | see something that is missing. 381 | 382 | # Known limitations 383 | 384 | - Streaming is emulated in the browser, meaning that streaming large request or 385 | response payloads in the browser will consume large amounts of memory and might fail. 386 | This [problem has a solution](https://github.com/hmil/RosHTTP/issues/46) 387 | - Some headers cannot be set in the browser ([list](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name)). 388 | - There is no way to avoid redirects in the browser. This is a W3C spec. 389 | - Chrome does not allow userspace handling of a 407 status code. It is treated 390 | like a network error. See [chromium issue](https://bugs.chromium.org/p/chromium/issues/detail?id=372136). 391 | - The `TRACE` HTTP method does not work in browsers and `PATCH` does not work in the JVM. 392 | 393 | # Contributing 394 | 395 | Please read the [contributing guide](https://github.com/hmil/RosHTTP/blob/master/CONTRIBUTING.md). 396 | 397 | # Changelog 398 | 399 | **v3.0.0** 400 | 401 | - Move to Scala.js 1.0 402 | 403 | **v2.2.4** 404 | 405 | - Update to monix v2.3.3 406 | 407 | **v2.2.0** 408 | 409 | - Add withCrossDomainCookies (by @nondeterministic) 410 | 411 | **v2.1.0** 412 | 413 | - Fix edge cases with `require` in JS environments 414 | - Add missing boolean and array JSON types 415 | 416 | **v2.0.2** 417 | 418 | - Update to monix v2.3.0 419 | - Update to scala v2.11.11 420 | 421 | **v2.0.1** 422 | 423 | - Update to monix v2.1.2 424 | 425 | **v2.0.0** 426 | 427 | - Renamed withQueryArrayParameter to withQuerySeqParameter 428 | - Timeout errors on body 429 | - Rename *Error classes to *Exception 430 | - Add streaming API 431 | - Add implicit Scheduler parameter 432 | - Add implicit execution context parameter 433 | 434 | **v1.1.0** 435 | - Fix bug on responses without Content-Type header 436 | - Detect key-value pairs during query string escapement 437 | 438 | **v1.0.1** 439 | - Fix NPE when reading empty error response 440 | 441 | **v1.0.0** 442 | - Using [semantic versioning](http://semver.org/) from now on 443 | - Renamed RösHTTP 444 | - Add .withBody() 445 | 446 | **v0.3.0** 447 | - Remove general purpose StringBody 448 | - Add missing patch method 449 | - Make Method constructor public 450 | - Disambiguate `withQueryArrayParameter` and `withQueryObjectParameter` 451 | - Remove map parameters from `.withQueryParameter(s)` and `.withHeaders` 452 | 453 | **v0.2.0** 454 | - Support request body with `post()`, `put()` and `options()` 455 | - Add `withHttpMethod()` 456 | - Support HTTPS 457 | 458 | **v0.1.0** 459 | - First release 460 | 461 | # License 462 | 463 | MIT 464 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "RösHTTP root project" 2 | 3 | crossScalaVersions := Seq("2.12.11", "2.13.2") 4 | 5 | lazy val root = project.in(file(".")) 6 | .aggregate(scalaHttpJS, scalaHttpJVM) 7 | 8 | lazy val scalaHttp = crossProject(JSPlatform, JVMPlatform).in(file(".")) 9 | // .configureCross(InBrowserTesting.cross) 10 | .settings( 11 | name := "roshttp", 12 | version := "3.0.0", 13 | scalaVersion := "2.13.2", 14 | organization := "fr.hmil", 15 | licenses += ("MIT", url("http://opensource.org/licenses/MIT")), 16 | homepage := Some(url("http://github.com/hmil/RosHTTP")), 17 | 18 | pomExtra := ( 19 | 20 | git@github.com:hmil/RosHTTP.git 21 | scm:git:git@github.com:hmil/RosHTTP.git 22 | 23 | 24 | 25 | hmil 26 | Hadrien Milano 27 | https://github.com/hmil/ 28 | 29 | 30 | ), 31 | pomIncludeRepository := { _ => false }, 32 | 33 | libraryDependencies += "org.scala-lang.modules" %% "scala-collection-compat" % "2.1.6", 34 | libraryDependencies += "com.lihaoyi" %%% "utest" % "0.7.4" % Test, 35 | libraryDependencies += "io.monix" %%% "monix" % "3.2.1", 36 | 37 | testFrameworks += new TestFramework("utest.runner.Framework") 38 | ) 39 | .jvmSettings( 40 | // jvm-specific settings 41 | ) 42 | .jsSettings( 43 | // js-specific settings 44 | libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "1.0.0", 45 | 46 | jsEnv := new org.scalajs.jsenv.nodejs.NodeJSEnv() 47 | ) 48 | 49 | lazy val scalaHttpJVM = scalaHttp.jvm 50 | lazy val scalaHttpJS = scalaHttp.js 51 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/BrowserDriver.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.exceptions.{HttpException, RequestException, UploadStreamException} 6 | import fr.hmil.roshttp.response.{HttpResponse, HttpResponseFactory, HttpResponseHeader} 7 | import fr.hmil.roshttp.util.HeaderMap 8 | import monix.execution.Ack.Continue 9 | import monix.execution.{Ack, Scheduler} 10 | import monix.reactive.{Observable, Observer} 11 | import org.scalajs.dom 12 | import org.scalajs.dom.ext.Ajax 13 | import org.scalajs.dom.raw.ErrorEvent 14 | 15 | import scala.collection.mutable 16 | import scala.concurrent.{Future, Promise} 17 | import scala.scalajs.js.JavaScriptException 18 | import scala.scalajs.js.typedarray.ArrayBuffer 19 | import scala.util.{Failure, Success} 20 | 21 | private object BrowserDriver extends DriverTrait { 22 | 23 | override def send[T <: HttpResponse](req: HttpRequest, factory: HttpResponseFactory[T]) 24 | (implicit scheduler: Scheduler): Future[T] = { 25 | val p: Promise[T] = Promise[T]() 26 | 27 | val xhr = new dom.XMLHttpRequest() 28 | xhr.open(req.method.toString, req.url) 29 | xhr.withCredentials = req.crossDomainCookies 30 | xhr.responseType = "arraybuffer" 31 | req.headers.foreach(t => xhr.setRequestHeader(t._1, t._2)) 32 | 33 | xhr.onerror = { (e: ErrorEvent) => 34 | p.failure(RequestException(JavaScriptException(e))) 35 | } 36 | 37 | val bufferQueue = new ByteBufferQueue(req.backendConfig.internalBufferLength) 38 | 39 | xhr.onreadystatechange = { (e: dom.Event) => 40 | if (xhr.readyState == dom.XMLHttpRequest.HEADERS_RECEIVED) { 41 | val headers = xhr.getAllResponseHeaders() match { 42 | case null => Map[String, String]() 43 | case s: String => s.split("\r\n").map({ s => 44 | val split = s.split(": ") 45 | (split.head, split.tail.mkString.trim) 46 | }).toMap[String, String] 47 | } 48 | 49 | p.completeWith( 50 | factory( 51 | new HttpResponseHeader(xhr.status, HeaderMap(headers)), 52 | bufferQueue.observable, 53 | req.backendConfig) 54 | .map({response => 55 | if (xhr.status >= 400) { 56 | throw HttpException.badStatus(response) 57 | } else { 58 | response 59 | } 60 | }) 61 | ) 62 | } else if (xhr.readyState == dom.XMLHttpRequest.DONE) { 63 | chopChunk().foreach(bufferQueue.push) 64 | bufferQueue.end() 65 | } 66 | } 67 | 68 | def chopChunk(): Seq[ByteBuffer] = { 69 | val buffer = xhr.response.asInstanceOf[ArrayBuffer] 70 | val buffers = ByteBufferChopper.chop( 71 | Converters.arrayBufferToByteBuffer(buffer), 72 | req.backendConfig.maxChunkSize) 73 | buffers 74 | } 75 | 76 | if (req.body.isEmpty) { 77 | xhr.send() 78 | } else { 79 | bufferBody(req.body.get.content).andThen({ 80 | case buffer: Success[ByteBuffer] => xhr.send(Ajax.InputData.byteBuffer2ajax(buffer.value)) 81 | case f: Failure[ByteBuffer] => p.failure(f.exception) 82 | }) 83 | } 84 | 85 | p.future 86 | } 87 | 88 | private def bufferBody(bodyStream: Observable[ByteBuffer])(implicit scheduler: Scheduler): Future[ByteBuffer] = { 89 | val bufferQueue = mutable.Queue[ByteBuffer]() 90 | var bytes = 0 91 | val p = Promise[ByteBuffer]() 92 | 93 | bodyStream.subscribe(new Observer[ByteBuffer] { 94 | override def onError(ex: Throwable): Unit = p.failure(UploadStreamException(ex)) 95 | 96 | override def onComplete(): Unit = p.success(recomposeBody(bufferQueue, bytes)) 97 | 98 | override def onNext(elem: ByteBuffer): Future[Ack] = { 99 | bytes += elem.limit() 100 | bufferQueue.enqueue(elem) 101 | Future.successful(Continue) 102 | } 103 | }) 104 | 105 | p.future 106 | } 107 | 108 | private def recomposeBody(seq: mutable.Queue[ByteBuffer], bytes: Int): ByteBuffer = { 109 | // Allocate maximum expected body length 110 | val buffer = ByteBuffer.allocate(bytes) 111 | seq.foreach(chunk => buffer.put(chunk)) 112 | buffer.rewind() 113 | buffer 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/ByteBufferChopper.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.nio.ByteBuffer 4 | 5 | 6 | object ByteBufferChopper { 7 | 8 | def chop(buffer: ByteBuffer, maxChunkSize: Int): Seq[ByteBuffer] = { 9 | val nb_buffers = (buffer.limit() + maxChunkSize - 1) / maxChunkSize 10 | val buffers = new Array[ByteBuffer](nb_buffers) 11 | var i = 0 12 | while (i < nb_buffers) { 13 | val length = Math.min(maxChunkSize, buffer.remaining) 14 | buffers(i) = buffer.slice() 15 | buffers(i).limit(length) 16 | buffer.position(buffer.position() + length) 17 | i = i + 1 18 | } 19 | buffers 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/Converters.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.node.buffer.Buffer 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.typedarray.{ArrayBuffer, Int8Array, TypedArrayBuffer, Uint8Array} 9 | import scala.scalajs.js.JSConverters._ 10 | import js.typedarray.TypedArrayBufferOps._ 11 | 12 | private object Converters { 13 | def byteArrayToUint8Array(arr: Array[Byte]): Uint8Array = { 14 | js.Dynamic.newInstance(js.Dynamic.global.Uint8Array)(arr.toJSArray).asInstanceOf[Uint8Array] 15 | } 16 | 17 | def byteBufferToNodeBuffer(buffer: ByteBuffer): Buffer = { 18 | if (buffer.isDirect) { 19 | js.Dynamic.newInstance(js.Dynamic.global.Buffer)(buffer.arrayBuffer).asInstanceOf[Buffer] 20 | } else if (buffer.hasArray) { 21 | js.Dynamic.newInstance(js.Dynamic.global.Buffer)(byteArrayToUint8Array(buffer.array())).asInstanceOf[Buffer] 22 | } else { 23 | val arr = new Int8Array(buffer.limit()) 24 | var i = 0 25 | while (i < arr.length) { 26 | arr(i) = buffer.get(i) 27 | i += 1 28 | } 29 | js.Dynamic.newInstance(js.Dynamic.global.Buffer)(arr).asInstanceOf[Buffer] 30 | } 31 | } 32 | 33 | def nodeBufferToByteBuffer(buffer: Buffer): ByteBuffer = { 34 | TypedArrayBuffer.wrap(buffer.asInstanceOf[ArrayBuffer]) 35 | } 36 | 37 | def arrayBufferToByteBuffer(buffer: ArrayBuffer): ByteBuffer = { 38 | TypedArrayBuffer.wrap(buffer) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/CrossPlatformUtils.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import scala.scalajs.js 4 | 5 | 6 | private object CrossPlatformUtils { 7 | 8 | def encodeURIComponent(query: String): String = 9 | js.URIUtils.encodeURIComponent(query) 10 | 11 | def decodeURIComponent(query: String): String = 12 | js.URIUtils.decodeURIComponent(query) 13 | } 14 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/HttpDriver.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import fr.hmil.roshttp.node.Modules.{HttpModule, HttpsModule} 4 | import fr.hmil.roshttp.response.{HttpResponse, HttpResponseFactory} 5 | import monix.execution.Scheduler 6 | 7 | import scala.concurrent.Future 8 | 9 | private object HttpDriver extends DriverTrait { 10 | 11 | private var _driver: Option[DriverTrait] = None 12 | 13 | def send[T <: HttpResponse](req: HttpRequest, factory: HttpResponseFactory[T])(implicit scheduler: Scheduler): 14 | Future[T] = { 15 | _driver.getOrElse(chooseBackend()).send(req, factory) 16 | } 17 | 18 | private def chooseBackend(): DriverTrait = { 19 | if (HttpModule.isAvailable && HttpsModule.isAvailable) { 20 | _driver = Some(NodeDriver) 21 | } else { 22 | _driver = Some(BrowserDriver) 23 | } 24 | _driver.get 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/JsEnvUtils.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | /* 4 | Taken from http://japgolly.blogspot.fr/2016/03/scalajs-firefox-chrome-sbt.html 5 | */ 6 | 7 | import scala.scalajs.js.Dynamic.global 8 | import scala.util.Try 9 | 10 | private object JsEnvUtils { 11 | 12 | /** Sample (real) values are: 13 | * - Mozilla/5.0 (Unknown; Linux x86_64) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1 14 | * - Mozilla/5.0 (X11; Linux x86_64; rv:45.0) Gecko/20100101 Firefox/45.0 15 | * - Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36 16 | */ 17 | val userAgent: String = 18 | Try(global.navigator.userAgent.asInstanceOf[String]) getOrElse "Unknown" 19 | 20 | // Check each browser 21 | val isFirefox = userAgent contains "Firefox" 22 | val isChrome = userAgent contains "Chrome" 23 | 24 | val isRealBrowser = isFirefox || isChrome 25 | 26 | // Or you can even just check if running in X 27 | val isRunningInX = userAgent contains "X11" 28 | } 29 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/NodeDriver.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.io.IOException 4 | import java.nio.ByteBuffer 5 | 6 | import fr.hmil.roshttp.ByteBufferQueue.Feeder 7 | import fr.hmil.roshttp.exceptions.{HttpException, RequestException, UploadStreamException} 8 | import fr.hmil.roshttp.node.Modules.{http, https} 9 | import fr.hmil.roshttp.node.buffer.Buffer 10 | import fr.hmil.roshttp.node.http.{IncomingMessage, RequestOptions} 11 | import fr.hmil.roshttp.response.{HttpResponse, HttpResponseFactory, HttpResponseHeader} 12 | import fr.hmil.roshttp.util.HeaderMap 13 | import monix.execution.Ack.Continue 14 | import monix.execution.{Ack, Scheduler} 15 | import monix.reactive.Observer 16 | 17 | import scala.concurrent.{Future, Promise} 18 | import scala.scalajs.js 19 | import scala.scalajs.js.JSConverters._ 20 | import scala.scalajs.js.JavaScriptException 21 | 22 | private object NodeDriver extends DriverTrait { 23 | 24 | def makeRequest[T <: HttpResponse](req: HttpRequest, factory: HttpResponseFactory[T], p: Promise[T]) 25 | (implicit scheduler: Scheduler): Unit = { 26 | val module = { 27 | if (req.protocol == Protocol.HTTP) 28 | http 29 | else 30 | https 31 | } 32 | var headers = req.headers 33 | if (!req.backendConfig.allowChunkedRequestBody) { 34 | headers += "Transfer-Encoding" -> "" 35 | } 36 | val nodeRequest = module.request(RequestOptions( 37 | hostname = req.host, 38 | port = req.port.orUndefined, 39 | method = req.method.toString, 40 | headers = js.Dictionary(headers.toSeq: _*), 41 | path = req.longPath 42 | ), handleResponse(req, factory, p)_) 43 | nodeRequest.on("error", { (s: js.Dynamic) => 44 | p.tryFailure(RequestException(new IOException(s.toString))) 45 | () 46 | }) 47 | if (req.body.isDefined) { 48 | req.body.foreach({ part => 49 | part.content.subscribe(new Observer[ByteBuffer] { 50 | override def onError(ex: Throwable): Unit = { 51 | p.tryFailure(UploadStreamException(ex)) 52 | nodeRequest.abort() 53 | } 54 | 55 | override def onComplete(): Unit = { 56 | nodeRequest.end() 57 | } 58 | 59 | override def onNext(elem: ByteBuffer): Future[Ack] = { 60 | nodeRequest.write(Converters.byteBufferToNodeBuffer(elem)) 61 | Continue 62 | } 63 | }) 64 | }) 65 | } else { 66 | nodeRequest.end() 67 | } 68 | } 69 | 70 | def handleResponse[T <: HttpResponse](req:HttpRequest, factory: HttpResponseFactory[T], p: Promise[T]) 71 | (message: IncomingMessage)(implicit scheduler: Scheduler): Unit = { 72 | if (message.statusCode >= 300 && message.statusCode < 400 && message.headers.contains("location")) { 73 | makeRequest(req.withURL(message.headers("location")), factory, p) 74 | } else { 75 | val headers = message.headers.toMap[String, String] 76 | val bufferQueue = new ByteBufferQueue(req.backendConfig.internalBufferLength, 77 | new Feeder { 78 | override def onFlush(): Unit = message.resume() 79 | override def onFull(): Unit = message.pause() 80 | }) 81 | 82 | message.on("data", { (nodeBuffer: js.Dynamic) => 83 | convertAndChopBuffer(nodeBuffer, req.backendConfig.maxChunkSize).foreach(bufferQueue.push) 84 | }) 85 | message.on("end", { (s: js.Dynamic) => 86 | bufferQueue.end() 87 | }) 88 | message.on("error", { (s: js.Dynamic) => 89 | bufferQueue.pushError(JavaScriptException(s)) 90 | }) 91 | 92 | p.completeWith( 93 | factory(new HttpResponseHeader(message.statusCode, HeaderMap(headers)), 94 | bufferQueue.observable, 95 | req.backendConfig) 96 | .map({ response => 97 | if (message.statusCode < 400) { 98 | response 99 | } else { 100 | throw HttpException.badStatus(response) 101 | } 102 | })) 103 | } 104 | () 105 | } 106 | 107 | def send[T <: HttpResponse](req: HttpRequest, factory: HttpResponseFactory[T])(implicit scheduler: Scheduler): 108 | Future[T] = { 109 | val p: Promise[T] = Promise[T]() 110 | makeRequest(req, factory, p) 111 | p.future 112 | } 113 | 114 | private def convertAndChopBuffer(nodeBuffer: js.Any, maxChunkSize: Int): Seq[ByteBuffer] = { 115 | val buffer = Converters.nodeBufferToByteBuffer(nodeBuffer.asInstanceOf[Buffer]) 116 | ByteBufferChopper.chop(buffer, maxChunkSize) 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/Global.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node 2 | 3 | import scala.scalajs.js 4 | import js.annotation._ 5 | 6 | /** 7 | * Facade for objects accessible from node's global scope 8 | */ 9 | @js.native 10 | @JSGlobalScope 11 | private[roshttp] object Global extends js.Object { 12 | def require[T](name: String): js.UndefOr[T] = js.native 13 | } 14 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/Helpers.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.JavaScriptException 5 | 6 | /** 7 | * collection of helper functions for nodejs related stuff 8 | */ 9 | private[roshttp] object Helpers { 10 | 11 | /** 12 | * Tests whether the current environment is commonjs-like 13 | * 14 | * @return true if the function "require" is available on the global scope 15 | */ 16 | def isRequireAvailable: Boolean = js.typeOf(js.Dynamic.global.selectDynamic("require")) != "undefined" 17 | 18 | /** 19 | * Gets javascript module using require() 20 | * 21 | * @param moduleName Module name 22 | * @return The requested module as a scala facade 23 | */ 24 | def require[T](moduleName: String): Option[T] = { 25 | if (isRequireAvailable) { 26 | try { 27 | Global.require[T](moduleName).toOption 28 | } catch { 29 | case _: JavaScriptException => None 30 | } 31 | } else { 32 | None 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/Module.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node 2 | 3 | private[roshttp] abstract class Module[T](val name: String) { 4 | def isAvailable: Boolean = require.isDefined 5 | 6 | def require(): Option[T] 7 | 8 | lazy val api = require.getOrElse(throw new ModuleNotFoundException(name)) 9 | } 10 | 11 | private[roshttp] class ModuleNotFoundException(name: String) extends RuntimeException("Module " + name + " not found") 12 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/Modules.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node 2 | 3 | import fr.hmil.roshttp.node.http.{Http, Https} 4 | 5 | import scala.scalajs.js 6 | 7 | 8 | /** 9 | * This object allows to access nodejs builtin modules without explicitely calling require. 10 | * 11 | * If a browser shim is used and is accessible in the global context, it will be returned 12 | * and no call to require() will take place 13 | */ 14 | private[roshttp] object Modules { 15 | 16 | object HttpModule extends Module[Http]("http") { 17 | override def require: Option[Http] = { 18 | if (js.typeOf(js.Dynamic.global.Http) != "undefined") 19 | Some(js.Dynamic.global.Http.asInstanceOf[Http]) 20 | else 21 | Helpers.require("http") 22 | } 23 | } 24 | 25 | object HttpsModule extends Module[Https]("https") { 26 | override def require(): Option[Https] = { 27 | if (js.typeOf(js.Dynamic.global.Https) != "undefined") 28 | Some(js.Dynamic.global.Https.asInstanceOf[Https]) 29 | else 30 | Helpers.require("https") 31 | } 32 | } 33 | 34 | lazy val http: Http = HttpModule.api 35 | lazy val https: Https = HttpsModule.api 36 | } 37 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/buffer/Buffer.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.buffer 2 | 3 | import scala.scalajs.js 4 | import js.annotation._ 5 | 6 | 7 | /** 8 | * Nodejs Buffer API 9 | * 10 | * @see https://nodejs.org/api/buffer.html 11 | */ 12 | @js.native 13 | @JSGlobal 14 | private[roshttp] class Buffer extends js.Object { 15 | // new Buffer(array) 16 | // new Buffer(buffer) 17 | // new Buffer(arrayBuffer) 18 | // new Buffer(size) 19 | // new Buffer(str[, encoding]) 20 | // Class Method: Buffer.byteLength(string[, encoding]) 21 | // Class Method: Buffer.compare(buf1, buf2) 22 | // Class Method: Buffer.concat(list[, totalLength]) 23 | // Class Method: Buffer.isBuffer(obj) 24 | // Class Method: Buffer.isEncoding(encoding) 25 | 26 | @JSBracketAccess 27 | def apply(index: Int): Byte = js.native 28 | @JSBracketAccess 29 | def update(index: Int, v: Byte): Unit = js.native 30 | 31 | def compare(otherBuffer: Buffer): Int = js.native 32 | 33 | def copy(targetBuffer: Buffer): Int = js.native 34 | def copy(targetBuffer: Buffer, targetStart: Int): Int = js.native 35 | def copy(targetBuffer: Buffer, targetStart: Int, sourceStart: Int): Int = js.native 36 | def copy(targetBuffer: Buffer, targetStart: Int, sourceStart: Int, sourceEnd: Int): Int = js.native 37 | 38 | // buf.entries() 39 | 40 | // override def equals(otherBuffer: Buffer): Boolean = js.native 41 | 42 | def fill(value: String): Buffer = js.native 43 | def fill(value: Buffer): Buffer = js.native 44 | def fill(value: Int): Buffer = js.native 45 | def fill(value: String, encoding: String): Buffer = js.native 46 | def fill(value: Buffer, encoding: String): Buffer = js.native 47 | def fill(value: Int, encoding: String): Buffer = js.native 48 | def fill(value: String, offset: Int): Buffer = js.native 49 | def fill(value: Buffer, offset: Int): Buffer = js.native 50 | def fill(value: Int, offset: Int): Buffer = js.native 51 | def fill(value: String, offset: Int, encoding: String): Buffer = js.native 52 | def fill(value: Buffer, offset: Int, encoding: String): Buffer = js.native 53 | def fill(value: Int, offset: Int, encoding: String): Buffer = js.native 54 | def fill(value: String, offset: Int, end: Int): Buffer = js.native 55 | def fill(value: Buffer, offset: Int, end: Int): Buffer = js.native 56 | def fill(value: Int, offset: Int, end: Int): Buffer = js.native 57 | def fill(value: String, offset: Int, end: Int, encoding: String): Buffer = js.native 58 | def fill(value: Buffer, offset: Int, end: Int, encoding: String): Buffer = js.native 59 | def fill(value: Int, offset: Int, end: Int, encoding: String): Buffer = js.native 60 | 61 | def indexOf(value: String): Int = js.native 62 | def indexOf(value: Buffer): Int = js.native 63 | def indexOf(value: Int): Int = js.native 64 | def indexOf(value: String, byteOffset: Int): Int = js.native 65 | def indexOf(value: Buffer, byteOffset: Int): Int = js.native 66 | def indexOf(value: Int, byteOffset: Int): Int = js.native 67 | def indexOf(value: String, byteOffset: Int, encoding: String): Int = js.native 68 | def indexOf(value: Buffer, byteOffset: Int, encoding: String): Int = js.native 69 | def indexOf(value: Int, byteOffset: Int, encoding: String): Int = js.native 70 | 71 | def includes(value: String): Boolean = js.native 72 | def includes(value: Buffer): Boolean = js.native 73 | def includes(value: Int): Boolean = js.native 74 | def includes(value: String, byteOffset: Int): Boolean = js.native 75 | def includes(value: Buffer, byteOffset: Int): Boolean = js.native 76 | def includes(value: Int, byteOffset: Int): Boolean = js.native 77 | def includes(value: String, byteOffset: Int, encoding: String): Boolean = js.native 78 | def includes(value: Buffer, byteOffset: Int, encoding: String): Boolean = js.native 79 | def includes(value: Int, byteOffset: Int, encoding: String): Boolean = js.native 80 | 81 | // buf.keys() 82 | 83 | val length: Int = js.native 84 | 85 | def readDoubleBE(offset: Double): Int = js.native 86 | def readDoubleBE(offset: Double, noAssert: Boolean): Int = js.native 87 | 88 | def readDoubleLE(offset: Double): Int = js.native 89 | def readDoubleLE(offset: Double, noAssert: Boolean): Int = js.native 90 | 91 | def readFloatBE(offset: Float): Int = js.native 92 | def readFloatBE(offset: Float, noAssert: Boolean): Int = js.native 93 | 94 | def readFloatLE(offset: Float): Int = js.native 95 | def readFloatLE(offset: Float, noAssert: Boolean): Int = js.native 96 | 97 | def readInt8(offset: Int): Int = js.native 98 | def readInt8(offset: Int, noAssert: Boolean): Int = js.native 99 | 100 | def readInt16BE(offset: Int): Int = js.native 101 | def readInt16BE(offset: Int, noAssert: Boolean): Int = js.native 102 | 103 | def readInt16LE(offset: Int): Int = js.native 104 | def readInt16LE(offset: Int, noAssert: Boolean): Int = js.native 105 | 106 | def readInt32BE(offset: Int): Int = js.native 107 | def readInt32BE(offset: Int, noAssert: Boolean): Int = js.native 108 | 109 | def readInt32LE(offset: Int): Int = js.native 110 | def readInt32LE(offset: Int, noAssert: Boolean): Int = js.native 111 | 112 | def readIntBE(offset: Int): Int = js.native 113 | def readIntBE(offset: Int, noAssert: Boolean): Int = js.native 114 | 115 | def readIntLE(offset: Int): Int = js.native 116 | def readIntLE(offset: Int, noAssert: Boolean): Int = js.native 117 | 118 | def readUInt8(offset: Int): Int = js.native 119 | def readUInt8(offset: Int, noAssert: Boolean): Int = js.native 120 | 121 | def readUInt16BE(offset: Int): Int = js.native 122 | def readUInt16BE(offset: Int, noAssert: Boolean): Int = js.native 123 | 124 | def readUInt16LE(offset: Int): Int = js.native 125 | def readUInt16LE(offset: Int, noAssert: Boolean): Int = js.native 126 | 127 | def readUInt32BE(offset: Int): Int = js.native 128 | def readUInt32BE(offset: Int, noAssert: Boolean): Int = js.native 129 | 130 | def readUInt32LE(offset: Int): Int = js.native 131 | def readUInt32LE(offset: Int, noAssert: Boolean): Int = js.native 132 | 133 | def readUIntBE(offset: Int): Int = js.native 134 | def readUIntBE(offset: Int, noAssert: Boolean): Int = js.native 135 | 136 | def readUIntLE(offset: Int): Int = js.native 137 | def readUIntLE(offset: Int, noAssert: Boolean): Int = js.native 138 | 139 | def slice(): Buffer = js.native 140 | def slice(start: Int): Buffer = js.native 141 | def slice(start: Int, end: Int): Buffer = js.native 142 | 143 | override def toString(): String = js.native 144 | def toString(encoding: String): String = js.native 145 | def toString(encoding: String, start: Int): String = js.native 146 | def toString(encoding: String, start: Int, end: Int): String = js.native 147 | 148 | def toJSON(): js.Object = js.native 149 | 150 | // buf.values() 151 | 152 | def write(string: String): Int = js.native 153 | def write(string: String, encoding: String): Int = js.native 154 | def write(string: String, offset: Int): Int = js.native 155 | def write(string: String, offset: Int, encoding: String): Int = js.native 156 | def write(string: String, offset: Int, length: Int): Int = js.native 157 | def write(string: String, offset: Int, length: Int, encoding: String): Int = js.native 158 | 159 | def writeDoubleBE(value: Double, offset: Int): Int = js.native 160 | def writeDoubleBE(value: Double, offset: Int, noAssert: Boolean): Int = js.native 161 | 162 | def writeDoubleLE(value: Double, offset: Int): Int = js.native 163 | def writeDoubleLE(value: Double, offset: Int, noAssert: Boolean): Int = js.native 164 | 165 | def writeFloatBE(value: Float, offset: Int): Int = js.native 166 | def writeFloatBE(value: Float, offset: Int, noAssert: Boolean): Int = js.native 167 | 168 | def writeFloatLE(value: Float, offset: Int): Int = js.native 169 | def writeFloatLE(value: Float, offset: Int, noAssert: Boolean): Int = js.native 170 | 171 | def writeInt8(value: Int, offset: Int): Int = js.native 172 | def writeInt8(value: Int, offset: Int, noAssert: Boolean): Int = js.native 173 | 174 | def writeInt16BE(value: Int, offset: Int): Int = js.native 175 | def writeInt16BE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 176 | 177 | def writeInt16LE(value: Int, offset: Int): Int = js.native 178 | def writeInt16LE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 179 | 180 | def writeInt32BE(value: Int, offset: Int): Int = js.native 181 | def writeInt32BE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 182 | 183 | def writeInt32LE(value: Int, offset: Int): Int = js.native 184 | def writeInt32LE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 185 | 186 | def writeIntBE(value: Int, offset: Int): Int = js.native 187 | def writeIntBE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 188 | 189 | def writeIntLE(value: Int, offset: Int): Int = js.native 190 | def writeIntLE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 191 | 192 | def writeUInt8(value: Int, offset: Int): Int = js.native 193 | def writeUInt8(value: Int, offset: Int, noAssert: Boolean): Int = js.native 194 | 195 | def writeUInt16BE(value: Int, offset: Int): Int = js.native 196 | def writeUInt16BE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 197 | 198 | def writeUInt16LE(value: Int, offset: Int): Int = js.native 199 | def writeUInt16LE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 200 | 201 | def writeUInt32BE(value: Int, offset: Int): Int = js.native 202 | def writeUInt32BE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 203 | 204 | def writeUInt32LE(value: Int, offset: Int): Int = js.native 205 | def writeUInt32LE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 206 | 207 | def writeUIntBE(value: Int, offset: Int): Int = js.native 208 | def writeUIntBE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 209 | 210 | def writeUIntLE(value: Int, offset: Int): Int = js.native 211 | def writeUIntLE(value: Int, offset: Int, noAssert: Boolean): Int = js.native 212 | } 213 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/events/EventEmitter.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.events 2 | 3 | import scala.scalajs.js 4 | 5 | @js.native 6 | private[roshttp] trait EventEmitter extends js.Object { 7 | def on(event: String, cb: js.Function1[js.Dynamic, Unit]): Unit = js.native 8 | } 9 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/http/Agent.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.http 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSGlobal 5 | 6 | 7 | /** 8 | * node http agent API. 9 | * 10 | * This facade is not complete! 11 | */ 12 | @js.native 13 | @JSGlobal 14 | private[roshttp] class Agent extends js.Object { 15 | 16 | def this(options: AgentOptions) { 17 | this() 18 | } 19 | 20 | // def createConnection(options: net.SocketOptions): net.Socket = js.native -- Not implemented here 21 | // def createConnection(options: net.SocketOptions, js.Function): net.Socket = js.native -- Not implemented here 22 | 23 | /** 24 | * Destroy any sockets that are currently in use by the agent. 25 | * 26 | * It is usually not necessary to do this. However, if you are using an agent with 27 | * KeepAlive enabled, then it is best to explicitly shut down the agent when you 28 | * know that it will no longer be used. Otherwise, sockets may hang open for quite 29 | * a long time before the server terminates them. 30 | */ 31 | def destroy(): Unit = js.native 32 | 33 | // val freeSockets: 34 | 35 | /** 36 | * Get a unique name for a set of request options, to determine whether a connection 37 | * can be reused. In the http agent, this returns host:port:localAddress. 38 | * In the https agent, the name includes the CA, cert, ciphers, and other 39 | * HTTPS/TLS-specific options that determine socket reusability. 40 | */ 41 | def getName(options: RequestOptions): String = js.native 42 | 43 | 44 | /** 45 | * By default set to 256. For Agents supporting HTTP KeepAlive, this sets the 46 | * maximum number of sockets that will be left open in the free state. 47 | */ 48 | var maxFreeSockets: Integer = js.native 49 | 50 | /** 51 | * By default set to Infinity. Determines how many concurrent sockets the agent 52 | * can have open per origin. Origin is either a 'host:port' or 53 | * 'host:port:localAddress' combination. 54 | */ 55 | var maxSockets: Integer = js.native 56 | 57 | /** 58 | * An object which contains queues of requests that have not yet been assigned 59 | * to sockets. Do not modify. 60 | */ 61 | // val requests 62 | 63 | /** 64 | * An object which contains arrays of sockets currently in use by the Agent. 65 | * Do not modify. 66 | */ 67 | // val sockets: Seq[Socket] = js.native 68 | } 69 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/http/AgentOptions.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.http 2 | 3 | import scala.scalajs.js 4 | 5 | private[roshttp] trait AgentOptions extends js.Object { 6 | val keepAlive: Boolean 7 | val keepAliveMsecs: Integer 8 | val maxSockets: Integer 9 | val maxFreeSockets: Integer 10 | } 11 | 12 | 13 | private[roshttp] object AgentOptions { 14 | 15 | /** 16 | * 17 | * @param keepAlive Keep sockets around in a pool to be used by other requests in the future. Default = false 18 | * @param keepAliveMsecs When using HTTP KeepAlive, how often to send TCP KeepAlive packets over 19 | * sockets being kept alive. Default = 1000. Only relevant if keepAlive is set to true. 20 | * @param maxSockets Maximum number of sockets to allow per host. Default = Infinity. 21 | * @param maxFreeSockets Maximum number of sockets to leave open in a free state. Only relevant 22 | * if keepAlive is set to true. Default = 256. 23 | * @return An AgentOption instance 24 | */ 25 | def apply( 26 | keepAlive: js.UndefOr[Boolean] = js.undefined, 27 | keepAliveMsecs: js.UndefOr[Integer] = js.undefined, 28 | maxSockets: js.UndefOr[Integer] = js.undefined, 29 | maxFreeSockets: js.UndefOr[Integer] = js.undefined 30 | 31 | ): AgentOptions = { 32 | val r = js.Dynamic.literal() 33 | 34 | keepAlive.foreach(r.keepAlive = _) 35 | keepAliveMsecs.foreach(r.keepAliveMsecs = _) 36 | maxSockets.foreach(r.maxSockets = _) 37 | maxFreeSockets.foreach(r.maxFreeSockets = _) 38 | 39 | r.asInstanceOf[AgentOptions] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/http/ClientRequest.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.http 2 | 3 | import fr.hmil.roshttp.node.events.EventEmitter 4 | import fr.hmil.roshttp.node.buffer.Buffer 5 | 6 | import scala.scalajs.js 7 | import js.annotation._ 8 | 9 | /** 10 | * Complete nodejs http ClientRequest API facade 11 | */ 12 | @js.native 13 | @JSGlobal 14 | private[roshttp] class ClientRequest extends EventEmitter { 15 | 16 | /** 17 | * Marks the request as aborting. Calling this will cause remaining data in 18 | * the response to be dropped and the socket to be destroyed. 19 | */ 20 | def abort(): Unit = js.native 21 | 22 | /** 23 | * Finishes sending the request. If any parts of the body are unsent, it will 24 | * flush them to the stream. If the request is chunked, this will send the 25 | * terminating '0\r\n\r\n'. 26 | * 27 | * If data is specified, it is equivalent to calling response.write(data, encoding) 28 | * followed by request.end(callback). 29 | * 30 | * If callback is specified, it will be called when the request stream is finished. 31 | */ 32 | def end(): Unit = js.native 33 | def end(data: Buffer): Unit = js.native 34 | def end(data: Buffer, callback: js.Function0[Unit]): Unit = js.native 35 | def end(data: String): Unit = js.native 36 | def end(data: String, encoding: String): Unit = js.native 37 | def end(data: String, callback: js.Function0[Unit]): Unit = js.native 38 | def end(data: String, encoding: String, callback: js.Function0[Unit]): Unit = js.native 39 | def end(callback: js.Function0[Unit]): Unit = js.native 40 | 41 | 42 | /** 43 | * Flush the request headers. 44 | * 45 | * For efficiency reasons, Node.js normally buffers the request headers until 46 | * you call request.end() or write the first chunk of request data. It then tries 47 | * hard to pack the request headers and data into a single TCP packet. 48 | * 49 | * That's usually what you want (it saves a TCP round-trip) but not when the 50 | * first data isn't sent until possibly much later. request.flushHeaders() lets 51 | * you bypass the optimization and kickstart the request. 52 | */ 53 | def flushHeaders(): Unit = js.native 54 | 55 | /** 56 | * Once a socket is assigned to this request and is connected socket.setNoDelay() 57 | * will be called. 58 | */ 59 | def setNoDelay(noDelay: Boolean): Unit = js.native 60 | def setNoDelay(): Unit = js.native 61 | 62 | 63 | /** 64 | * Once a socket is assigned to this request and is connected socket.setKeepAlive() will be called. 65 | */ 66 | def setSocketKeepAlive(enable: Boolean, initialDelay: Int): Unit = js.native 67 | def setSocketKeepAlive(enable: Boolean): Unit = js.native 68 | def setSocketKeepAlive(initialDelay: Int): Unit = js.native 69 | 70 | /** 71 | * Once a socket is assigned to this request and is connected socket.setTimeout() will be called. 72 | * 73 | * @param timeout Milliseconds before a request is considered to be timed out. 74 | * @param callback Optional function to be called when a timeout occurs. Same as binding to the timeout event. 75 | */ 76 | def setTimeout(timeout: Int, callback: js.Function0[Unit]): Unit = js.native 77 | def setTimeout(timeout: Int): Unit = js.native 78 | 79 | 80 | /** 81 | * Sends a chunk of the body. By calling this method many times, the user can stream 82 | * a request body to aserver--in that case it is suggested to use the ['Transfer-Encoding', 'chunked'] 83 | * header line when creating the request. 84 | * 85 | * @param chunk should be a Buffer or a string. 86 | * @param encoding optional and only applies when chunk is a string. Defaults to 'utf8'. 87 | * @param callback optional and will be called when this chunk of data is flushed. 88 | * @return 89 | */ 90 | def write(chunk: String, encoding: String, callback: js.Function0[Unit]): ClientRequest = js.native 91 | def write(chunk: String): ClientRequest = js.native 92 | def write(chunk: String, encoding: String): ClientRequest = js.native 93 | def write(chunk: String, callback: js.Function0[Unit]): ClientRequest = js.native 94 | def write(chunk: Buffer): ClientRequest = js.native 95 | def write(chunk: Buffer, callback: js.Function0[Unit]): ClientRequest = js.native 96 | 97 | } 98 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/http/Http.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.http 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.{JSGlobal, JSGlobalScope, JSName} 5 | 6 | /** 7 | * The node http API. 8 | * 9 | * Server-related stuff not included. 10 | * createClient not included because it is deprecated. 11 | * 12 | * @see https://nodejs.org/api/http.html 13 | */ 14 | @js.native 15 | private[roshttp] trait Http extends js.Object{ 16 | 17 | /** 18 | * A list of the HTTP methods that are supported by the parser. 19 | */ 20 | val METHODS: Seq[String] = js.native 21 | 22 | /** 23 | * A collection of all the standard HTTP response status codes, and the short 24 | * description of each. For example, http.STATUS_CODES[404] === 'Not Found'. 25 | */ 26 | val STATUS_CODES: Map[Number, String] = js.native 27 | 28 | /** 29 | * Global instance of Agent which is used as the default for all http client requests. 30 | */ 31 | val globalAgent: Agent = js.native 32 | 33 | // http.createServer([requestListener]) -- server-side stuff, not needed in this project 34 | // http.createClient([port][, host]) -- deprecated API, not implemented 35 | 36 | /** 37 | * Since most requests are GET requests without bodies, Node.js provides this convenience 38 | * method. The only difference between this method and http.request() is that it sets the 39 | * method to GET and calls req.end() automatically. 40 | */ 41 | def get(url: String): ClientRequest = js.native 42 | def get(url: String, cb: js.Function1[IncomingMessage, Unit]): ClientRequest = js.native 43 | def get(options: RequestOptions): ClientRequest = js.native 44 | def get(options: RequestOptions, cb: js.Function1[IncomingMessage, Unit]): ClientRequest = js.native 45 | 46 | /** 47 | * Node.js maintains several connections per server to make HTTP requests. This function 48 | * allows one to transparently issue requests. 49 | * options can be an object or a string. If options is a string, it is automatically 50 | * parsed with url.parse(). 51 | */ 52 | def request(url: String): ClientRequest = js.native 53 | def request(url: String, cb: js.Function1[IncomingMessage, Unit]): ClientRequest = js.native 54 | def request(options: RequestOptions): ClientRequest = js.native 55 | def request(options: RequestOptions, cb: js.Function1[IncomingMessage, Unit]): ClientRequest = js.native 56 | } 57 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/http/Https.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.http 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation._ 5 | 6 | /** 7 | * For our purposes, we can just pretend https has the same interface as http 8 | */ 9 | @js.native 10 | private[roshttp] trait Https extends Http 11 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/http/IncomingMessage.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.http 2 | 3 | import fr.hmil.roshttp.node.events.EventEmitter 4 | 5 | import scala.scalajs.js 6 | import scala.scalajs.js.annotation.JSGlobal 7 | 8 | @js.native 9 | @JSGlobal 10 | private[roshttp] class IncomingMessage extends EventEmitter { 11 | val headers: js.Dictionary[String] = js.native 12 | val httpVersion: String = js.native 13 | val method: String = js.native 14 | val rawHeaders: js.Dictionary[String] = js.native 15 | val rawTrailers: js.Dictionary[String] = js.native 16 | def setTimeout(msecs: Int, callback: js.Function): IncomingMessage = js.native 17 | val statusCode: Int = js.native 18 | val statusMessage: String = js.native 19 | // message.socket -- not facaded here 20 | val trailers: js.Dictionary[String] = js.native 21 | val url: String = js.native 22 | 23 | def pause(): Unit = js.native 24 | def resume(): Unit = js.native 25 | } 26 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/http/RequestOptions.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.http 2 | 3 | import scala.scalajs.js 4 | 5 | 6 | 7 | private[roshttp] trait RequestOptions extends js.Object { 8 | val protocol: String 9 | val host: String 10 | val hostname: String 11 | val family: Int 12 | val port: Int 13 | val localAddress: String 14 | val socketPath: String 15 | val method: String 16 | val path: String 17 | val headers: Map[String, String] 18 | val auth: String 19 | val agent: Agent 20 | // val createConnection 21 | } 22 | 23 | private[roshttp] object RequestOptions { 24 | 25 | /** 26 | * @param protocol Protocol to use. Defaults to 'http:'. 27 | * @param host A domain name or IP address of the server to issue the request to. Defaults to 'localhost'. 28 | * @param hostname Alias for host. To support url.parse() hostname is preferred over host. 29 | * @param family IP address family to use when resolving host and hostname. Valid values are 4 or 6. 30 | * When unspecified, both IP v4 and v6 will be used. 31 | * @param port Port of remote server. Defaults to 80. 32 | * @param localAddress Local interface to bind for network connections. 33 | * @param socketPath Unix Domain Socket (use one of host:port or socketPath). 34 | * @param method A string specifying the HTTP request method. Defaults to 'GET'. 35 | * @param path Request path. Defaults to '/'. Should include query string if any. E.G. '/index.html?page=12'. 36 | * An exception is thrown when the request path contains illegal characters. Currently, only 37 | * spaces are rejected but that may change in the future. 38 | * @param headers An object containing request headers. 39 | * @param auth Basic authentication i.e. 'user:password' to compute an Authorization header. 40 | * @param agent Controls Agent behavior. When an Agent is used request will default to Connection: keep-alive. 41 | * @return 42 | */ 43 | def apply( 44 | protocol: js.UndefOr[String] = js.undefined, 45 | host: js.UndefOr[String] = js.undefined, 46 | hostname: js.UndefOr[String] = js.undefined, 47 | family: js.UndefOr[Int] = js.undefined, 48 | port: js.UndefOr[Int] = js.undefined, 49 | localAddress: js.UndefOr[String] = js.undefined, 50 | socketPath: js.UndefOr[String] = js.undefined, 51 | method: js.UndefOr[String] = js.undefined, 52 | path: js.UndefOr[String] = js.undefined, 53 | headers: js.UndefOr[js.Dictionary[String]] = js.undefined, 54 | auth: js.UndefOr[String] = js.undefined, 55 | agent: js.UndefOr[Agent] = js.undefined 56 | 57 | ): RequestOptions = { 58 | val r = js.Dynamic.literal() 59 | 60 | protocol.foreach(r.protocol = _) 61 | host.foreach(r.host = _) 62 | hostname.foreach(r.hostname = _) 63 | family.foreach(r.family = _) 64 | port.foreach(r.port = _) 65 | localAddress.foreach(r.localAddress = _) 66 | socketPath.foreach(r.socketPath = _) 67 | method.foreach(r.method = _) 68 | path.foreach(r.path = _) 69 | headers.foreach(r.headers = _) 70 | auth.foreach(r.auth = _) 71 | agent.foreach(r.agent = _) 72 | 73 | r.asInstanceOf[RequestOptions] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /js/src/main/scala/fr/hmil/roshttp/node/net/SocketOptions.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.node.net 2 | 3 | import scala.scalajs.js 4 | 5 | private[roshttp] trait SocketOptions extends js.Object { 6 | // val fd: FileDescriptor - not implemented here 7 | val allowHalfOpen: Boolean 8 | val readable: Boolean 9 | val writable: Boolean 10 | } 11 | 12 | private[roshttp] object SocketOptions { 13 | def apply( 14 | allowHalfOpen: js.UndefOr[Boolean], 15 | readable: js.UndefOr[Boolean], 16 | writable: js.UndefOr[Boolean] 17 | ): SocketOptions = { 18 | val r = js.Dynamic.literal() 19 | allowHalfOpen.foreach(r.allowHalfOpen = _) 20 | readable.foreach(r.readable = _) 21 | writable.foreach(r.writable = _) 22 | r.asInstanceOf[SocketOptions] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /jvm/src/main/scala/fr/hmil/roshttp/CrossPlatformUtils.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.net.{URLDecoder, URLEncoder} 4 | 5 | private object CrossPlatformUtils { 6 | 7 | def encodeURIComponent(query: String): String = { 8 | URLEncoder.encode(query, "UTF-8").replace("+", "%20") 9 | } 10 | 11 | def decodeURIComponent(query: String): String = 12 | URLDecoder.decode(query, "UTF-8") 13 | } 14 | -------------------------------------------------------------------------------- /jvm/src/main/scala/fr/hmil/roshttp/HttpDriver.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.net.{HttpURLConnection, URL} 4 | import java.nio.ByteBuffer 5 | 6 | import fr.hmil.roshttp.exceptions._ 7 | import fr.hmil.roshttp.response.{HttpResponse, HttpResponseFactory, HttpResponseHeader} 8 | import fr.hmil.roshttp.util.HeaderMap 9 | import monix.execution.Ack.Continue 10 | import monix.execution.{Ack, Scheduler} 11 | import monix.reactive.{Observable, Observer} 12 | import monix.eval.Task 13 | 14 | import scala.concurrent.{Future, Promise, blocking} 15 | 16 | 17 | private object HttpDriver extends DriverTrait { 18 | 19 | def send[T <: HttpResponse] 20 | (req: HttpRequest, responseFactory: HttpResponseFactory[T]) 21 | (implicit scheduler: Scheduler): Future[T] = { 22 | 23 | sendRequest(req).flatMap({ connection => 24 | readResponse(connection, responseFactory, req.backendConfig)}) 25 | } 26 | 27 | private def sendRequest(req: HttpRequest)(implicit scheduler: Scheduler): Future[HttpURLConnection] = { 28 | val p = Promise[HttpURLConnection]() 29 | val connection = new URL(req.url).openConnection().asInstanceOf[HttpURLConnection] 30 | req.headers.foreach(t => connection.addRequestProperty(t._1, t._2)) 31 | connection.setRequestMethod(req.method.toString) 32 | if (req.body.isDefined) { 33 | req.body.foreach({ part => 34 | connection.setDoOutput(true) 35 | if (req.backendConfig.allowChunkedRequestBody) { 36 | req.headers.get("Content-Length") match { 37 | case Some(lengthStr) => 38 | try { 39 | val length = lengthStr.toInt 40 | connection.setFixedLengthStreamingMode(length) 41 | } catch { 42 | case e:NumberFormatException => 43 | p.tryFailure(e) 44 | } 45 | case None => connection.setChunkedStreamingMode(req.backendConfig.maxChunkSize) 46 | } 47 | } 48 | 49 | val os = connection.getOutputStream 50 | part.content.subscribe(new Observer[ByteBuffer] { 51 | override def onError(ex: Throwable): Unit = { 52 | os.close() 53 | p.tryFailure(UploadStreamException(ex)) 54 | } 55 | override def onComplete(): Unit = { 56 | os.close() 57 | p.trySuccess(connection) 58 | } 59 | override def onNext(buffer: ByteBuffer): Future[Ack] = { 60 | if (buffer.hasArray) { 61 | os.write(buffer.array().view.slice(0, buffer.limit()).toArray) 62 | } else { 63 | val tmp = new Array[Byte](buffer.limit) 64 | buffer.get(tmp) 65 | os.write(tmp) 66 | } 67 | Continue 68 | } 69 | }) 70 | }) 71 | } else { 72 | p.trySuccess(connection) 73 | } 74 | p.future 75 | } 76 | 77 | private def readResponse[T <: HttpResponse]( 78 | connection: HttpURLConnection, responseFactory: HttpResponseFactory[T], config: BackendConfig) 79 | (implicit scheduler: Scheduler): Future[T] = { 80 | val code = connection.getResponseCode 81 | val headerMap = HeaderMap(Iterator.from(0) 82 | .map(i => (i, connection.getHeaderField(i))) 83 | .takeWhile(_._2 != null) 84 | .flatMap({ t => 85 | connection.getHeaderFieldKey(t._1) match { 86 | case null => None 87 | case key => Some((key, t._2.mkString.trim)) 88 | } 89 | }).toMap[String, String]) 90 | 91 | val header = new HttpResponseHeader(code, headerMap) 92 | 93 | Future { 94 | blocking { 95 | if (code < 400) { 96 | responseFactory( 97 | header, 98 | inputStreamToObservable(connection.getInputStream, config.maxChunkSize), 99 | config 100 | ) 101 | } else { 102 | responseFactory( 103 | header, 104 | Option(connection.getErrorStream) 105 | .map(is => inputStreamToObservable(is, config.maxChunkSize)) 106 | .getOrElse(Observable.eval(ByteBuffer.allocate(0))), 107 | config 108 | ).map(response => throw HttpException.badStatus(response)) 109 | } 110 | } 111 | }.flatMap(f => f) 112 | } 113 | 114 | private def inputStreamToObservable(in: java.io.InputStream, chunkSize: Int): Observable[ByteBuffer] = { 115 | val iterator = new Iterator[ByteBuffer] { 116 | private var buffer: Array[Byte] = _ 117 | private var lastCount = 0 118 | 119 | def hasNext: Boolean = 120 | lastCount match { 121 | case 0 => 122 | buffer = new Array[Byte](chunkSize) 123 | lastCount = in.read(buffer) 124 | lastCount >= 0 125 | case nr => 126 | nr >= 0 127 | } 128 | 129 | def next(): ByteBuffer = { 130 | if (lastCount < 0) 131 | throw new NoSuchElementException 132 | else { 133 | val result = ByteBuffer.wrap(buffer, 0, lastCount) 134 | lastCount = 0 135 | result 136 | } 137 | } 138 | } 139 | 140 | Observable.fromIterator(Task(iterator)) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /jvm/src/main/scala/fr/hmil/roshttp/JsEnvUtils.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | private object JsEnvUtils { 4 | val userAgent: String = "jvm" 5 | 6 | // Check each browser 7 | val isFirefox = false 8 | val isChrome = false 9 | 10 | val isRealBrowser = false 11 | 12 | // Or you can even just check if running in X 13 | val isRunningInX = false 14 | } 15 | -------------------------------------------------------------------------------- /project/InBrowserTesting.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import sbt.Keys._ 3 | import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._ 4 | import org.scalajs.jsdependencies.sbtplugin.JSDependenciesPlugin.autoImport._ 5 | 6 | import org.scalajs.jsenv.selenium._ 7 | 8 | object InBrowserTesting { 9 | 10 | lazy val testAll = TaskKey[Unit]("test-all", "Run tests in all test platforms.") 11 | 12 | val ConfigFirefox = config("firefox") 13 | val ConfigChrome = config("chrome") 14 | 15 | private def browserConfig(cfg: Configuration, env: SeleniumJSEnv): Project => Project = 16 | _.settings( 17 | inConfig(cfg)( 18 | Defaults.testSettings ++ 19 | // scalaJSTestSettings ++ 20 | Seq( 21 | 22 | // Scala.JS public settings 23 | scalaJSLinkerConfig := (scalaJSLinkerConfig in Test).value, 24 | fastOptJS := (fastOptJS in Test).value, 25 | fullOptJS := (fullOptJS in Test).value, 26 | jsDependencies := (jsDependencies in Test).value, 27 | jsDependencyFilter := (jsDependencyFilter in Test).value, 28 | jsDependencyManifest := (jsDependencyManifest in Test).value, 29 | jsDependencyManifests := (jsDependencyManifests in Test).value, 30 | jsManifestFilter := (jsManifestFilter in Test).value, 31 | // loadedJSEnv := (loadedJSEnv in Test).value, 32 | packageJSDependencies := (packageJSDependencies in Test).value, 33 | packageMinifiedJSDependencies := (packageMinifiedJSDependencies in Test).value, 34 | resolvedJSDependencies := (resolvedJSDependencies in Test).value, 35 | // resolvedJSEnv := (resolvedJSEnv in Test).value, 36 | // scalaJSConsole := (scalaJSConsole in Test).value, 37 | scalaJSIR := (scalaJSIR in Test).value, 38 | scalaJSLinkedFile := (scalaJSLinkedFile in Test).value, 39 | scalaJSNativeLibraries := (scalaJSNativeLibraries in Test).value, 40 | scalajsp := (scalajsp in Test).evaluated, 41 | scalaJSStage := (scalaJSStage in Test).value, 42 | 43 | // Scala.JS internal settings 44 | scalaJSLinker := (scalaJSLinker in Test).value, 45 | 46 | // SBT test settings 47 | definedTestNames := (definedTestNames in Test).value, 48 | definedTests := (definedTests in Test).value, 49 | // executeTests := (executeTests in Test).value, 50 | // loadedTestFrameworks := (loadedTestFrameworks in Test).value, 51 | // testExecution := (testExecution in Test).value, 52 | // testFilter := (testFilter in Test).value, 53 | testForkedParallel := (testForkedParallel in Test).value, 54 | // testFrameworks := (testFrameworks in Test).value, 55 | testGrouping := (testGrouping in Test).value, 56 | // testListeners := (testListeners in Test).value, 57 | // testLoader := (testLoader in Test).value, 58 | // testOnly := (testOnly in Test).value, 59 | testOptions := (testOptions in Test).value, 60 | // testQuick := (testQuick in Test).value, 61 | testResultLogger := (testResultLogger in Test).value, 62 | // test := (test in Test).value, 63 | 64 | // In-browser settings 65 | jsEnv := env))) 66 | 67 | def js: Project => Project = { 68 | //val materializer = new CustomFileMaterializer("test/server/runtime", "http://localhost:3000/runtime") 69 | _.configure( 70 | browserConfig(ConfigFirefox, new SeleniumJSEnv(org.openqa.selenium.remote.DesiredCapabilities.firefox())), 71 | browserConfig(ConfigChrome, new SeleniumJSEnv(org.openqa.selenium.remote.DesiredCapabilities.chrome()))) 72 | .settings( 73 | testAll := { 74 | (test in Test).value 75 | (test in ConfigFirefox).value 76 | (test in ConfigChrome).value 77 | }) 78 | } 79 | 80 | def jvm: Project => Project = 81 | _.settings( 82 | testAll := (test in Test).value) 83 | 84 | } 85 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.3.10 2 | 3 | set logLevel in Global := Level.Debug 4 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | libraryDependencies += "org.scala-js" %% "scalajs-env-selenium" % "1.0.0" 2 | 3 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.0.1") 4 | 5 | addSbtPlugin("org.scalastyle" % "scalastyle-sbt-plugin" % "1.0.0") 6 | 7 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.1") 8 | 9 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.0") 10 | 11 | addSbtPlugin("org.foundweekends" % "sbt-bintray" % "0.5.4") 12 | 13 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") 14 | 15 | addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.0") -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmil/RosHTTP/7711894cebb11a85e44ea040deb69b0bce66c9ad/release.sh -------------------------------------------------------------------------------- /scalastyle-config.xml: -------------------------------------------------------------------------------- 1 | 2 | Scalastyle configuration for Scala.js 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | COLON, COMMA, RPAREN 69 | 70 | 71 | 72 | 73 | LPAREN 74 | 75 | 76 | 77 | 78 | IF, FOR, WHILE, DO, TRY 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /shared/src/main/scala-2.12/fr/hmil/roshttp/util/HeaderMap.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.util 2 | 3 | import fr.hmil.roshttp.util.HeaderMap.CaseInsensitiveString 4 | 5 | 6 | /** A set of HTTP headers identified by case insensitive keys 7 | * 8 | * A Map[CaseInsensitiveString, String] would conform to the strict Map specification 9 | * but it would make the API ugly, forcing the explicit usage of CaseInsensitiveString 10 | * instead of string. 11 | * 12 | * That's why we have the HeaderMap class to represent HTTP headers in a map like 13 | * interface which is nice to use. It is however not *exactly* a map because 14 | * different keys can map to the same value if they are case-insensitive equivalent. 15 | * 16 | * @tparam B Required for MapLike implementation. Should always be set to String. 17 | */ 18 | class HeaderMap[B >: String] private(map: Map[CaseInsensitiveString, B] = Map()) 19 | // extends WithDefault[String, B](new HeaderMap[B](), Map()) { 20 | // extends MapOps[String, B, Map[String, B], HeaderMap[B]] { 21 | extends Map[String, B] { 22 | 23 | override def empty: HeaderMap[B] = new HeaderMap(Map()) 24 | 25 | override def get(key: String): Option[B] = { 26 | map.get(new CaseInsensitiveString(key)) 27 | } 28 | 29 | override def iterator: Iterator[(String, B)] = { 30 | map.map({ t => (t._1.value, t._2)}).iterator 31 | } 32 | 33 | override def +[B1 >: B](kv: (String, B1)): HeaderMap[B1] = { 34 | val key = new CaseInsensitiveString(kv._1) 35 | new HeaderMap[B1](map - key + (key -> kv._2)) 36 | } 37 | 38 | override def -(key: String): HeaderMap[B] = { 39 | new HeaderMap[B](map - new CaseInsensitiveString(key)) 40 | } 41 | 42 | override def updated[B1 >: B](key: String, value: B1): HeaderMap[B1] = { 43 | new HeaderMap[B1](map.updated(new CaseInsensitiveString(key), value)) 44 | } 45 | 46 | override def toString: String = { 47 | map.map({ t => t._1 + ": " + t._2}).mkString("\n") 48 | } 49 | } 50 | 51 | object HeaderMap { 52 | 53 | /** Creates a HeaderMap from a map of string to string. */ 54 | def apply(map: Map[String, String]): HeaderMap[String] = new HeaderMap( 55 | map.map(t => (new CaseInsensitiveString(t._1), t._2)) 56 | ) 57 | 58 | /** Creates an empty HeaderMap. */ 59 | def apply(): HeaderMap[String] = HeaderMap(Map()) 60 | 61 | /** A string whose equals and hashCode methods are case insensitive. */ 62 | class CaseInsensitiveString(val value: String) { 63 | 64 | override def equals(other: Any): Boolean = other match { 65 | case s:CaseInsensitiveString => s.value.equalsIgnoreCase(value) 66 | case _ => false 67 | } 68 | 69 | override def hashCode(): Int = value.toLowerCase.hashCode 70 | 71 | override def toString: String = value 72 | } 73 | } -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/BackendConfig.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | /** Low-level configuration for the HTTP client backend 4 | * 5 | * @param maxChunkSize Maximum size of each data chunk in streamed requests/responses. 6 | * @param internalBufferLength Maximum number of chunks of response data to buffer when the network is faster than what 7 | * the stream consumer can handle. 8 | * @param allowChunkedRequestBody If set to false, HTTP chunked encoding will be disabled (i.e. the request payload 9 | * cannot be streamed). 10 | */ 11 | class BackendConfig private( 12 | val maxChunkSize: Int, 13 | val internalBufferLength: Int, 14 | val allowChunkedRequestBody: Boolean 15 | ) 16 | 17 | object BackendConfig { 18 | def apply( 19 | maxChunkSize: Int = 8192, 20 | internalBufferLength: Int = 128, 21 | allowChunkedRequestBody: Boolean = true 22 | ): BackendConfig = new BackendConfig( 23 | maxChunkSize = maxChunkSize, 24 | internalBufferLength = internalBufferLength, 25 | allowChunkedRequestBody = allowChunkedRequestBody 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/ByteBufferQueue.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.ByteBufferQueue.Feeder 6 | import monix.execution.{Ack, Cancelable} 7 | import monix.execution.Ack.{Continue, Stop} 8 | import monix.reactive.Observable 9 | import monix.reactive.observers.Subscriber 10 | 11 | import scala.collection.mutable 12 | import scala.concurrent.ExecutionContext 13 | import scala.util.{Failure, Success, Try} 14 | 15 | /** 16 | * Mutable queue of byteBuffers which acts as a buffer between a source and a 17 | * sink. This queue guarantees that all operations (data and errors) exit in 18 | * the same order as they entered. 19 | */ 20 | private[roshttp] class ByteBufferQueue( 21 | private val capacity: Int, 22 | private val feeder: Feeder = ByteBufferQueue.noopFeeder) 23 | (implicit ec: ExecutionContext) { 24 | 25 | private var subscriber: Option[Subscriber[ByteBuffer]] = None 26 | private val bufferQueue = mutable.Queue[ByteBuffer]() 27 | private var hasEnd = false 28 | private var isWaitingForAck = false 29 | private var error: Throwable = _ 30 | 31 | private val cancelable = new Cancelable { 32 | override def cancel(): Unit = stop() 33 | } 34 | 35 | def propagate(): Unit = subscriber.foreach({ subscriber => 36 | if (!isWaitingForAck) { 37 | if (bufferQueue.nonEmpty) { 38 | isWaitingForAck = true 39 | val wasFull = isFull 40 | subscriber.onNext(bufferQueue.dequeue()).onComplete(handleAck) 41 | if (wasFull) { 42 | feeder.onFlush() 43 | } 44 | } else if (hasEnd) { 45 | if (error != null) { 46 | subscriber.onError(error) 47 | } 48 | stop() 49 | } 50 | } 51 | }) 52 | 53 | def handleAck(ack: Try[Ack]): Unit = { 54 | isWaitingForAck = false 55 | ack match { 56 | case Success(Stop) => 57 | subscriber = None 58 | case Success(Continue) => 59 | if (bufferQueue.nonEmpty) { 60 | propagate() 61 | } else if (hasEnd) { 62 | stop() 63 | } 64 | case Failure(ex) => 65 | subscriber = None 66 | subscriber.foreach(_.onError(ex)) 67 | } 68 | } 69 | 70 | def push(buffer: ByteBuffer): Unit = { 71 | if (hasEnd) throw new IllegalStateException("Trying to push new data to an ended buffer queue") 72 | if (isFull) throw new IllegalStateException("Buffer queue is full") 73 | bufferQueue.enqueue(buffer) 74 | if (isFull) { 75 | feeder.onFull() 76 | } 77 | if (bufferQueue.nonEmpty) { 78 | propagate() 79 | } 80 | } 81 | 82 | def end(): Unit = { 83 | hasEnd = true 84 | if (bufferQueue.isEmpty) { 85 | stop() 86 | } 87 | } 88 | 89 | def isFull: Boolean = { 90 | bufferQueue.length == capacity 91 | } 92 | 93 | def pushError(error: Throwable): Unit = { 94 | this.error = error 95 | this.hasEnd = true 96 | propagate() 97 | } 98 | 99 | val observable = new Observable[ByteBuffer]() { 100 | override def unsafeSubscribeFn(sub: Subscriber[ByteBuffer]): Cancelable = { 101 | if (subscriber.isDefined) { 102 | throw new IllegalStateException("A subscriber is already defined") 103 | } 104 | subscriber = Some(sub) 105 | if (bufferQueue.nonEmpty) { 106 | propagate() 107 | } else if (hasEnd) { 108 | stop() 109 | } 110 | cancelable 111 | } 112 | } 113 | 114 | private def stop(): Unit = { 115 | subscriber.foreach(_.onComplete()) 116 | } 117 | 118 | def length: Int = { 119 | bufferQueue.length 120 | } 121 | } 122 | 123 | object ByteBufferQueue { 124 | trait Feeder { 125 | def onFull(): Unit 126 | def onFlush(): Unit 127 | } 128 | 129 | private val noopFeeder = new Feeder { 130 | override def onFlush(): Unit = () 131 | override def onFull(): Unit = () 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/DriverTrait.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import fr.hmil.roshttp.response.{HttpResponse, HttpResponseFactory} 4 | import monix.execution.Scheduler 5 | 6 | import scala.concurrent.Future 7 | 8 | private trait DriverTrait { 9 | def send[T <: HttpResponse](req: HttpRequest, factory: HttpResponseFactory[T])(implicit scheduler: Scheduler): 10 | Future[T] 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/HttpRequest.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.net.URI 4 | import java.util.concurrent.TimeUnit 5 | 6 | import fr.hmil.roshttp.body.BodyPart 7 | import fr.hmil.roshttp.exceptions.TimeoutException 8 | import fr.hmil.roshttp.response.{HttpResponse, HttpResponseFactory, SimpleHttpResponse, StreamHttpResponse} 9 | import fr.hmil.roshttp.util.{HeaderMap, Utils} 10 | import monix.execution.Scheduler 11 | 12 | import scala.concurrent.{Future, Promise} 13 | import scala.concurrent.duration.{FiniteDuration, TimeUnit} 14 | import scala.util.{Success, Failure} 15 | 16 | /** Builds an HTTP request. 17 | * 18 | * The request is sent using [[send]]. A request can be sent multiple times. 19 | */ 20 | final class HttpRequest private ( 21 | val method: Method, 22 | val host: String, 23 | val path: String, 24 | val port: Option[Int], 25 | val protocol: Protocol, 26 | val queryString: Option[String], 27 | val crossDomainCookies: Boolean, 28 | val headers: HeaderMap[String], 29 | val body: Option[BodyPart], 30 | val backendConfig: BackendConfig, 31 | val timeout: FiniteDuration) { 32 | 33 | /** The path with the query string or just the path if there is no query string */ 34 | val longPath = path + queryString.map(q => s"?$q").getOrElse("") 35 | 36 | /** The target url for this request */ 37 | val url: String = s"$protocol://$host${port.fold("")(":" + _)}$longPath" 38 | 39 | /** Sets the HTTP method. Defaults to GET. 40 | * 41 | * Beware of browser limitations when using exotic methods. 42 | * 43 | * @param method The new method 44 | * @return A copy of this [[HttpRequest]] with an updated method 45 | */ 46 | def withMethod(method: Method): HttpRequest = 47 | copy(method = method) 48 | 49 | /** Sets the host used in the request URI. 50 | * 51 | * @param host The new host 52 | * @return A copy of this [[HttpRequest]] with an updated host 53 | */ 54 | def withHost(host: String): HttpRequest = 55 | copy(host = host) 56 | 57 | /** Sets the path used in the request URI. 58 | * 59 | * The path is the part that lies between the host (or port if present) 60 | * and the query string or the end of the request. Note that the query 61 | * string is not part of the path. 62 | * 63 | * @param path The new path, including the leading '/' and excluding 64 | * any '?' or '#' and subsequent characters 65 | * @return A copy of this [[HttpRequest]] with an updated path 66 | */ 67 | def withPath(path: String): HttpRequest = 68 | copy(path = path) 69 | 70 | /** Sets the port used in the request URI. 71 | * 72 | * @param port The new port 73 | * @return A copy of this [[HttpRequest]] with an updated port 74 | */ 75 | def withPort(port: Int): HttpRequest = 76 | copy(port = Some(port)) 77 | 78 | /** Discards changes introduced by any call to [[withPort]] 79 | * 80 | * @return A copy of this [[HttpRequest]] with no explicit port. 81 | */ 82 | def withDefaultPort(): HttpRequest = 83 | copy(port = None) 84 | 85 | /** Sets the protocol used in the request URL. 86 | * 87 | * Setting the protocol also sets the port accordingly (80 for HTTP, 443 for HTTPS). 88 | * 89 | * @param protocol The HTTP or HTTPS protocol 90 | * @return A copy of this [[HttpRequest]] with an updated protocol and port 91 | */ 92 | def withProtocol(protocol: Protocol): HttpRequest = { 93 | copy(protocol = protocol) 94 | } 95 | 96 | /** Sets the query string. 97 | * 98 | * The argument is escaped by this method. If you want to bypass the escaping, use [[withQueryStringRaw]]. 99 | * 100 | * Escaping also means that the queryString property will generally not be equal to what you passed as 101 | * argument to [[withQueryString]]. 102 | * 103 | * For instance: `request.withQueryString("äéuô").queryString.get != "%C3%A4%C3%A9u%C3%B4"` 104 | * 105 | * @param queryString The unescaped query string. 106 | * @return A copy of this [[HttpRequest]] with an updated queryString 107 | */ 108 | def withQueryString(queryString: String): HttpRequest = 109 | copy(queryString = Some(Utils.encodeQueryString(queryString))) 110 | 111 | /** Sets the query string without escaping. 112 | * 113 | * Raw query strings must only contain legal characters as per rfc3986. Adding 114 | * special characters yields undefined behaviour. 115 | * 116 | * In most cases, [[withQueryString]] should be preferred. 117 | * 118 | * @param queryString The raw, escaped query string 119 | * @return A copy of this [[HttpRequest]] with an updated queryString 120 | */ 121 | def withQueryStringRaw(queryString: String): HttpRequest = 122 | copy(queryString = Some(queryString)) 123 | 124 | /** Removes the query string. 125 | * 126 | * @return A copy of this [[HttpRequest]] without query string 127 | */ 128 | def withoutQueryString(): HttpRequest = 129 | copy(queryString = None) 130 | 131 | /** Adds a query parameter key/value pair. 132 | * 133 | * Query parameters end up in the query string as `key=value` pairs separated by ampersands. 134 | * Both the key and parameter are escaped to ensure proper query string format. 135 | * 136 | * @param key The unescaped parameter key 137 | * @param value The unescaped parameter value 138 | * @return A copy of this [[HttpRequest]] with an updated query string. 139 | */ 140 | def withQueryParameter(key: String, value: String): HttpRequest = 141 | copy(queryString = Some( 142 | queryString.map(q => q + '&').getOrElse("") + 143 | CrossPlatformUtils.encodeURIComponent(key) + 144 | "=" + 145 | CrossPlatformUtils.encodeURIComponent(value))) 146 | 147 | /** Adds a query array parameter. 148 | * 149 | * Although this is not part of a spec, most servers recognize bracket indices 150 | * in a query string as array indices or object keys. 151 | * 152 | * example: ?list[0]=foo&list[1]=bar 153 | * 154 | * This method formats array values according to the above example. 155 | * 156 | * @param key The unescaped parameter key 157 | * @param values The unescaped parameter array values 158 | * @return A copy of this [[HttpRequest]] with an updated query string. 159 | */ 160 | def withQuerySeqParameter(key: String, values: Seq[String]): HttpRequest = 161 | values.foldLeft(this)((acc, value) => acc.withQueryParameter(key, value)) 162 | 163 | /** Adds an object made of key/value pairs to the query string. 164 | * 165 | * Although this is not part of a spec, most servers recognize bracket indices 166 | * in a query string as object keys. The same key can appear multiple times as 167 | * some server will interpret this as multiple elements in an array for that key. 168 | * 169 | * example: ?obj[foo]=bar&obj[baz]=42 170 | * 171 | * @param key The unescaped parameter key 172 | * @param values The unescaped parameter map values 173 | * @return A copy of this [[HttpRequest]] with an updated query string. 174 | */ 175 | def withQueryObjectParameter(key: String, values: Seq[(String, String)]): HttpRequest = 176 | withQueryParameters(values.map(p => (s"$key[${p._1}]", p._2)): _*) 177 | 178 | /** Adds multiple query parameters. 179 | * 180 | * @param parameters A sequence of new, unescaped parameters. 181 | * @return A copy of this [[HttpRequest]] with an updated query string. 182 | */ 183 | def withQueryParameters(parameters: (String, String)*): HttpRequest = 184 | parameters.foldLeft(this)((acc, entry) => acc.withQueryParameter(entry._1, entry._2)) 185 | 186 | /** Adds or updates a header to the current set of headers. 187 | * 188 | * @param key The header key (case insensitive) 189 | * @param value The header value 190 | * @return A copy of this [[HttpRequest]] with an updated header set. 191 | */ 192 | def withHeader(key: String, value: String): HttpRequest = 193 | copy(headers = headers + (key -> value)) 194 | 195 | /** Adds or updates multiple headers to the current set of headers. 196 | * 197 | * @param newHeaders The headers to add. 198 | * @return A copy of this [[HttpRequest]] with an updated header set. 199 | */ 200 | def withHeaders(newHeaders: (String, String)*): HttpRequest = 201 | copy(headers = HeaderMap(headers ++ newHeaders)) 202 | 203 | /** Allows browser to add a cookie to a cross-domain request, if one was 204 | * received prior from the server. 205 | * 206 | * @param toggle If true, browser is allowed to add cookie to cross-domain request. 207 | * @return A copy of this [[HttpRequest]] with an updated header set. 208 | */ 209 | def withCrossDomainCookies(toggle: Boolean): HttpRequest = 210 | copy(crossDomainCookies = toggle) 211 | 212 | /** Specifies the request timeout. 213 | * 214 | * When a request takes longer than timeout to complete, the future is 215 | * rejected with a [[fr.hmil.roshttp.exceptions.TimeoutException]]. 216 | * 217 | * @param timeout The duration to wait before throwing a timeout exception. 218 | * @return A copu of this [[HttpRequest]] with an updated timeout setting. 219 | */ 220 | def withTimeout(timeout: FiniteDuration): HttpRequest = 221 | copy(timeout = timeout) 222 | 223 | /** Updates request protocol, host, port, path and queryString according to a url. 224 | * 225 | * @param url A valid HTTP url 226 | * @return A copy of this [[HttpRequest]] with updated URL-related attributes. 227 | */ 228 | def withURL(url: String): HttpRequest = { 229 | val parser = new URI(url) 230 | copy( 231 | protocol = if (parser.getScheme != null) Protocol.fromString(parser.getScheme) else protocol, 232 | host = if (parser.getHost != null) parser.getHost else host, 233 | port = if (parser.getPort != -1) Some(parser.getPort) else port, 234 | path = if (parser.getPath != null) parser.getPath else path, 235 | queryString = { 236 | if (parser.getQuery != null) 237 | Some(Utils.encodeQueryString(parser.getQuery)) 238 | else 239 | queryString 240 | } 241 | ) 242 | } 243 | 244 | /** 245 | * Use the provided backend configuration when executing the request 246 | */ 247 | def withBackendConfig(backendConfig: BackendConfig): HttpRequest = { 248 | copy(backendConfig = backendConfig) 249 | } 250 | 251 | /** Attaches a body to this request and sets the Content-Type header. 252 | * 253 | * The body will be sent with the request regardless of other parameters once 254 | * [[send()]] is invoked. Any subsequent call to [[withBody()]], [[send(BodyPart)]], 255 | * [[post(BodyPart)]], [[put(BodyPart)]] or similar methods will override the request body. 256 | * 257 | * The Content-Type header is set to this body's content-type. It can still be manually 258 | * overridden using a method of the [[withHeader()]] family. 259 | * 260 | * Note that the HTTP spec forbids sending data with some methods. In case you need to deal with a broken backend, 261 | * this library allows you to do so anyway. **Beware that in this case, the JVM can still enforce a compliant HTTP 262 | * method**. 263 | */ 264 | def withBody(body: BodyPart): HttpRequest = { 265 | withHeader("Content-Type", body.contentType).copy(body = Some(body)) 266 | } 267 | 268 | 269 | private def _send[T <: HttpResponse](factory: HttpResponseFactory[T])(implicit scheduler: Scheduler): Future[T] = { 270 | val promise = Promise[T] 271 | 272 | val timeoutTask = scheduler.scheduleOnce(timeout.length, timeout.unit, 273 | new Runnable { 274 | override def run(): Unit = { 275 | promise.tryFailure(new TimeoutException) 276 | } 277 | }) 278 | 279 | val backendFuture: Future[T] = HttpDriver.send(this, factory) 280 | backendFuture.onComplete({response => 281 | timeoutTask.cancel() 282 | promise.tryComplete(response) 283 | }) 284 | 285 | promise.future 286 | } 287 | 288 | def stream()(implicit scheduler: Scheduler): Future[StreamHttpResponse] = 289 | _send(StreamHttpResponse) 290 | 291 | /** Sends this request. 292 | * 293 | * A request can be sent multiple times. When a request is sent, it returns a Future[HttpResponse] 294 | * which either succeeds with an [[HttpResponse]] or fails.]] 295 | */ 296 | def send()(implicit scheduler: Scheduler): Future[SimpleHttpResponse] = 297 | _send(SimpleHttpResponse) 298 | 299 | /** Sends this request with the GET method. 300 | * 301 | * @see [[send]] 302 | */ 303 | def get()(implicit scheduler: Scheduler): Future[SimpleHttpResponse] = 304 | withMethod(Method.GET).send() 305 | 306 | /** Sends this request with the POST method and a body 307 | * 308 | * @see [[send]] 309 | * @param body The body to send with the request 310 | */ 311 | def post(body: BodyPart)(implicit scheduler: Scheduler): Future[SimpleHttpResponse] = 312 | withMethod(Method.POST).send(body) 313 | 314 | /** Sends this request with the PUT method and a body 315 | * 316 | * @see [[post]] 317 | * @param body The body to send with the request 318 | */ 319 | def put(body: BodyPart)(implicit scheduler: Scheduler): Future[SimpleHttpResponse] = 320 | withMethod(Method.PUT).send(body) 321 | 322 | /** Sends this request with the OPTIONS method and a body 323 | * 324 | * @see [[post]] 325 | * @param body The body to send with the request 326 | */ 327 | def options(body: BodyPart)(implicit scheduler: Scheduler): Future[SimpleHttpResponse] = 328 | withMethod(Method.OPTIONS).send(body) 329 | 330 | /** Sends this request with a body. 331 | * 332 | * This method should not be used directly. If you want to [[post]] or [[put]] 333 | * some data, you should use the appropriate methods. If you do not want to send 334 | * data with the request, you should use [[post]] without arguments. 335 | * 336 | * @param body The body to send. 337 | */ 338 | def send(body: BodyPart)(implicit scheduler: Scheduler): Future[SimpleHttpResponse] = 339 | withBody(body).send() 340 | 341 | /** Internal method to back public facing .withXXX methods. */ 342 | private def copy( 343 | method: Method = this.method, 344 | host: String = this.host, 345 | path: String = this.path, 346 | port: Option[Int] = this.port, 347 | protocol: Protocol = this.protocol, 348 | queryString: Option[String] = this.queryString, 349 | headers: HeaderMap[String] = this.headers, 350 | crossDomainCookies: Boolean = this.crossDomainCookies, 351 | body: Option[BodyPart] = this.body, 352 | backendConfig: BackendConfig = this.backendConfig, 353 | timeout: FiniteDuration = this.timeout 354 | ): HttpRequest = { 355 | new HttpRequest( 356 | method = method, 357 | host = host, 358 | path = path, 359 | port = port, 360 | protocol = protocol, 361 | queryString = queryString, 362 | crossDomainCookies = crossDomainCookies, 363 | headers = headers, 364 | body = body, 365 | backendConfig = backendConfig, 366 | timeout = timeout) 367 | } 368 | 369 | } 370 | 371 | object HttpRequest { 372 | 373 | private def default = new HttpRequest( 374 | method = Method.GET, 375 | host = null, 376 | path = null, 377 | port = None, 378 | protocol = Protocol.HTTP, 379 | queryString = None, 380 | headers = HeaderMap(), 381 | crossDomainCookies = false, 382 | body = None, 383 | backendConfig = BackendConfig(), 384 | timeout = FiniteDuration(30, TimeUnit.SECONDS) 385 | ) 386 | 387 | /** Creates a blank HTTP request. 388 | * 389 | * It is most of the time more convenient to use apply(String) but this 390 | * constructor allows you to programatically construct a request from scratch. 391 | * 392 | * @return [[HttpRequest.default]] 393 | */ 394 | def apply(): HttpRequest = default 395 | 396 | /** Creates an [[HttpRequest]] with the provided target url. 397 | * 398 | * Same thing as calling `HttpRequest().withURL(url)`. 399 | * 400 | * @param url The target url. 401 | * @return An [[HttpRequest]] ready to GET the target url. 402 | */ 403 | def apply(url: String): HttpRequest = this().withURL(url) 404 | } 405 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/Method.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | /** Wraps HTTP method strings. */ 4 | final class Method private(private val name: String) { 5 | 6 | override def toString: String = name.toUpperCase 7 | 8 | override def equals(o: Any): Boolean = o match { 9 | case that: Method => that.name.equalsIgnoreCase(this.name) 10 | case _ => false 11 | } 12 | 13 | override def hashCode: Int = name.toUpperCase.hashCode 14 | } 15 | 16 | /** Exposes available methods as object as well as an implicit conversion 17 | * from string to Method objects. 18 | * 19 | * Because all backends do not support all methods, this library imposes a subset 20 | * of all available HTTP Methods. Should you find a use case for this library 21 | * with other HTTP methods, please submit an issue with your motivation. 22 | */ 23 | object Method { 24 | val GET = Method("GET") 25 | val POST = Method("POST") 26 | val HEAD = Method("HEAD") 27 | val OPTIONS = Method("OPTIONS") 28 | val PUT = Method("PUT") 29 | val DELETE = Method("DELETE") 30 | /** The PATCH HTTP method does not work on the JVM */ 31 | val PATCH = Method("PATCH") 32 | /** The TRACE HTTP method does not work in the browser */ 33 | val TRACE = Method("TRACE") 34 | 35 | /** Creates a custom http method. 36 | * 37 | * Support for custom methods depends on the backend so use at your own risk! 38 | * 39 | * @param name method name 40 | */ 41 | def apply(name: String): Method = new Method(name) 42 | } 43 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/Protocol.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | /** Defines the protocol used. 4 | * 5 | * When setting a protocol from a string, we want to preserve the initial case such as 6 | * not to alter the url. 7 | */ 8 | final case class Protocol private(private val name: String) { 9 | 10 | override implicit def toString: String = name 11 | 12 | override def equals(o: Any): Boolean = o match { 13 | case that: Protocol => that.name.equalsIgnoreCase(this.name) 14 | case _ => false 15 | } 16 | 17 | override def hashCode: Int = name.hashCode 18 | } 19 | 20 | object Protocol { 21 | val HTTP = fromString("http") 22 | val HTTPS = fromString("https") 23 | 24 | def fromString(name: String): Protocol = name.toUpperCase match { 25 | case "HTTP" => Protocol(name) 26 | case "HTTPS" => Protocol(name) 27 | case _ => throw new IllegalArgumentException(s"Invalid protocol: $name") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/BodyPart.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import monix.reactive.Observable 6 | 7 | trait BodyPart { 8 | def contentType: String 9 | def content: Observable[ByteBuffer] 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/BulkBodyPart.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | import java.nio.ByteBuffer 3 | 4 | import monix.reactive.Observable 5 | 6 | abstract class BulkBodyPart extends BodyPart { 7 | override def content: Observable[ByteBuffer] = Observable.eval(contentData) 8 | def contentData: ByteBuffer 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/ByteBufferBody.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.nio.ByteBuffer 4 | 5 | /** A body containing raw binary data 6 | * 7 | * Usage: Stream bodies are used to send arbitrary binary data such as 8 | * audio, video or any other file attachment. 9 | * The content-type can be overridden to something more specific like `image/jpeg` 10 | * or `audio/wav` for instance. 11 | * It is common to embed a stream body in a [[MultiPartBody]] to send additional information 12 | * with the binary file. 13 | * When possible, send a `Content-Length` header along with an octet-stream body. It may allow the receiver 14 | * end to better handle the loading. 15 | * 16 | * A stream body is sent with the content-type application/octet-stream. 17 | * 18 | * @param data The bytes to send 19 | * @param contentType 20 | */ 21 | class ByteBufferBody private( 22 | data: ByteBuffer, 23 | override val contentType: String 24 | ) extends BulkBodyPart { 25 | override def contentData: ByteBuffer = data 26 | } 27 | 28 | object ByteBufferBody { 29 | def apply(data: ByteBuffer, contentType: String = "application/octet-stream"): ByteBufferBody = 30 | new ByteBufferBody(data, contentType) 31 | } 32 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/Implicits.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.io.InputStream 4 | import java.nio.ByteBuffer 5 | 6 | import fr.hmil.roshttp.body.JSONBody._ 7 | import monix.reactive.Observable 8 | import monix.eval.Task 9 | 10 | 11 | object Implicits { 12 | implicit def stringToJSONString(value: String): JSONString = new JSONString(value) 13 | implicit def intToJSONNumber(value: Int): JSONNumber = new JSONNumber(value) 14 | implicit def floatToJSONNumber(value: Float): JSONNumber = new JSONNumber(value) 15 | implicit def doubleToJSONNumber(value: Double): JSONNumber = new JSONNumber(value) 16 | implicit def booleanToJSONBoolean(value: Boolean):JSONBoolean = new JSONBoolean(value) 17 | implicit def JSONObjectToJSONBody(obj: JSONObject): JSONBody = JSONBody(obj) 18 | implicit def JSONArrayToJSONBody(arr: JSONArray): JSONBody = JSONBody(arr) 19 | 20 | implicit def byteBufferToByteBufferBody(buffer: ByteBuffer): BodyPart = ByteBufferBody(buffer) 21 | implicit def observableToStreamBody(is: InputStream): BodyPart = 22 | StreamBody(Observable.fromInputStream(Task(is)).map(ByteBuffer.wrap)) 23 | } 24 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/JSONBody.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.body.JSONBody.JSONValue 6 | 7 | /** Allows to send arbitrarily complex JSON data. 8 | * 9 | * @param value The JSON value to send. 10 | */ 11 | class JSONBody private(value: JSONValue) extends BulkBodyPart { 12 | override def contentType: String = s"application/json; charset=utf-8" 13 | 14 | override def contentData: ByteBuffer = ByteBuffer.wrap(value.toString.getBytes("utf-8")) 15 | } 16 | 17 | object JSONBody { 18 | trait JSONValue { 19 | def toString: String 20 | } 21 | 22 | class JSONNumber(value: Number) extends JSONValue { 23 | override def toString: String = value.toString 24 | } 25 | 26 | class JSONBoolean(value:Boolean) extends JSONValue { 27 | override def toString: String = value.toString 28 | } 29 | 30 | class JSONString(value: String) extends JSONValue { 31 | override def toString: String = "\"" + escapeJS(value) + "\"" 32 | } 33 | 34 | class JSONObject(values: Map[String, JSONValue]) extends JSONValue { 35 | override def toString: String = { 36 | "{" + 37 | values.map({case (name, part) => 38 | "\"" + escapeJS(name) + "\"" + 39 | ":" + part 40 | }).mkString(",") + 41 | "}" 42 | } 43 | } 44 | 45 | object JSONObject { 46 | def apply(values: (String, JSONValue)*): JSONObject = new JSONObject(Map(values: _*)) 47 | } 48 | 49 | class JSONArray(values: Seq[JSONValue]) extends JSONValue { 50 | override def toString: String = "[" + values.mkString(",") + "]" 51 | } 52 | 53 | object JSONArray { 54 | def apply(values: JSONValue*): JSONArray = new JSONArray(values) 55 | } 56 | 57 | 58 | 59 | def apply(value: JSONValue): JSONBody = new JSONBody(value) 60 | 61 | // String escapement taken from scala-js 62 | private final val EscapeJSChars = "\\a\\b\\t\\n\\v\\f\\r\\\"\\\\" 63 | 64 | private def escapeJS(str: String): String = { 65 | // scalastyle:off return 66 | val end = str.length 67 | var i = 0 68 | while (i != end) { 69 | val c = str.charAt(i) 70 | if (c >= 32 && c <= 126 && c != '\\' && c != '"') 71 | i += 1 72 | else 73 | return createEscapeJSString(str) 74 | } 75 | str 76 | // scalastyle:on return 77 | } 78 | 79 | private def createEscapeJSString(str: String): String = { 80 | val sb = new java.lang.StringBuilder(2 * str.length) 81 | printEscapeJS(str, sb) 82 | sb.toString 83 | } 84 | 85 | private def printEscapeJS(str: String, out: java.lang.Appendable): Unit = { 86 | /* Note that Java and JavaScript happen to use the same encoding for 87 | * Unicode, namely UTF-16, which means that 1 char from Java always equals 88 | * 1 char in JavaScript. */ 89 | val end = str.length() 90 | var i = 0 91 | /* Loop prints all consecutive ASCII printable characters starting 92 | * from current i and one non ASCII printable character (if it exists). 93 | * The new i is set at the end of the appended characters. 94 | */ 95 | while (i != end) { 96 | val start = i 97 | var c: Int = str.charAt(i) 98 | // Find all consecutive ASCII printable characters from `start` 99 | while (i != end && c >= 32 && c <= 126 && c != 34 && c != 92) { 100 | i += 1 101 | if (i != end) 102 | c = str.charAt(i) 103 | } 104 | // Print ASCII printable characters from `start` 105 | if (start != i) 106 | out.append(str, start, i) 107 | 108 | // Print next non ASCII printable character 109 | if (i != end) { 110 | def escapeJSEncoded(c: Int): Unit = { 111 | if (6 < c && c < 14) { 112 | val i = 2 * (c - 7) 113 | out.append(EscapeJSChars, i, i + 2) 114 | } else if (c == 34) { 115 | out.append(EscapeJSChars, 14, 16) 116 | } else if (c == 92) { 117 | out.append(EscapeJSChars, 16, 18) 118 | } else { 119 | out.append(f"\\u$c%04x") 120 | } 121 | } 122 | escapeJSEncoded(c) 123 | i += 1 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/MultiPartBody.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import monix.execution.Scheduler 6 | import monix.reactive.Observable 7 | 8 | import scala.util.Random 9 | 10 | /** A body made of multiple parts. 11 | * 12 | * Usage: A multipart body acts as a container for other bodies. For instance, 13 | * the multipart body is commonly used to send a form with binary attachments in conjunction with 14 | * the [[ByteBufferBody]]. 15 | * For simple key/value pairs, use [[URLEncodedBody]] instead. 16 | * 17 | * Safety consideration: A random boundary is generated to separate parts. If the boundary was 18 | * to occur within a body part, it would mess up the whole body. In practice, the odds are extremely small though. 19 | * 20 | * @param parts The pieces of body. The key in the map is used as `name` for the `Content-Disposition` header 21 | * of each part. 22 | * @param subtype The exact multipart mime type as in `multipart/subtype`. Defaults to `form-data`. 23 | */ 24 | class MultiPartBody(parts: Map[String, BodyPart], subtype: String = "form-data")(implicit scheduler: Scheduler) 25 | extends BodyPart { 26 | 27 | val boundary = "----" + Random.alphanumeric.take(24).mkString.toLowerCase 28 | 29 | override def contentType: String = s"multipart/$subtype; boundary=$boundary" 30 | 31 | override def content: Observable[ByteBuffer] = { 32 | parts. 33 | // Prepend multipart encapsulation boundary and body part headers to 34 | // each body part. 35 | map({ case (name, part) => 36 | ByteBuffer.wrap( 37 | ("\r\n--" + boundary + "\r\n" + 38 | "Content-Disposition: form-data; name=\"" + name + "\"\r\n" + 39 | s"Content-Type: ${part.contentType}\r\n" + 40 | "\r\n").getBytes("utf-8") 41 | ) +: part.content 42 | }). 43 | // Join body parts 44 | reduceLeft((acc, elem) => acc ++ elem). 45 | // Append the closing boundary 46 | :+(ByteBuffer.wrap(s"\r\n--$boundary--\r\n".getBytes("utf-8"))) 47 | } 48 | } 49 | 50 | object MultiPartBody { 51 | def apply(parts: (String, BodyPart)*)(implicit scheduler: Scheduler): MultiPartBody = 52 | new MultiPartBody(Map(parts: _*)) 53 | } 54 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/PlainTextBody.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.nio.ByteBuffer 4 | 5 | /** Plain text body sent as `text/plain` mime type. 6 | * 7 | * @param text The plain text to send 8 | * @param charset Charset used for encoded (defaults to utf-8) 9 | */ 10 | class PlainTextBody private( 11 | text: String, 12 | charset: String 13 | ) extends BulkBodyPart { 14 | 15 | override def contentType: String = "text/plain; charset=" + charset 16 | override def contentData: ByteBuffer = ByteBuffer.wrap(text.getBytes(charset)) 17 | } 18 | 19 | object PlainTextBody { 20 | def apply(text: String, charset: String = "utf-8"): PlainTextBody = new PlainTextBody(text, charset) 21 | } 22 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/StreamBody.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import monix.reactive.Observable 6 | 7 | class StreamBody private( 8 | override val content: Observable[ByteBuffer], 9 | override val contentType: String 10 | ) extends BodyPart 11 | 12 | object StreamBody { 13 | def apply(data: Observable[ByteBuffer], contentType: String = "application/octet-stream"): StreamBody = 14 | new StreamBody(data, contentType) 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/body/URLEncodedBody.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.body 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.CrossPlatformUtils 6 | 7 | /** An urlencoded HTTP body. 8 | * 9 | * Usage: urlencoded bodies are best suited for simple key/value maps of strings. For more 10 | * structured data, use [[JSONBody]]. For binary data, use [[ByteBufferBody]] or [[MultiPartBody]]. 11 | * 12 | * URLEncoded bodies are associated with the mime type "application/x-www-form-urlencoded" 13 | * and look like query string parameters (eg. key=value&key2=value2 ). 14 | * 15 | * @param values A map of key/value pairs to send with the request. 16 | */ 17 | class URLEncodedBody private(values: Map[String, String]) extends BulkBodyPart { 18 | 19 | override def contentType: String = s"application/x-www-form-urlencoded" 20 | 21 | override def contentData: ByteBuffer = ByteBuffer.wrap( 22 | values.map({case (name, part) => 23 | CrossPlatformUtils.encodeURIComponent(name) + 24 | "=" + 25 | CrossPlatformUtils.encodeURIComponent(part) 26 | }).mkString("&").getBytes("utf-8") 27 | ) 28 | } 29 | 30 | object URLEncodedBody { 31 | def apply(values: (String, String)*): URLEncodedBody = new URLEncodedBody(Map(values: _*)) 32 | } 33 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/exceptions/HttpException.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.exceptions 2 | 3 | import java.io.IOException 4 | 5 | import fr.hmil.roshttp.response.{HttpResponse, SimpleHttpResponse, StreamHttpResponse} 6 | 7 | /** Exception in the HTTP application layer. 8 | * 9 | * In other words, this exception occurs when a bad HTTP status code (>= 400) is received. 10 | */ 11 | case class HttpException[+T <: HttpResponse] private(response: T)(message: String = null) 12 | extends IOException(message) 13 | 14 | object HttpException { 15 | def badStatus[T <: HttpResponse](response: T): HttpException[T] = 16 | new HttpException[T](response)(s"Server responded with status ${response.statusCode}") 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/exceptions/RequestException.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.exceptions 2 | 3 | import java.io.IOException 4 | 5 | /** Captures network errors occurring during an HTTP request. 6 | * 7 | * @see [[ResponseException]] 8 | */ 9 | case class RequestException(cause: Throwable) 10 | extends IOException("A network error occurred during HTTP request transmission.", cause) 11 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/exceptions/ResponseException.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.exceptions 2 | 3 | import java.io.IOException 4 | 5 | import fr.hmil.roshttp.response.HttpResponseHeader 6 | 7 | /** Captures network errors occurring during reception of an HTTP response body. 8 | * 9 | * When this exception occurs, HTTP headers have already been received. 10 | * The response header data is recovered in the header field. 11 | * 12 | * @see [[RequestException]] 13 | */ 14 | case class ResponseException private[roshttp]( 15 | cause: Throwable, 16 | header: HttpResponseHeader) 17 | extends IOException("A network error occurred during HTTP response transmission.", cause) 18 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/exceptions/TimeoutException.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.exceptions 2 | 3 | import java.io.IOException 4 | 5 | import fr.hmil.roshttp.response.HttpResponseHeader 6 | 7 | /** Captures timeout exceptions occurring during an HTTP response. */ 8 | case class TimeoutException(header: Option[HttpResponseHeader] = None) 9 | extends IOException("HTTP response timed out.") 10 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/exceptions/UploadStreamException.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.exceptions 2 | 3 | import java.io.IOException 4 | 5 | /** Captures errors in the request body stream. 6 | * 7 | * This exception means that the stream which feeds request body data into the request broke. 8 | */ 9 | case class UploadStreamException(cause: Throwable) 10 | extends IOException("An error occurred upstream while sending request data.", cause) 11 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/response/HttpResponse.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.response 2 | 3 | import fr.hmil.roshttp.util.HeaderMap 4 | 5 | 6 | private[roshttp] trait HttpResponse { 7 | val statusCode: Int 8 | val headers: HeaderMap[String] 9 | val body: Any 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/response/HttpResponseFactory.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.response 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.BackendConfig 6 | import fr.hmil.roshttp.util.HeaderMap 7 | import monix.execution.Scheduler 8 | import monix.reactive.Observable 9 | 10 | import scala.concurrent.Future 11 | 12 | private[roshttp] trait HttpResponseFactory[T <: HttpResponse] { 13 | def apply( 14 | header: HttpResponseHeader, 15 | bodyStream: Observable[ByteBuffer], 16 | config: BackendConfig) 17 | (implicit scheduler: Scheduler): Future[T] 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/response/HttpResponseHeader.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.response 2 | 3 | import fr.hmil.roshttp.util.HeaderMap 4 | 5 | /** Data contained by the header section of an HTTP response message 6 | * 7 | * Most of the time, [[SimpleHttpResponse]] and [[StreamHttpResponse]] are the classes 8 | * you will want to use since they contain both header and body data. 9 | * However, if a network error occurs and the response body cannot be retreived, this class 10 | * is used to represent the header data received. 11 | */ 12 | class HttpResponseHeader(val statusCode: Int, val headers: HeaderMap[String]) 13 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/response/SimpleHttpResponse.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.response 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.BackendConfig 6 | import fr.hmil.roshttp.exceptions.ResponseException 7 | import fr.hmil.roshttp.util.{HeaderMap, Utils} 8 | import monix.execution.Scheduler 9 | import monix.reactive.Observable 10 | 11 | import scala.collection.mutable 12 | import scala.concurrent.{Future, Promise} 13 | import scala.util.{Failure, Success} 14 | 15 | /** 16 | * An HTTP response obtained via an [[fr.hmil.roshttp.HttpRequest]] 17 | */ 18 | class SimpleHttpResponse( 19 | val statusCode: Int, 20 | val headers: HeaderMap[String], 21 | val body: String) 22 | extends HttpResponse 23 | 24 | object SimpleHttpResponse extends HttpResponseFactory[SimpleHttpResponse] { 25 | override def apply( 26 | header: HttpResponseHeader, 27 | bodyStream: Observable[ByteBuffer], 28 | config: BackendConfig) 29 | (implicit scheduler: Scheduler): Future[SimpleHttpResponse] = { 30 | 31 | val charset = Utils.charsetFromContentType(header.headers.getOrElse("content-type", null)) 32 | val buffers = mutable.Queue[ByteBuffer]() 33 | val promise = Promise[SimpleHttpResponse]() 34 | 35 | val streamCollector = bodyStream. 36 | foreach(elem => buffers.enqueue(elem)). 37 | map({_ => 38 | val body = recomposeBody(buffers, config.maxChunkSize, charset) 39 | new SimpleHttpResponse(header.statusCode, header.headers, body) 40 | }) 41 | 42 | streamCollector.onComplete({ 43 | case res:Success[SimpleHttpResponse] => 44 | promise.trySuccess(res.value) 45 | case e:Failure[_] => 46 | promise.tryFailure(new ResponseException(e.exception, header)) 47 | }) 48 | 49 | promise.future 50 | } 51 | 52 | private def recomposeBody(seq: mutable.Queue[ByteBuffer], maxChunkSize: Int, charset: String): String = { 53 | // Allocate maximum expected body length 54 | val buffer = ByteBuffer.allocate(seq.length * maxChunkSize) 55 | val totalBytes = seq.foldLeft(0)({ (count, chunk) => 56 | buffer.put(chunk) 57 | count + chunk.limit() 58 | }) 59 | buffer.limit(totalBytes) 60 | Utils.getStringFromBuffer(buffer, charset) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/response/StreamHttpResponse.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.response 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.BackendConfig 6 | import fr.hmil.roshttp.util.HeaderMap 7 | import monix.execution.Scheduler 8 | import monix.reactive.Observable 9 | 10 | import scala.concurrent.Future 11 | 12 | 13 | class StreamHttpResponse( 14 | val statusCode: Int, 15 | val headers: HeaderMap[String], 16 | val body: Observable[ByteBuffer]) 17 | extends HttpResponse 18 | 19 | object StreamHttpResponse extends HttpResponseFactory[StreamHttpResponse] { 20 | override def apply( 21 | header: HttpResponseHeader, 22 | bodyStream: Observable[ByteBuffer], 23 | config: BackendConfig) 24 | (implicit scheduler: Scheduler): Future[StreamHttpResponse] = 25 | Future.successful(new StreamHttpResponse(header.statusCode, header.headers, bodyStream)) 26 | } 27 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/tools/io/IO.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.tools.io 2 | 3 | import java.io.{ByteArrayOutputStream, OutputStream, Writer, _} 4 | 5 | import scala.annotation.tailrec 6 | import scala.reflect.ClassTag 7 | 8 | /** Very useful IO utilities shamelessly copied from org.scalajs.core.tools.io */ 9 | private[roshttp] object IO { 10 | /** Returns the lines in an input stream. 11 | * Lines do not contain the new line characters. 12 | */ 13 | def readLines(stream: InputStream): List[String] = 14 | readLines(new InputStreamReader(stream)) 15 | 16 | /** Returns the lines in a string. 17 | * Lines do not contain the new line characters. 18 | */ 19 | def readLines(content: String): List[String] = 20 | readLines(new StringReader(content)) 21 | 22 | /** Returns the lines in a reader. 23 | * Lines do not contain the new line characters. 24 | */ 25 | def readLines(reader: Reader): List[String] = { 26 | val br = new BufferedReader(reader) 27 | try { 28 | val builder = List.newBuilder[String] 29 | @tailrec 30 | def loop(): Unit = { 31 | val line = br.readLine() 32 | if (line ne null) { 33 | builder += line 34 | loop() 35 | } 36 | } 37 | loop() 38 | builder.result() 39 | } finally { 40 | br.close() 41 | } 42 | } 43 | 44 | /** Reads the entire content of a reader as a string. */ 45 | def readReaderToString(reader: Reader): String = { 46 | val buffer = newBuffer[Char] 47 | val builder = new StringBuilder 48 | @tailrec 49 | def loop(): Unit = { 50 | val len = reader.read(buffer) 51 | if (len > 0) { 52 | builder.appendAll(buffer, 0, len) 53 | loop() 54 | } 55 | } 56 | loop() 57 | builder.toString() 58 | } 59 | 60 | /** Reads the entire content of an input stream as a UTF-8 string. */ 61 | def readInputStreamToString(stream: InputStream): String = { 62 | val reader = new BufferedReader(new InputStreamReader(stream, "UTF-8")) 63 | readReaderToString(reader) 64 | } 65 | 66 | /** Reads the entire content of an input stream as a byte array. */ 67 | def readInputStreamToByteArray(stream: InputStream): Array[Byte] = { 68 | val builder = new ByteArrayOutputStream() 69 | pipe(stream, builder) 70 | builder.toByteArray 71 | } 72 | /** Pipes data from `in` to `out` */ 73 | def pipe(in: InputStream, out: OutputStream): Unit = { 74 | val buffer = newBuffer[Byte] 75 | 76 | @tailrec 77 | def loop(): Unit = { 78 | val size = in.read(buffer) 79 | if (size > 0) { 80 | out.write(buffer, 0, size) 81 | loop() 82 | } 83 | } 84 | try { 85 | loop() 86 | } catch { 87 | // work around java quirk. See SO thread for details 88 | // http://stackoverflow.com/questions/10333257/java-io-ioexception-premature-eof 89 | case e:IOException if e.getMessage == "Premature EOF" => // ignore 90 | } 91 | } 92 | 93 | /** Pipes data from `in` to `out` */ 94 | def pipe(in: Reader, out: Writer): Unit = { 95 | val buffer = newBuffer[Char] 96 | 97 | @tailrec 98 | def loop(): Unit = { 99 | val size = in.read(buffer) 100 | if (size > 0) { 101 | out.write(buffer, 0, size) 102 | loop() 103 | } 104 | } 105 | loop() 106 | } 107 | 108 | @inline 109 | private def newBuffer[T: ClassTag] = new Array[T](4096) 110 | } 111 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/util/HeaderMap.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.util 2 | 3 | import fr.hmil.roshttp.util.HeaderMap.CaseInsensitiveString 4 | 5 | import scala.collection.compat._ 6 | 7 | 8 | /** A set of HTTP headers identified by case insensitive keys 9 | * 10 | * A Map[CaseInsensitiveString, String] would conform to the strict Map specification 11 | * but it would make the API ugly, forcing the explicit usage of CaseInsensitiveString 12 | * instead of string. 13 | * 14 | * That's why we have the HeaderMap class to represent HTTP headers in a map like 15 | * interface which is nice to use. It is however not *exactly* a map because 16 | * different keys can map to the same value if they are case-insensitive equivalent. 17 | * 18 | * @tparam B Required for MapLike implementation. Should always be set to String. 19 | */ 20 | class HeaderMap[B >: String] private(map: Map[CaseInsensitiveString, B] = Map()) 21 | // extends WithDefault[String, B](new HeaderMap[B](), Map()) { 22 | // extends MapOps[String, B, Map[String, B], HeaderMap[B]] { 23 | extends Map[String, B] { 24 | 25 | override def empty: HeaderMap[B] = new HeaderMap(Map()) 26 | 27 | override def get(key: String): Option[B] = { 28 | map.get(new CaseInsensitiveString(key)) 29 | } 30 | 31 | override def iterator: Iterator[(String, B)] = { 32 | map.map({ t => (t._1.value, t._2)}).iterator 33 | } 34 | 35 | override def +[B1 >: B](kv: (String, B1)): HeaderMap[B1] = { 36 | val key = new CaseInsensitiveString(kv._1) 37 | new HeaderMap[B1](map - key + (key -> kv._2)) 38 | } 39 | 40 | def removed(key: String): HeaderMap[B] = { 41 | new HeaderMap[B](map - new CaseInsensitiveString(key)) 42 | } 43 | 44 | override def updated[B1 >: B](key: String, value: B1): HeaderMap[B1] = { 45 | new HeaderMap[B1](map.updated(new CaseInsensitiveString(key), value)) 46 | } 47 | 48 | override def toString: String = { 49 | map.map({ t => t._1 + ": " + t._2}).mkString("\n") 50 | } 51 | } 52 | 53 | object HeaderMap { 54 | 55 | /** Creates a HeaderMap from a map of string to string. */ 56 | def apply(map: Map[String, String]): HeaderMap[String] = new HeaderMap( 57 | map.map(t => (new CaseInsensitiveString(t._1), t._2)) 58 | ) 59 | 60 | /** Creates an empty HeaderMap. */ 61 | def apply(): HeaderMap[String] = HeaderMap(Map()) 62 | 63 | /** A string whose equals and hashCode methods are case insensitive. */ 64 | class CaseInsensitiveString(val value: String) { 65 | 66 | override def equals(other: Any): Boolean = other match { 67 | case s:CaseInsensitiveString => s.value.equalsIgnoreCase(value) 68 | case _ => false 69 | } 70 | 71 | override def hashCode(): Int = value.toLowerCase.hashCode 72 | 73 | override def toString: String = value 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /shared/src/main/scala/fr/hmil/roshttp/util/Utils.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp.util 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.CrossPlatformUtils 6 | 7 | object Utils { 8 | 9 | /** 10 | * Extracts the charset from a content-type header string 11 | * @param input content-type header value 12 | * @return the charset contained in the content-type or the default 13 | * one-byte encoding charset (to avoid tampering binary buffer). 14 | */ 15 | def charsetFromContentType(input: String): String = { 16 | if (input == null) { 17 | oneByteCharset 18 | } else { 19 | // From W3C spec: 20 | // Content-Type := type "/" subtype *[";" parameter] 21 | // eg: text/html; charset=UTF-8 22 | input.split(';').toStream.drop(1).foldLeft(oneByteCharset)((acc, s) => { 23 | if (s.matches("^\\s*charset=.+$")) { 24 | s.substring(s.indexOf("charset") + 8) 25 | } else { 26 | acc 27 | } 28 | }) 29 | } 30 | } 31 | 32 | /** urlencodes a query string by preserving key-value pairs. */ 33 | def encodeQueryString(queryString: String): String = { 34 | queryString 35 | .split("&") 36 | .map(_ 37 | .split("=") 38 | .map(encodeURIComponent) 39 | .mkString("=")) 40 | .mkString("&") 41 | } 42 | 43 | def encodeURIComponent(input: String): String = CrossPlatformUtils.encodeURIComponent(input) 44 | 45 | def getStringFromBuffer(byteBuffer: ByteBuffer, charset: String): String = { 46 | if (byteBuffer.hasArray) { 47 | new String(byteBuffer.array(), 0, byteBuffer.limit(), charset) 48 | } else { 49 | val tmp = new Array[Byte](byteBuffer.limit) 50 | byteBuffer.get(tmp) 51 | new String(tmp, charset) 52 | } 53 | } 54 | 55 | private val oneByteCharset = "utf-8" 56 | } 57 | -------------------------------------------------------------------------------- /shared/src/test/scala/fr/hmil/roshttp/HttpRequestSpec.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.body.Implicits._ 6 | import fr.hmil.roshttp.body.JSONBody._ 7 | import fr.hmil.roshttp.body._ 8 | import fr.hmil.roshttp.exceptions._ 9 | import fr.hmil.roshttp.response.SimpleHttpResponse 10 | import monix.execution.Scheduler.Implicits.global 11 | import monix.eval.Task 12 | import monix.reactive.Observable 13 | import utest._ 14 | 15 | import scala.util.Failure 16 | 17 | object HttpRequestSpec extends TestSuite { 18 | 19 | private val SERVER_URL = "http://localhost:3000" 20 | 21 | /* 22 | * Status codes defined in HTTP/1.1 spec 23 | */ 24 | private val goodStatus = List( 25 | // We do not support 1xx status codes 26 | 200, 201, 202, 203, 204, 205, 206, 27 | 300, 301, 302, 303, 304, 305, 306, 307 28 | ) 29 | 30 | private def badStatus = { 31 | val base = List( 32 | 400, 401, 402, 403, 404, 405, 406, 408, 409, 33 | 410, 411, 412, 413, 414, 415, 416, 417, 34 | 500, 501, 502, 503, 504, 505 35 | ) 36 | if (JsEnvUtils.isChrome) { 37 | // Chrome does not support userspace 407 error handling 38 | // see: https://bugs.chromium.org/p/chromium/issues/detail?id=372136 39 | base 40 | } else { 41 | 407 :: base 42 | } 43 | } 44 | 45 | private val statusText = Map( 46 | 200 -> "OK", 47 | 201 -> "Created", 48 | 202 -> "Accepted", 49 | 203 -> "Non-Authoritative Information", 50 | 204 -> "", 51 | 205 -> "", 52 | 206 -> "Partial Content", 53 | 300 -> "Multiple Choices", 54 | 301 -> "Moved Permanently", 55 | 302 -> "Found", 56 | 303 -> "See Other", 57 | 304 -> "", 58 | 305 -> "Use Proxy", 59 | 306 -> "306", 60 | 307 -> "Temporary Redirect", 61 | 400 -> "Bad Request", 62 | 401 -> "Unauthorized", 63 | 402 -> "Payment Required", 64 | 403 -> "Forbidden", 65 | 404 -> "Not Found", 66 | 405 -> "Method Not Allowed", 67 | 406 -> "Not Acceptable", 68 | 407 -> "Proxy Authentication Required", 69 | 408 -> "Request Timeout", 70 | 409 -> "Conflict", 71 | 410 -> "Gone", 72 | 411 -> "Length Required", 73 | 412 -> "Precondition Failed", 74 | 413 -> "Payload Too Large", 75 | 414 -> "URI Too Long", 76 | 415 -> "Unsupported Media Type", 77 | 416 -> "Range Not Satisfiable", 78 | 417 -> "Expectation Failed", 79 | 500 -> "Internal Server Error", 80 | 501 -> "Not Implemented", 81 | 502 -> "Bad Gateway", 82 | 503 -> "Service Unavailable", 83 | 504 -> "Gateway Timeout", 84 | 505 -> "HTTP Version Not Supported" 85 | ) 86 | 87 | private val legalMethods = { 88 | val base = "GET" :: "POST" :: "HEAD" :: "OPTIONS" :: "PUT" :: "DELETE" :: Nil 89 | if (JsEnvUtils.isRealBrowser) { 90 | // The jvm cannot send PATCH requests 91 | "PATCH" :: base 92 | } else { 93 | // Browsers cannot send TRACE requests 94 | "TRACE" :: base 95 | } 96 | } 97 | 98 | private val IMAGE_BYTES: Array[Byte] = List[Int]( 99 | 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 100 | 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x08, 0x06, 0x00, 0x00, 0x00, 0xC4, 0x0F, 0xBE, 0x8B, 0x00, 0x00, 0x00, 101 | 0x06, 0x62, 0x4B, 0x47, 0x44, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0xA0, 0xBD, 0xA7, 0x93, 0x00, 0x00, 0x00, 102 | 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x0B, 0x13, 0x00, 0x00, 0x0B, 0x13, 0x01, 0x00, 0x9A, 0x9C, 0x18, 103 | 0x00, 0x00, 0x00, 0x07, 0x74, 0x49, 0x4D, 0x45, 0x07, 0xE0, 0x05, 0x0A, 0x0B, 0x1A, 0x39, 0x9E, 0xB0, 0x43, 104 | 0x04, 0x00, 0x00, 0x00, 0xF2, 0x49, 0x44, 0x41, 0x54, 0x18, 0xD3, 0x45, 0xCD, 0xBD, 0x4A, 0xC3, 0x50, 0x1C, 105 | 0x40, 0xF1, 0x93, 0xFB, 0xBF, 0xB9, 0x4D, 0xD2, 0x56, 0xD4, 0x98, 0xA1, 0x14, 0x05, 0x51, 0x50, 0xA8, 0x76, 106 | 0x13, 0x1C, 0x14, 0xF1, 0x19, 0x7C, 0x07, 0x71, 0xE9, 0x03, 0x08, 0x4E, 0xBE, 0x85, 0x83, 0x83, 0x9B, 0x8B, 107 | 0x8F, 0xA0, 0x93, 0x64, 0xB0, 0xA0, 0x45, 0x07, 0x6D, 0xD0, 0xCD, 0x8F, 0x45, 0x84, 0x54, 0xA9, 0xB1, 0xF9, 108 | 0x72, 0x50, 0xE8, 0x99, 0x7F, 0x70, 0x2C, 0xFE, 0xBB, 0xBF, 0xB8, 0x6C, 0x3E, 0x77, 0xF6, 0x9A, 0x55, 0xB7, 109 | 0xB6, 0x5E, 0x29, 0xF3, 0x35, 0x67, 0x94, 0x6E, 0xB4, 0xEE, 0x7A, 0xF3, 0x16, 0xC0, 0xA9, 0x1F, 0xEC, 0x9A, 110 | 0xA2, 0x38, 0xF2, 0x6C, 0x83, 0xA7, 0x2C, 0x6A, 0xA2, 0x09, 0x1C, 0x27, 0x9E, 0x7D, 0x8A, 0x26, 0x35, 0xC0, 111 | 0x57, 0x59, 0x5A, 0x43, 0xC7, 0x61, 0xA0, 0x35, 0x6F, 0x65, 0x41, 0x94, 0x7C, 0x23, 0x9F, 0xB1, 0x02, 0xD0, 112 | 0x00, 0xFE, 0xE2, 0xC2, 0xB5, 0x2B, 0xF6, 0xAD, 0x79, 0x7D, 0x59, 0x6A, 0xA5, 0x59, 0xA5, 0xE3, 0x78, 0x50, 113 | 0xAD, 0xCB, 0xF2, 0x20, 0xFE, 0x03, 0x3F, 0xFD, 0x68, 0xB5, 0xAE, 0xED, 0x76, 0x43, 0x14, 0x13, 0x22, 0xF4, 114 | 0x8B, 0x9C, 0x30, 0x4B, 0x13, 0x00, 0x05, 0xF0, 0x61, 0x2A, 0x61, 0xB7, 0xBD, 0x72, 0x76, 0xEC, 0x4F, 0x0D, 115 | 0x0F, 0xB5, 0xE2, 0x3C, 0x4B, 0xD9, 0x16, 0x71, 0xC7, 0x0B, 0xCA, 0xCD, 0xC6, 0x4D, 0x6F, 0x67, 0xCB, 0x18, 116 | 0x5C, 0x11, 0x8C, 0x36, 0xB8, 0xDA, 0x1E, 0x03, 0x94, 0x4A, 0x64, 0x26, 0x88, 0xF2, 0x74, 0xF4, 0xC0, 0xB4, 117 | 0xFF, 0x2E, 0x62, 0x85, 0x73, 0xDD, 0xAB, 0x93, 0xC7, 0xFD, 0x03, 0x7E, 0x01, 0x01, 0x9A, 0x49, 0xCF, 0xD0, 118 | 0xA6, 0xE4, 0x8F, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82).map(_.toByte).toArray 119 | 120 | val tests = this { 121 | 122 | test("Meta") - { 123 | test("The test server should be reachable") - { 124 | HttpRequest(SERVER_URL) 125 | .send().map({ s => s.statusCode ==> 200 }) 126 | } 127 | } 128 | 129 | test("Responses") - { 130 | test("with status codes < 400") - { 131 | test("should complete the request with success") - { 132 | goodStatus.map(status => { 133 | HttpRequest(SERVER_URL) 134 | .withPath(s"/status/$status") 135 | .send() 136 | .map({ s => 137 | s.statusCode ==> status 138 | }) 139 | }).reduce((f1, f2) => f1.flatMap(_ => f2)) 140 | } 141 | } 142 | test("with status codes >= 400") - { 143 | test("should complete the request with failure") - { 144 | badStatus.map(status => 145 | HttpRequest(SERVER_URL) 146 | .withPath(s"/status/$status") 147 | .send() 148 | .map(r => r.headers("X-Status-Code") ==> r.statusCode) 149 | .failed.map(_ => "success") 150 | ).reduce((f1, f2) => f1.flatMap(_ => f2)) 151 | } 152 | } 153 | test("with redirects") - { 154 | test("follow redirects") - { 155 | HttpRequest(SERVER_URL) 156 | .withPath("/redirect/temporary/echo/redirected") 157 | .send() 158 | .map(res => { 159 | res.body ==> "redirected" 160 | }) 161 | } 162 | } 163 | test("with timeout") - { 164 | test("Throw the appropriate exception") - { 165 | HttpRequest(s"$SERVER_URL/no_response") 166 | .send().onComplete { 167 | case e: Failure[_] => 168 | e.exception match { 169 | case TimeoutException(_) => () // success 170 | } 171 | case _ => 172 | } 173 | } 174 | } 175 | } 176 | 177 | test("Buffered responses") - { 178 | test("with status code >= 400") - { 179 | test("should provide a response body in error handler") - { 180 | badStatus.map(status => 181 | HttpRequest(SERVER_URL) 182 | .withPath(s"/status/$status") 183 | .send() 184 | .failed.map { 185 | case HttpException(res: SimpleHttpResponse) => 186 | statusText(res.statusCode) ==> res.body 187 | case e => throw new java.lang.AssertionError("Unexpected failure", e) 188 | } 189 | ).reduce((f1, f2) => f1.flatMap(_ => f2)) 190 | } 191 | test("can be empty") - { 192 | HttpRequest(s"$SERVER_URL/empty_body/400") 193 | .send() 194 | .failed 195 | .map { 196 | case HttpException(res: SimpleHttpResponse) => 197 | res.body ==> "" 198 | } 199 | } 200 | } 201 | test("with status code < 400") - { 202 | test("can be empty") - { 203 | HttpRequest(s"$SERVER_URL/empty_body/200") 204 | .send() 205 | .map(response => response.body ==> "") 206 | } 207 | } 208 | test("can be chunked and recomposed") - { 209 | HttpRequest(s"$SERVER_URL/echo_repeat/foo") 210 | .withQueryParameters( 211 | "repeat" -> "4", 212 | "delay" -> "1000") 213 | .withBackendConfig(BackendConfig(maxChunkSize = 4)) 214 | .send() 215 | .map(res => res.body ==> "foofoofoofoo") 216 | } 217 | test("can contain multibyte characters") - { 218 | val payload = "12\uD83D\uDCA978" 219 | HttpRequest(s"$SERVER_URL/multibyte_string") 220 | .send() 221 | .map(res => res.body ==> payload) 222 | } 223 | test("can contain multibyte characters split by chunk boundary") - { 224 | val payload = "12\uD83D\uDCA978" 225 | HttpRequest(s"$SERVER_URL/multibyte_string") 226 | .withBackendConfig(BackendConfig( 227 | maxChunkSize = 4 228 | )) 229 | .send() 230 | .map(res => res.body ==> payload) 231 | } 232 | } 233 | 234 | test("Streamed response body") - { 235 | test("work with a single chunk") - { 236 | val greeting_bytes: ByteBuffer = ByteBuffer.wrap("Hello World!".getBytes) 237 | HttpRequest(s"$SERVER_URL") 238 | .stream() 239 | .map({ r => 240 | // Take only the first element because the body is so short we know it will fit in one buffer 241 | r.body.firstL.map(_.get ==> greeting_bytes) 242 | }) 243 | } 244 | test("fail on bad status code") - { 245 | HttpRequest(SERVER_URL) 246 | .withPath(s"/status/400") 247 | .stream() 248 | .map(r => r.headers("X-Status-Code") ==> r.statusCode) 249 | .failed.map(_ => "success") 250 | } 251 | test("chunks are capped to chunkSize config") - { 252 | val config = BackendConfig(maxChunkSize = 128) 253 | HttpRequest(s"$SERVER_URL/resources/icon.png") 254 | .withBackendConfig(config) 255 | .stream() 256 | .flatMap(_ 257 | .body 258 | .map({ buffer => 259 | assert(buffer.limit() <= config.maxChunkSize) 260 | }) 261 | .bufferTumbling(3) 262 | .firstL.runToFuture 263 | ) 264 | } 265 | } 266 | 267 | test("Query string") - { 268 | test("set in constructor") - { 269 | test("vanilla") - { 270 | HttpRequest(s"$SERVER_URL/query?Hello%20world.") 271 | .send() 272 | .map(res => { 273 | res.body ==> "Hello world." 274 | }) 275 | } 276 | test("with illegal characters") - { 277 | HttpRequest(s"$SERVER_URL/query?Heizölrückstoßabdämpfung%20+") 278 | .send() 279 | .map(res => { 280 | res.body ==> "Heizölrückstoßabdämpfung +" 281 | }) 282 | } 283 | test("with key-value pairs") - { 284 | HttpRequest(s"$SERVER_URL/query/parsed?foo=bar&hello=world") 285 | .send() 286 | .map(res => { 287 | res.body ==> "{\"foo\":\"bar\",\"hello\":\"world\"}" 288 | }) 289 | } 290 | } 291 | test("set in withQueryString") - { 292 | test("vanilla") - { 293 | HttpRequest(s"$SERVER_URL/query") 294 | .withQueryString("Hello world.") 295 | .send() 296 | .map(res => { 297 | res.body ==> "Hello world." 298 | }) 299 | } 300 | test("with illegal characters") - { 301 | HttpRequest(s"$SERVER_URL/query") 302 | .withQueryString("Heizölrückstoßabdämpfung %20+") 303 | .send() 304 | .map(res => { 305 | res.body ==> "Heizölrückstoßabdämpfung %20+" 306 | }) 307 | } 308 | test("is escaped") - { 309 | HttpRequest(s"$SERVER_URL/query") 310 | .withQueryString("Heizölrückstoßabdämpfung") 311 | .queryString.get ==> "Heiz%C3%B6lr%C3%BCcksto%C3%9Fabd%C3%A4mpfung" 312 | } 313 | } 314 | test("set in withRawQueryString") - { 315 | HttpRequest(s"$SERVER_URL/query") 316 | .withQueryStringRaw("Heiz%C3%B6lr%C3%BCcksto%C3%9Fabd%C3%A4mpfung") 317 | .send() 318 | .map(res => { 319 | res.body ==> "Heizölrückstoßabdämpfung" 320 | }) 321 | } 322 | test("set in withQueryParameter") - { 323 | test("single") - { 324 | HttpRequest(s"$SERVER_URL/query/parsed") 325 | .withQueryParameter("device", "neon") 326 | .send() 327 | .map(res => { 328 | res.body ==> "{\"device\":\"neon\"}" 329 | }) 330 | } 331 | test("added in batch") - { 332 | HttpRequest(s"$SERVER_URL/query/parsed") 333 | .withQueryParameters( 334 | "device" -> "neon", 335 | "element" -> "argon") 336 | .send() 337 | .map(res => { 338 | res.body ==> "{\"device\":\"neon\",\"element\":\"argon\"}" 339 | }) 340 | } 341 | test("added in batch with illegal characters") - { 342 | HttpRequest(s"$SERVER_URL/query/parsed") 343 | .withQueryParameters( 344 | " zařízení" -> "topný olej vůle potlačující", 345 | "chäřac+=r&" -> "+Heizölrückstoßabdämpfung=r&") 346 | .send() 347 | .map(res => { 348 | res.body ==> "{\" zařízení\":\"topný olej vůle potlačující\"," + 349 | "\"chäřac+=r&\":\"+Heizölrückstoßabdämpfung=r&\"}" 350 | }) 351 | } 352 | test("added in sequence") - { 353 | HttpRequest(s"$SERVER_URL/query/parsed") 354 | .withQueryParameters( 355 | "element" -> "argon", 356 | "device" -> "chair" 357 | ) 358 | .withQueryParameter("tool", "hammer") 359 | .withQueryParameter("device", "neon") 360 | .send() 361 | .map(res => { 362 | res.body ==> "{\"element\":\"argon\",\"device\":[\"chair\",\"neon\"],\"tool\":\"hammer\"}" 363 | }) 364 | } 365 | test("as list parameter") - { 366 | HttpRequest(s"$SERVER_URL/query/parsed") 367 | .withQuerySeqParameter("map", Seq("foo", "bar")) 368 | .send() 369 | .map(res => { 370 | res.body ==> "{\"map\":[\"foo\",\"bar\"]}" 371 | }) 372 | } 373 | } 374 | test("removed") - { 375 | val req = HttpRequest(s"$SERVER_URL/query/parsed") 376 | .withQueryString("device=chair") 377 | .withoutQueryString() 378 | 379 | assert(req.queryString.isEmpty) 380 | } 381 | } 382 | test("Protocol") - { 383 | test("can be set to HTTP and HTTPS") - { 384 | HttpRequest() 385 | .withProtocol(Protocol.HTTP) 386 | .withProtocol(Protocol.HTTPS) 387 | } 388 | } 389 | 390 | test("Request headers") - { 391 | test("Can be set with a map") - { 392 | val headers = Map( 393 | "accept" -> "text/html, application/xhtml", 394 | "Cache-Control" -> "max-age=0", 395 | "custom" -> "foobar") 396 | 397 | val req = HttpRequest(s"$SERVER_URL/headers") 398 | .withHeaders(headers.toSeq: _*) 399 | 400 | // Test with corrected case 401 | req.headers ==> headers 402 | 403 | req.send().map(res => { 404 | assert(res.body.contains("\"accept\":\"text/html, application/xhtml\"")) 405 | assert(res.body.contains("\"cache-control\":\"max-age=0\"")) 406 | assert(res.body.contains("\"custom\":\"foobar\"")) 407 | }) 408 | } 409 | test("Can be set individually") - { 410 | val req = HttpRequest(s"$SERVER_URL/headers") 411 | .withHeader("cache-control", "max-age=0") 412 | .withHeader("Custom", "foobar") 413 | 414 | req.headers ==> Map( 415 | "cache-control" -> "max-age=0", 416 | "Custom" -> "foobar") 417 | 418 | req.send().map(res => { 419 | assert(res.body.contains("\"cache-control\":\"max-age=0\"")) 420 | assert(res.body.contains("\"custom\":\"foobar\"")) 421 | }) 422 | } 423 | test("Overwrite previous value when set") - { 424 | val req = HttpRequest(s"$SERVER_URL/headers") 425 | .withHeaders( 426 | "accept" -> "text/html, application/xhtml", 427 | "Cache-Control" -> "max-age=0", 428 | "custom" -> "foobar" 429 | ) 430 | .withHeaders( 431 | "Custom" -> "barbar", 432 | "Accept" -> "application/json" 433 | ) 434 | .withHeader("cache-control", "max-age=128") 435 | 436 | req.headers ==> Map( 437 | "cache-control" -> "max-age=128", 438 | "custom" -> "barbar", 439 | "accept" -> "application/json") 440 | 441 | req.send().map(res => { 442 | assert(res.body.contains("\"cache-control\":\"max-age=128\"")) 443 | assert(res.body.contains("\"custom\":\"barbar\"")) 444 | assert(res.body.contains("\"accept\":\"application/json\"")) 445 | }) 446 | } 447 | test("Override body content-type") - { 448 | HttpRequest(s"$SERVER_URL/headers") 449 | .withBody(PlainTextBody("Hello world")) 450 | .withHeader("Content-Type", "text/html") 451 | .withMethod(Method.POST) 452 | .send() 453 | .map(res => { 454 | assert(res.body.contains("\"content-type\":\"text/html\"")) 455 | assert(!res.body.contains("\"content-type\":\"text/plain\"")) 456 | }) 457 | } 458 | } 459 | 460 | test("Response headers") - { 461 | test("can be read in the general case") - { 462 | HttpRequest(s"$SERVER_URL/") 463 | .send() 464 | .map({ 465 | res => 466 | res.headers("X-Powered-By") ==> "Express" 467 | }) 468 | } 469 | test("can be read in the error case") - { 470 | HttpRequest(s"$SERVER_URL/status/400") 471 | .send() 472 | .failed.map { 473 | case HttpException(res: SimpleHttpResponse) => 474 | res.headers("X-Powered-By") ==> "Express" 475 | } 476 | } 477 | } 478 | 479 | test("Http method") - { 480 | test("can be set to any legal value") - { 481 | legalMethods.map(method => 482 | HttpRequest(s"$SERVER_URL/method") 483 | .withMethod(Method(method)) 484 | .send() 485 | .map(_.headers("X-Request-Method") ==> method) 486 | ).reduce((f1, f2) => f1.flatMap(_ => f2)) 487 | } 488 | test("ignores case and capitalizes") - { 489 | legalMethods.map(method => 490 | HttpRequest(s"$SERVER_URL/method") 491 | .withMethod(Method(method.toLowerCase)) 492 | .send() 493 | .map(_.headers("X-Request-Method") ==> method) 494 | ).reduce((f1, f2) => f1.flatMap(_ => f2)) 495 | } 496 | } 497 | 498 | test("Request body") - { 499 | test("Plain text") - { 500 | test("works with ASCII strings") - { 501 | HttpRequest(s"$SERVER_URL/body") 502 | .post(PlainTextBody("Hello world")) 503 | .map({ res => 504 | res.body ==> "Hello world" 505 | res.headers("Content-Type").toLowerCase ==> "text/plain; charset=utf-8" 506 | }) 507 | } 508 | test("works with non-ASCII strings") - { 509 | HttpRequest(s"$SERVER_URL/body") 510 | .post(PlainTextBody("Heizölrückstoßabdämpfung")) 511 | .map({ res => 512 | res.body ==> "Heizölrückstoßabdämpfung" 513 | res.headers("Content-Type").toLowerCase ==> "text/plain; charset=utf-8" 514 | }) 515 | } 516 | } 517 | test("Multipart") - { 518 | test( "works as intended") - { 519 | val part = MultiPartBody( 520 | "foo" -> PlainTextBody("bar"), 521 | "engine" -> PlainTextBody("Heizölrückstoßabdämpfung")) 522 | HttpRequest(s"$SERVER_URL/body") 523 | .post(part) 524 | .map({ res => 525 | res.body ==> "{\"foo\":\"bar\",\"engine\":\"Heizölrückstoßabdämpfung\"}" 526 | res.headers("Content-Type").toLowerCase ==> 527 | s"multipart/form-data; boundary=${part.boundary}; charset=utf-8" 528 | }) 529 | } 530 | } 531 | test("URL encoded") - { 532 | test("works as intended") - { 533 | val part = URLEncodedBody( 534 | "foo" -> "bar", 535 | "engine" -> "Heizölrückstoßabdämpfung") 536 | HttpRequest(s"$SERVER_URL/body") 537 | .post(part) 538 | .map({ res => 539 | res.body ==> "{\"foo\":\"bar\",\"engine\":\"Heizölrückstoßabdämpfung\"}" 540 | res.headers("Content-Type").toLowerCase ==> s"application/x-www-form-urlencoded; charset=utf-8" 541 | }) 542 | } 543 | } 544 | 545 | test("JSON") - { 546 | test("works as intended") - { 547 | val part = JSONObject( 548 | "foo" -> 42, 549 | "bar" -> true, 550 | "engine" -> "Heizölrückstoßabdämpfung", 551 | "\"quoted'" -> "Has \" quotes") 552 | HttpRequest(s"$SERVER_URL/body") 553 | .post(part) 554 | .map({ res => 555 | res.body ==> "{\"foo\":42,\"bar\":true,\"engine\":\"Heizölrückstoßabdämpfung\"," + 556 | "\"\\\"quoted'\":\"Has \\\" quotes\"}" 557 | res.headers("Content-Type").toLowerCase ==> s"application/json; charset=utf-8" 558 | }) 559 | } 560 | } 561 | test("Byte Buffer") - { 562 | test("can send a binary buffer") - { 563 | HttpRequest(s"$SERVER_URL/compare/icon.png") 564 | .post(ByteBufferBody(ByteBuffer.wrap(IMAGE_BYTES))) 565 | } 566 | } 567 | test("streamed") - { 568 | test("with wrapped array ByteBuffer") - { 569 | test("is properly sent") - { 570 | HttpRequest(s"$SERVER_URL/compare/icon.png") 571 | .post( 572 | // Splits the image bytes into chunks to create a streamed body 573 | StreamBody( 574 | Observable.fromIterable(Seq(IMAGE_BYTES: _*) 575 | .grouped(12) 576 | .toSeq 577 | ).map(b => ByteBuffer.wrap(b.toArray)) 578 | ) 579 | ) 580 | } 581 | } 582 | test("with native ByteBuffer") - { 583 | test("is properly sent") - { 584 | val nativeBufferSeq = Seq(IMAGE_BYTES: _*) 585 | .grouped(12) 586 | .map({ chunk => 587 | val b = ByteBuffer.allocateDirect(chunk.size) 588 | var i = 0 589 | while (i < chunk.size) { 590 | b.put(chunk(i)) 591 | i += 1 592 | } 593 | b.rewind() 594 | b 595 | }).toSeq 596 | HttpRequest(s"$SERVER_URL/compare/icon.png") 597 | .post(StreamBody(Observable.fromIterable(nativeBufferSeq))) 598 | } 599 | } 600 | test("with read-only ByteBuffer") - { 601 | test("is properly sent") - { 602 | val readOnlyBuffers = Observable.fromIterable( 603 | Seq(IMAGE_BYTES: _*) 604 | .grouped(12) 605 | .toSeq) 606 | .map({ b => 607 | val res = ByteBuffer.wrap(b.toArray).asReadOnlyBuffer() 608 | assert(!res.hasArray) 609 | res 610 | }) 611 | HttpRequest(s"$SERVER_URL/compare/icon.png") 612 | .post(StreamBody(readOnlyBuffers)) 613 | .recover { 614 | case e: UploadStreamException => 615 | e.printStackTrace() 616 | throw e 617 | } 618 | } 619 | } 620 | test("embedded in multipart") - { 621 | test("handles errors correctly") - { 622 | def stateAction(i: Int) = { 623 | if (i == 0) throw new Exception("Stream error") 624 | (ByteBuffer.allocate(1), i - 1) 625 | } 626 | 627 | HttpRequest(s"$SERVER_URL/does_not_exist") 628 | .post(MultiPartBody("stream" -> StreamBody(Observable.fromStateAction(stateAction)(3)))) 629 | .recover({ 630 | case e: UploadStreamException => e 631 | }) 632 | } 633 | test("is properly sent") - { 634 | val part = MultiPartBody( 635 | "stream" -> StreamBody(Observable.fromIterator(Task(new Iterator[ByteBuffer]() { 636 | private var emitted = false 637 | 638 | override def hasNext: Boolean = !emitted 639 | 640 | override def next(): ByteBuffer = { 641 | emitted = true 642 | ByteBuffer.wrap("Bonjour.".getBytes) 643 | } 644 | })))) 645 | HttpRequest(s"$SERVER_URL/body") 646 | .post(part) 647 | .map({ res => 648 | res.body ==> "{\"stream\":\"Bonjour.\"}" 649 | }) 650 | } 651 | } 652 | test("handles errors correctly") - { 653 | HttpRequest(s"$SERVER_URL/does_not_exist") 654 | .post(StreamBody( 655 | Observable.fromStateAction({ i: Int => 656 | if (i == 0) throw new Exception("Stream error") 657 | (ByteBuffer.allocate(1), i - 1) 658 | })(3) 659 | )) 660 | .recover({ 661 | case e: UploadStreamException => e 662 | }) 663 | } 664 | } 665 | } 666 | 667 | test("CORS cookies") - { 668 | val currentDate = new java.util.Date().getTime().toDouble 669 | 670 | HttpRequest(s"$SERVER_URL/set_cookie") 671 | .withQueryParameters("token" -> currentDate.toString) 672 | .withCrossDomainCookies(true) 673 | .send() 674 | .map(res => { 675 | assert(res.statusCode == 200) 676 | 677 | HttpRequest(s"$SERVER_URL/verify_cookie") 678 | .withQueryParameters("token" -> currentDate.toString) 679 | .withCrossDomainCookies(true) 680 | .send() 681 | .map(res => { 682 | assert(res.statusCode == 200) 683 | }) 684 | }) 685 | } 686 | 687 | } 688 | } 689 | -------------------------------------------------------------------------------- /shared/src/test/scala/fr/hmil/roshttp/StreamingPressureTest.scala: -------------------------------------------------------------------------------- 1 | package fr.hmil.roshttp 2 | 3 | import java.nio.ByteBuffer 4 | 5 | import fr.hmil.roshttp.body.StreamBody 6 | import monix.reactive.Observable 7 | import monix.execution.Scheduler.Implicits.global 8 | import utest._ 9 | import monix.eval.Task 10 | 11 | object StreamingPressureTest extends TestSuite { 12 | 13 | private val SERVER_URL = "http://localhost:3000" 14 | 15 | private val ONE_MILLION = 1000000 16 | 17 | val tests = this{ 18 | 19 | // Send approx. 8 gigs of data to the server to check that there is no leak 20 | // test("Upload streams do not leak") - { 21 | // if (!JsEnvUtils.isRealBrowser) { 22 | // HttpRequest(s"$SERVER_URL/streams/in") 23 | // .post(StreamBody(Observable.fromIterator(Task(new Iterator[ByteBuffer]() { 24 | // override def hasNext: Boolean = true 25 | // override def next(): ByteBuffer = ByteBuffer.allocateDirect(8192) 26 | // })) 27 | // .take(ONE_MILLION))) 28 | // .map(r => r.body ==> "Received 8192000000 bytes.") 29 | // .recover({ 30 | // case e: Throwable => 31 | // e.printStackTrace() 32 | // }) 33 | // } 34 | // } 35 | 36 | // Receive approx. 8 gigs of data to ensure that there is no leak 37 | // test("Download streams do not leak") - { 38 | // // Due to browser incompatibility and node memory leak, run this test only in the JVM 39 | // if (JsEnvUtils.userAgent == "jvm") { 40 | // HttpRequest(s"$SERVER_URL/streams/out") 41 | // .stream() 42 | // .flatMap(_.body.map(_.limit().asInstanceOf[Long]).reduce((l, r) => l + r).runAsyncGetFirst) 43 | // .map(_.get ==> 8192000000L) 44 | // } 45 | // } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /shared/src/test/scala/fr/hmil/roshttp_test/ReadmeSanityCheck.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is automatically generated by test/update-readme-sanity-check.sh 3 | * Do not edit this file manually! 4 | */ 5 | package fr.hmil.roshttp_test 6 | 7 | import utest._ 8 | 9 | object ReadmeSanityCheck extends TestSuite { 10 | // Shims libraryDependencies 11 | class Dep { 12 | def %%%(s: String): String = "%%%" 13 | def %%(s: String): String = "%%" 14 | def %(s: String): String = "%" 15 | } 16 | implicit def fromString(s: String): Dep = new Dep() 17 | var libraryDependencies = Set[Dep]() 18 | 19 | // Silence print output 20 | def println(s: String): Unit = () 21 | 22 | // Test suite 23 | val tests = this { 24 | "Readme snippets compile and run successfully" - { 25 | 26 | import fr.hmil.roshttp.HttpRequest 27 | import monix.execution.Scheduler.Implicits.global 28 | import scala.util.{Failure, Success} 29 | import fr.hmil.roshttp.response.SimpleHttpResponse 30 | 31 | // Runs consistently on the jvm, in node.js and in the browser! 32 | val request = HttpRequest("https://schema.org/WebPage") 33 | 34 | request.send().onComplete({ 35 | case res:Success[SimpleHttpResponse] => println(res.get.body) 36 | case e: Failure[SimpleHttpResponse] => println("Houston, we got a problem!") 37 | }) 38 | 39 | import fr.hmil.roshttp.Protocol.HTTP 40 | 41 | HttpRequest() 42 | .withProtocol(HTTP) 43 | .withHost("localhost") 44 | .withPort(3000) 45 | .withPath("/weather") 46 | .withQueryParameter("city", "London") 47 | .send() // GET http://localhost:3000/weather?city=London 48 | 49 | // Sets the query string such that the target url ends in "?hello%20world" 50 | request.withQueryString("hello world") 51 | 52 | request 53 | .withQueryParameter("foo", "bar") 54 | .withQuerySeqParameter("table", Seq("a", "b", "c")) 55 | .withQueryObjectParameter("map", Seq( 56 | "d" -> "dval", 57 | "e" -> "e value" 58 | )) 59 | .withQueryParameters( 60 | "license" -> "MIT", 61 | "copy" -> "© 2016" 62 | ) 63 | /* Query is now: 64 | foo=bar&table=a&table=b&table=c&map[d]=dval&map[e]=e%20value&license=MIT©=%C2%A9%202016 65 | */ 66 | 67 | import fr.hmil.roshttp.Method.PUT 68 | 69 | request.withMethod(PUT).send() 70 | 71 | request.withHeader("Accept", "text/html") 72 | 73 | request.withHeaders( 74 | "Accept" -> "text/html", 75 | "User-Agent" -> "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" 76 | ) 77 | 78 | import fr.hmil.roshttp.BackendConfig 79 | 80 | HttpRequest("long.source.of/data") 81 | .withBackendConfig(BackendConfig( 82 | // Uses stream chunks of at most 1024 bytes 83 | maxChunkSize = 1024 84 | )) 85 | .stream() 86 | 87 | request.withCrossDomainCookies(true) 88 | 89 | request.send().map({res => 90 | println(res.headers("Set-Cookie")) 91 | }) 92 | 93 | import fr.hmil.roshttp.body.Implicits._ 94 | import fr.hmil.roshttp.body.URLEncodedBody 95 | 96 | val urlEncodedData = URLEncodedBody( 97 | "answer" -> "42", 98 | "platform" -> "jvm" 99 | ) 100 | request.post(urlEncodedData) 101 | // or 102 | request.put(urlEncodedData) 103 | 104 | import fr.hmil.roshttp.body.Implicits._ 105 | import fr.hmil.roshttp.body.JSONBody._ 106 | 107 | val jsonData = JSONObject( 108 | "answer" -> 42, 109 | "platform" -> "node" 110 | ) 111 | request.post(jsonData) 112 | 113 | import java.nio.ByteBuffer 114 | import fr.hmil.roshttp.body.ByteBufferBody 115 | 116 | val buffer = ByteBuffer.wrap( 117 | List[Byte](0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x0a) 118 | .toArray) 119 | request.post(ByteBufferBody(buffer)) 120 | 121 | import fr.hmil.roshttp.body.Implicits._ 122 | import fr.hmil.roshttp.body.JSONBody._ 123 | import fr.hmil.roshttp.body._ 124 | 125 | request.post(MultiPartBody( 126 | // The name part is sent as plain text 127 | "name" -> PlainTextBody("John"), 128 | // The skills part is a complex nested structure sent as JSON 129 | "skills" -> JSONObject( 130 | "programming" -> JSONObject( 131 | "C" -> 3, 132 | "PHP" -> 1, 133 | "Scala" -> 5 134 | ), 135 | "design" -> true 136 | ), 137 | "hobbies" -> JSONArray( 138 | "programming", 139 | "stargazing" 140 | ), 141 | // The picture is sent using a ByteBufferBody, assuming buffer is a ByteBuffer 142 | // containing the image data 143 | "picture" -> ByteBufferBody(buffer, "image/jpeg") 144 | )) 145 | 146 | import fr.hmil.roshttp.util.Utils._ 147 | 148 | request 149 | .stream() 150 | .map({ r => 151 | r.body.foreach(buffer => println(getStringFromBuffer(buffer, "UTF-8"))) 152 | }) 153 | 154 | import fr.hmil.roshttp.Method.POST 155 | 156 | request 157 | .withMethod(POST) 158 | .withBody(PlainTextBody("My upload data")) 159 | .stream() 160 | // The response will be streamed 161 | 162 | val inputStream = new java.io.ByteArrayInputStream(new Array[Byte](1)) 163 | 164 | import fr.hmil.roshttp.body.Implicits._ 165 | 166 | // On the JVM: 167 | // val inputStream = new java.io.FileInputStream("video.avi") 168 | request 169 | .post(inputStream) 170 | .onComplete({ 171 | case _:Success[SimpleHttpResponse] => println("Data successfully uploaded") 172 | case _:Failure[SimpleHttpResponse] => println("Error: Could not upload stream") 173 | }) 174 | 175 | import fr.hmil.roshttp.exceptions.HttpException 176 | import java.io.IOException 177 | request.send() 178 | .recover { 179 | case HttpException(e: SimpleHttpResponse) => 180 | // Here we may have some detailed application-level insight about the error 181 | println("There was an issue with your request." + 182 | " Here is what the application server says: " + e.body) 183 | case e: IOException => 184 | // By handling transport issues separately, you get a chance to apply 185 | // your own recovery strategy. Should you report to the user? Log the error? 186 | // Retry the request? Send an alert to your ops team? 187 | println("There was a network issue, please try again") 188 | } 189 | 190 | "Success" 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /test/resources/README: -------------------------------------------------------------------------------- 1 | Files in this folder are diffed with files uploaded to /upload. 2 | The server returns a success when the file matches and a failure otherwise 3 | -------------------------------------------------------------------------------- /test/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmil/RosHTTP/7711894cebb11a85e44ea040deb69b0bce66c9ad/test/resources/icon.png -------------------------------------------------------------------------------- /test/server/.gitignore: -------------------------------------------------------------------------------- 1 | runtime/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /test/server/README: -------------------------------------------------------------------------------- 1 | This folder contains a nodejs web server which is used to test RösHTTP. 2 | The server serves both a testing API and the runtime scripts required by the sbt 3 | scala-js test runner. 4 | 5 | To setup the server, run `npm install` in this directory 6 | To run the server, run `node index.js` 7 | -------------------------------------------------------------------------------- /test/server/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | var morgan = require('morgan'); 4 | var querystring = require('querystring'); 5 | var bodyParser = require('body-parser'); 6 | var multipart = require('connect-multiparty'); 7 | var fs = require('fs'); 8 | var path = require('path'); 9 | var cookieParser = require('cookie-parser') 10 | var cors = require('cors') 11 | 12 | app.use(morgan('combined')); 13 | app.use(cookieParser()) 14 | app.use(cors({ 15 | credentials: true, 16 | preflightContinue: true, 17 | origin: true, 18 | exposedHeaders: [ 'X-Powered-By', 'X-Request-Method'], 19 | methods: ["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH", "TRACE"] 20 | }) 21 | ) 22 | app.use(multipart()); 23 | 24 | // parse application/x-www-form-urlencoded 25 | app.use(bodyParser.urlencoded({ extended: false })); 26 | 27 | // parse application/json 28 | app.use(bodyParser.json()); 29 | 30 | // parse all other types 31 | app.use(bodyParser.raw({ 32 | type: 'text/plain' 33 | })); 34 | 35 | // Profiling middleware 36 | app.use(function(req, res, next) { 37 | var beginTime = Date.now(); 38 | var end = res.end; 39 | res.end = function() { 40 | var endTime = Date.now(); 41 | console.log("Spent " + (endTime - beginTime) + "ms on " + req.path); 42 | end.apply(this, arguments); 43 | }; 44 | next(); 45 | }); 46 | 47 | app.get('/status/:statusCode', function(req, res) { 48 | res.set('X-Status-Code', req.params.statusCode); 49 | res.sendStatus(req.params.statusCode); 50 | }); 51 | 52 | app.get('/redirect/temporary/*', function(req, res) { 53 | res.statusCode = 302; 54 | res.append('Location', '/' + req.params[0]); 55 | res.write("redirecting..."); 56 | res.end(); 57 | }); 58 | 59 | app.get('/raw_greeting', function(req, res) { 60 | res.write("Hello world").end(); // Does not send Content-Type header 61 | }); 62 | 63 | app.get('/redirect/permanent/*', function(req, res) { 64 | res.redirect(301 ,'/' + req.params[0]); 65 | }); 66 | 67 | 68 | app.get('/', function (req, res) { 69 | res.send('Hello World!'); 70 | }); 71 | 72 | app.get('/echo/:text', function(req, res) { 73 | res.set('Content-Type', 'text/plain'); 74 | res.send(req.params.text); 75 | }); 76 | 77 | app.get('/echo_repeat/:text', function(req, res) { 78 | res.set('Content-Type', 'text/plain'); 79 | var repeat = req.query.repeat || 1; 80 | var delay = req.query.delay || 1000; 81 | function sendOne() { 82 | res.write(req.params.text); 83 | repeat--; 84 | if (repeat > 0) { 85 | setTimeout(sendOne, delay); 86 | } else { 87 | res.end(); 88 | } 89 | } 90 | sendOne(); 91 | }); 92 | 93 | app.get('/multibyte_string', function(req, res) { 94 | res.send("12\uD83D\uDCA978"); 95 | }); 96 | 97 | app.get('/query', function(req, res) { 98 | var url = req.url; 99 | var qPos = url.indexOf('?') + 1; 100 | if (qPos > 0) { 101 | var hPos = url.substr(qPos).indexOf('#'); 102 | var query = url.substring(qPos, hPos < 0 ? undefined : hPos); 103 | res.send(querystring.unescape(query)); 104 | } else { 105 | res.send(''); 106 | } 107 | }); 108 | 109 | app.get('/query/parsed', function(req, res) { 110 | res.send(req.query); 111 | }); 112 | 113 | app.all('/headers', function(req, res) { 114 | res.set('Content-Type', 'text/plain'); 115 | res.send(JSON.stringify(req.headers).replace(/"(?!\\),/g, '"\n').replace(/(^{|}$)/g, '')); 116 | }); 117 | 118 | app.all('/method', function(req, res) { 119 | res.set('X-Request-Method', req.method); 120 | res.send(req.method); 121 | }); 122 | 123 | app.options('/body', function(req, res) { 124 | res.end(); 125 | }); 126 | 127 | app.all('/body', function(req, res) { 128 | if (!req.headers.hasOwnProperty('content-type')) { 129 | res.status(400).send("No request body!"); 130 | } else if(req.body.length === 0) { 131 | res.status(400).send("Empty request body!"); 132 | } else { 133 | res.set('Content-Type', req.headers['content-type']); 134 | res.send(req.body); 135 | } 136 | }); 137 | 138 | app.all('/empty_body/:statusCode', function(req, res) { 139 | res.set('X-Status-Code', req.params.statusCode); 140 | res.status(req.params.statusCode).send(''); 141 | }); 142 | 143 | app.post('/compare/:name', function(req, res) { 144 | bodyParser.raw()(req, res, function(err) { 145 | var fdata = fs.readFileSync(path.join(__dirname, "../resources", req.params.name)); 146 | 147 | if (fdata.compare(req.body) == 0) { 148 | res.status(200).send('Ok'); 149 | } else { 150 | res.status(400).send(req.body); 151 | } 152 | }); 153 | }); 154 | 155 | function logOverwrite(line) { 156 | process.stdout.clearLine(); // clear current text 157 | process.stdout.cursorTo(0); // move cursor to beginning of line 158 | process.stdout.write(line); 159 | } 160 | 161 | app.post('/streams/in', function(req, res) { 162 | var count = 0; 163 | req.on('data', function(chunk) { 164 | var prev = count; 165 | count += chunk.length 166 | if (prev % 16777216 > count % 16777216) { 167 | logOverwrite("Upload stream : " + count + " loaded"); 168 | } 169 | }); 170 | req.on('end', function() { 171 | process.stdout.write("\n"); 172 | res.send('Received ' + count + ' bytes.'); 173 | }) 174 | }); 175 | 176 | app.get('/set_cookie', function(req, res) { 177 | if (req.query.token !== undefined || req.cookies === undefined || Object.keys(req.cookies).length === 0) { 178 | res.cookie('test_cookie', req.query.token); 179 | res.status(200).send("Cookie request answered by sending cookie to client."); 180 | } else { 181 | res.status(500).send('Server sent no cookie to client!') 182 | } 183 | }); 184 | 185 | app.get('/verify_cookie', function(req, res) { 186 | var testCookie = req.cookies['test_cookie']; 187 | if (testCookie === undefined) { 188 | res.status(500).send('Cookie not received from client!'); 189 | } else { 190 | if (testCookie === req.query.token) { 191 | res.status(200).send('Cookie received from client.') 192 | } else { 193 | res.status(500).send('Cookie received from client, but contained data was wrong!') 194 | } 195 | } 196 | }); 197 | 198 | var ONE_MILLION = 1000000; 199 | app.get('/streams/out', function(req, res) { 200 | var buff = Buffer.alloc(8192, 97); 201 | var remaining = ONE_MILLION; 202 | var increment = remaining / 100; 203 | res.set('Content-Type', 'application/octet-stream'); 204 | res.set('Transfer-Encoding', 'chunked'); 205 | 206 | function niceWrite() { 207 | while (remaining > 0) { 208 | remaining --; 209 | if (remaining % increment === 0) { 210 | logOverwrite("Download stream: " + remaining + " chunks to go.") 211 | } 212 | if (res.write(buff) === false) { 213 | res.once('drain', niceWrite); 214 | return; 215 | } 216 | } 217 | process.stdout.write("\n"); 218 | res.end(); 219 | } 220 | niceWrite(); 221 | }); 222 | 223 | app.use('/resources', express.static('../resources')); 224 | app.use('/runtime', express.static('runtime')); 225 | 226 | app.listen(3000, function () { 227 | console.log('Test server listening on port 3000.'); 228 | }); 229 | -------------------------------------------------------------------------------- /test/server/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roshttp-test-server", 3 | "version": "0.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "accepts": { 8 | "version": "1.3.7", 9 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", 10 | "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", 11 | "requires": { 12 | "mime-types": "2.1.27", 13 | "negotiator": "0.6.2" 14 | } 15 | }, 16 | "array-flatten": { 17 | "version": "1.1.1", 18 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 19 | "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" 20 | }, 21 | "basic-auth": { 22 | "version": "2.0.1", 23 | "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", 24 | "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", 25 | "requires": { 26 | "safe-buffer": "5.1.2" 27 | } 28 | }, 29 | "body-parser": { 30 | "version": "1.15.1", 31 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.15.1.tgz", 32 | "integrity": "sha1-m87vBmm4+LlD8K2M5dlXFr10D9I=", 33 | "requires": { 34 | "bytes": "2.3.0", 35 | "content-type": "1.0.1", 36 | "debug": "2.2.0", 37 | "depd": "1.1.0", 38 | "http-errors": "1.4.0", 39 | "iconv-lite": "0.4.13", 40 | "on-finished": "2.3.0", 41 | "qs": "6.1.0", 42 | "raw-body": "2.1.6", 43 | "type-is": "1.6.12" 44 | }, 45 | "dependencies": { 46 | "bytes": { 47 | "version": "2.3.0", 48 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz", 49 | "integrity": "sha1-1baAoWW2IBc5rLYRVCqrwtjOsHA=" 50 | }, 51 | "content-type": { 52 | "version": "1.0.1", 53 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.1.tgz", 54 | "integrity": "sha1-oZ0iRzJ9wDgFDOYit6FU7FnF5gA=" 55 | }, 56 | "debug": { 57 | "version": "2.2.0", 58 | "resolved": "http://registry.npmjs.org/debug/-/debug-2.2.0.tgz", 59 | "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", 60 | "requires": { 61 | "ms": "0.7.1" 62 | }, 63 | "dependencies": { 64 | "ms": { 65 | "version": "0.7.1", 66 | "resolved": "http://registry.npmjs.org/ms/-/ms-0.7.1.tgz", 67 | "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" 68 | } 69 | } 70 | }, 71 | "depd": { 72 | "version": "1.1.0", 73 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz", 74 | "integrity": "sha1-4b2Cxqq2ztlluXuIsX7T5SjKGMM=" 75 | }, 76 | "http-errors": { 77 | "version": "1.4.0", 78 | "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.4.0.tgz", 79 | "integrity": "sha1-bAJC3qaz33r9oVPHEImzHG6Cqr8=", 80 | "requires": { 81 | "inherits": "2.0.1", 82 | "statuses": "1.2.1" 83 | }, 84 | "dependencies": { 85 | "inherits": { 86 | "version": "2.0.1", 87 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", 88 | "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=" 89 | }, 90 | "statuses": { 91 | "version": "1.2.1", 92 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz", 93 | "integrity": "sha1-3e1FzBglbVHtQK7BQkidXGECbSg=" 94 | } 95 | } 96 | }, 97 | "iconv-lite": { 98 | "version": "0.4.13", 99 | "resolved": "http://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz", 100 | "integrity": "sha1-H4irpKsLFQjoMSrMOTRfNumS4vI=" 101 | }, 102 | "on-finished": { 103 | "version": "2.3.0", 104 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", 105 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 106 | "requires": { 107 | "ee-first": "1.1.1" 108 | }, 109 | "dependencies": { 110 | "ee-first": { 111 | "version": "1.1.1", 112 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 113 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 114 | } 115 | } 116 | }, 117 | "qs": { 118 | "version": "6.1.0", 119 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.1.0.tgz", 120 | "integrity": "sha1-7B0WJrJCeNmfD99FSeUk4k7O6yY=" 121 | }, 122 | "raw-body": { 123 | "version": "2.1.6", 124 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.6.tgz", 125 | "integrity": "sha1-nAUHN/4HztbZSk/QnGG2rYdNMQ8=", 126 | "requires": { 127 | "bytes": "2.3.0", 128 | "iconv-lite": "0.4.13", 129 | "unpipe": "1.0.0" 130 | }, 131 | "dependencies": { 132 | "unpipe": { 133 | "version": "1.0.0", 134 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 135 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 136 | } 137 | } 138 | }, 139 | "type-is": { 140 | "version": "1.6.12", 141 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.12.tgz", 142 | "integrity": "sha1-A1Kp37//BA/maMwVPMlYKcNUFz4=", 143 | "requires": { 144 | "media-typer": "0.3.0", 145 | "mime-types": "2.1.11" 146 | }, 147 | "dependencies": { 148 | "media-typer": { 149 | "version": "0.3.0", 150 | "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 151 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 152 | }, 153 | "mime-types": { 154 | "version": "2.1.11", 155 | "resolved": "http://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz", 156 | "integrity": "sha1-wlnEcb2oCKhdbNGTtDCl+uRHOzw=", 157 | "requires": { 158 | "mime-db": "1.23.0" 159 | }, 160 | "dependencies": { 161 | "mime-db": { 162 | "version": "1.23.0", 163 | "resolved": "http://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz", 164 | "integrity": "sha1-oxtAcK2uon1zLqMzdApk0OyaZlk=" 165 | } 166 | } 167 | } 168 | } 169 | } 170 | } 171 | }, 172 | "bytes": { 173 | "version": "3.1.0", 174 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", 175 | "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" 176 | }, 177 | "connect-multiparty": { 178 | "version": "2.0.0", 179 | "resolved": "http://registry.npmjs.org/connect-multiparty/-/connect-multiparty-2.0.0.tgz", 180 | "integrity": "sha1-V6e2HMezG27vSmKHjWDXcbI2mas=", 181 | "requires": { 182 | "multiparty": "4.1.2", 183 | "on-finished": "2.3.0", 184 | "qs": "4.0.0", 185 | "type-is": "1.6.12" 186 | }, 187 | "dependencies": { 188 | "multiparty": { 189 | "version": "4.1.2", 190 | "resolved": "https://registry.npmjs.org/multiparty/-/multiparty-4.1.2.tgz", 191 | "integrity": "sha1-VPjslxIFL6Hf2OyXUFbIIw1vI3A=", 192 | "requires": { 193 | "fd-slicer": "1.0.1" 194 | }, 195 | "dependencies": { 196 | "fd-slicer": { 197 | "version": "1.0.1", 198 | "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", 199 | "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", 200 | "requires": { 201 | "pend": "1.2.0" 202 | }, 203 | "dependencies": { 204 | "pend": { 205 | "version": "1.2.0", 206 | "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", 207 | "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" 208 | } 209 | } 210 | } 211 | } 212 | }, 213 | "on-finished": { 214 | "version": "2.3.0", 215 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", 216 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 217 | "requires": { 218 | "ee-first": "1.1.1" 219 | }, 220 | "dependencies": { 221 | "ee-first": { 222 | "version": "1.1.1", 223 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 224 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 225 | } 226 | } 227 | }, 228 | "qs": { 229 | "version": "4.0.0", 230 | "resolved": "https://registry.npmjs.org/qs/-/qs-4.0.0.tgz", 231 | "integrity": "sha1-wx2bdOwn33XlQ6hseHKO2NRiNgc=" 232 | }, 233 | "type-is": { 234 | "version": "1.6.12", 235 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.12.tgz", 236 | "integrity": "sha1-A1Kp37//BA/maMwVPMlYKcNUFz4=", 237 | "requires": { 238 | "media-typer": "0.3.0", 239 | "mime-types": "2.1.11" 240 | }, 241 | "dependencies": { 242 | "media-typer": { 243 | "version": "0.3.0", 244 | "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 245 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 246 | }, 247 | "mime-types": { 248 | "version": "2.1.11", 249 | "resolved": "http://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz", 250 | "integrity": "sha1-wlnEcb2oCKhdbNGTtDCl+uRHOzw=", 251 | "requires": { 252 | "mime-db": "1.23.0" 253 | }, 254 | "dependencies": { 255 | "mime-db": { 256 | "version": "1.23.0", 257 | "resolved": "http://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz", 258 | "integrity": "sha1-oxtAcK2uon1zLqMzdApk0OyaZlk=" 259 | } 260 | } 261 | } 262 | } 263 | } 264 | } 265 | }, 266 | "content-disposition": { 267 | "version": "0.5.3", 268 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", 269 | "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", 270 | "requires": { 271 | "safe-buffer": "5.1.2" 272 | } 273 | }, 274 | "content-type": { 275 | "version": "1.0.4", 276 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", 277 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" 278 | }, 279 | "cookie": { 280 | "version": "0.3.1", 281 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", 282 | "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" 283 | }, 284 | "cookie-parser": { 285 | "version": "1.4.3", 286 | "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.3.tgz", 287 | "integrity": "sha1-D+MfoZ0AC5X0qt8fU/3CuKIDuqU=", 288 | "requires": { 289 | "cookie": "0.3.1", 290 | "cookie-signature": "1.0.6" 291 | } 292 | }, 293 | "cookie-signature": { 294 | "version": "1.0.6", 295 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 296 | "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" 297 | }, 298 | "cors": { 299 | "version": "2.8.4", 300 | "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.4.tgz", 301 | "integrity": "sha1-K9OB8usgECAQXNUOpZ2mMJBpRoY=", 302 | "requires": { 303 | "object-assign": "4.1.1", 304 | "vary": "1.1.2" 305 | } 306 | }, 307 | "debug": { 308 | "version": "2.6.9", 309 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 310 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 311 | "requires": { 312 | "ms": "2.0.0" 313 | } 314 | }, 315 | "depd": { 316 | "version": "1.1.2", 317 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", 318 | "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" 319 | }, 320 | "destroy": { 321 | "version": "1.0.4", 322 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", 323 | "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" 324 | }, 325 | "ee-first": { 326 | "version": "1.1.1", 327 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 328 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 329 | }, 330 | "encodeurl": { 331 | "version": "1.0.2", 332 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 333 | "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" 334 | }, 335 | "escape-html": { 336 | "version": "1.0.3", 337 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 338 | "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" 339 | }, 340 | "etag": { 341 | "version": "1.8.1", 342 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 343 | "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" 344 | }, 345 | "express": { 346 | "version": "4.17.1", 347 | "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", 348 | "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", 349 | "requires": { 350 | "accepts": "1.3.7", 351 | "array-flatten": "1.1.1", 352 | "body-parser": "1.19.0", 353 | "content-disposition": "0.5.3", 354 | "content-type": "1.0.4", 355 | "cookie": "0.4.0", 356 | "cookie-signature": "1.0.6", 357 | "debug": "2.6.9", 358 | "depd": "1.1.2", 359 | "encodeurl": "1.0.2", 360 | "escape-html": "1.0.3", 361 | "etag": "1.8.1", 362 | "finalhandler": "1.1.2", 363 | "fresh": "0.5.2", 364 | "merge-descriptors": "1.0.1", 365 | "methods": "1.1.2", 366 | "on-finished": "2.3.0", 367 | "parseurl": "1.3.3", 368 | "path-to-regexp": "0.1.7", 369 | "proxy-addr": "2.0.6", 370 | "qs": "6.7.0", 371 | "range-parser": "1.2.1", 372 | "safe-buffer": "5.1.2", 373 | "send": "0.17.1", 374 | "serve-static": "1.14.1", 375 | "setprototypeof": "1.1.1", 376 | "statuses": "1.5.0", 377 | "type-is": "1.6.18", 378 | "utils-merge": "1.0.1", 379 | "vary": "1.1.2" 380 | }, 381 | "dependencies": { 382 | "body-parser": { 383 | "version": "1.19.0", 384 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", 385 | "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", 386 | "requires": { 387 | "bytes": "3.1.0", 388 | "content-type": "1.0.4", 389 | "debug": "2.6.9", 390 | "depd": "1.1.2", 391 | "http-errors": "1.7.2", 392 | "iconv-lite": "0.4.24", 393 | "on-finished": "2.3.0", 394 | "qs": "6.7.0", 395 | "raw-body": "2.4.0", 396 | "type-is": "1.6.18" 397 | } 398 | }, 399 | "cookie": { 400 | "version": "0.4.0", 401 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", 402 | "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" 403 | } 404 | } 405 | }, 406 | "finalhandler": { 407 | "version": "1.1.2", 408 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", 409 | "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", 410 | "requires": { 411 | "debug": "2.6.9", 412 | "encodeurl": "1.0.2", 413 | "escape-html": "1.0.3", 414 | "on-finished": "2.3.0", 415 | "parseurl": "1.3.3", 416 | "statuses": "1.5.0", 417 | "unpipe": "1.0.0" 418 | } 419 | }, 420 | "forwarded": { 421 | "version": "0.1.2", 422 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", 423 | "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" 424 | }, 425 | "fresh": { 426 | "version": "0.5.2", 427 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 428 | "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" 429 | }, 430 | "http-errors": { 431 | "version": "1.7.2", 432 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", 433 | "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", 434 | "requires": { 435 | "depd": "1.1.2", 436 | "inherits": "2.0.3", 437 | "setprototypeof": "1.1.1", 438 | "statuses": "1.5.0", 439 | "toidentifier": "1.0.0" 440 | } 441 | }, 442 | "iconv-lite": { 443 | "version": "0.4.24", 444 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 445 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 446 | "requires": { 447 | "safer-buffer": "2.1.2" 448 | } 449 | }, 450 | "inherits": { 451 | "version": "2.0.3", 452 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 453 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 454 | }, 455 | "ipaddr.js": { 456 | "version": "1.9.1", 457 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 458 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 459 | }, 460 | "media-typer": { 461 | "version": "0.3.0", 462 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 463 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 464 | }, 465 | "merge-descriptors": { 466 | "version": "1.0.1", 467 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", 468 | "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" 469 | }, 470 | "methods": { 471 | "version": "1.1.2", 472 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 473 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" 474 | }, 475 | "mime": { 476 | "version": "1.6.0", 477 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 478 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 479 | }, 480 | "mime-db": { 481 | "version": "1.44.0", 482 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", 483 | "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" 484 | }, 485 | "mime-types": { 486 | "version": "2.1.27", 487 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", 488 | "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", 489 | "requires": { 490 | "mime-db": "1.44.0" 491 | } 492 | }, 493 | "morgan": { 494 | "version": "1.10.0", 495 | "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", 496 | "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", 497 | "requires": { 498 | "basic-auth": "2.0.1", 499 | "debug": "2.6.9", 500 | "depd": "2.0.0", 501 | "on-finished": "2.3.0", 502 | "on-headers": "1.0.2" 503 | }, 504 | "dependencies": { 505 | "depd": { 506 | "version": "2.0.0", 507 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 508 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 509 | } 510 | } 511 | }, 512 | "ms": { 513 | "version": "2.0.0", 514 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 515 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 516 | }, 517 | "negotiator": { 518 | "version": "0.6.2", 519 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", 520 | "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" 521 | }, 522 | "object-assign": { 523 | "version": "4.1.1", 524 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 525 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 526 | }, 527 | "on-finished": { 528 | "version": "2.3.0", 529 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", 530 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 531 | "requires": { 532 | "ee-first": "1.1.1" 533 | } 534 | }, 535 | "on-headers": { 536 | "version": "1.0.2", 537 | "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", 538 | "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" 539 | }, 540 | "parseurl": { 541 | "version": "1.3.3", 542 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 543 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 544 | }, 545 | "path-to-regexp": { 546 | "version": "0.1.7", 547 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", 548 | "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" 549 | }, 550 | "proxy-addr": { 551 | "version": "2.0.6", 552 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", 553 | "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", 554 | "requires": { 555 | "forwarded": "0.1.2", 556 | "ipaddr.js": "1.9.1" 557 | } 558 | }, 559 | "qs": { 560 | "version": "6.7.0", 561 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", 562 | "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" 563 | }, 564 | "range-parser": { 565 | "version": "1.2.1", 566 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 567 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 568 | }, 569 | "raw-body": { 570 | "version": "2.4.0", 571 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", 572 | "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", 573 | "requires": { 574 | "bytes": "3.1.0", 575 | "http-errors": "1.7.2", 576 | "iconv-lite": "0.4.24", 577 | "unpipe": "1.0.0" 578 | } 579 | }, 580 | "safe-buffer": { 581 | "version": "5.1.2", 582 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 583 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 584 | }, 585 | "safer-buffer": { 586 | "version": "2.1.2", 587 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 588 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 589 | }, 590 | "send": { 591 | "version": "0.17.1", 592 | "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", 593 | "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", 594 | "requires": { 595 | "debug": "2.6.9", 596 | "depd": "1.1.2", 597 | "destroy": "1.0.4", 598 | "encodeurl": "1.0.2", 599 | "escape-html": "1.0.3", 600 | "etag": "1.8.1", 601 | "fresh": "0.5.2", 602 | "http-errors": "1.7.2", 603 | "mime": "1.6.0", 604 | "ms": "2.1.1", 605 | "on-finished": "2.3.0", 606 | "range-parser": "1.2.1", 607 | "statuses": "1.5.0" 608 | }, 609 | "dependencies": { 610 | "ms": { 611 | "version": "2.1.1", 612 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", 613 | "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" 614 | } 615 | } 616 | }, 617 | "serve-static": { 618 | "version": "1.14.1", 619 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", 620 | "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", 621 | "requires": { 622 | "encodeurl": "1.0.2", 623 | "escape-html": "1.0.3", 624 | "parseurl": "1.3.3", 625 | "send": "0.17.1" 626 | } 627 | }, 628 | "setprototypeof": { 629 | "version": "1.1.1", 630 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", 631 | "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" 632 | }, 633 | "statuses": { 634 | "version": "1.5.0", 635 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", 636 | "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" 637 | }, 638 | "toidentifier": { 639 | "version": "1.0.0", 640 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", 641 | "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" 642 | }, 643 | "type-is": { 644 | "version": "1.6.18", 645 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 646 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 647 | "requires": { 648 | "media-typer": "0.3.0", 649 | "mime-types": "2.1.27" 650 | } 651 | }, 652 | "unpipe": { 653 | "version": "1.0.0", 654 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 655 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 656 | }, 657 | "utils-merge": { 658 | "version": "1.0.1", 659 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 660 | "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" 661 | }, 662 | "vary": { 663 | "version": "1.1.2", 664 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 665 | "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" 666 | } 667 | } 668 | } 669 | -------------------------------------------------------------------------------- /test/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roshttp-test-server", 3 | "version": "0.0.1", 4 | "description": "A testing server for RösHTTP", 5 | "main": "index.js", 6 | "dependencies": { 7 | "body-parser": "^1.15.0", 8 | "connect-multiparty": "^2.0.0", 9 | "cookie-parser": "^1.4.3", 10 | "cors": "^2.8.4", 11 | "express": "^4.13.4", 12 | "morgan": "^1.7.0" 13 | }, 14 | "devDependencies": {}, 15 | "scripts": { 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "author": "Hadrien Milano ", 19 | "license": "MIT" 20 | } 21 | -------------------------------------------------------------------------------- /test/update-readme-sanity-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | test_folder='shared/src/test/scala/fr/hmil/roshttp_test' 4 | test_name='ReadmeSanityCheck' 5 | fatalDiff='no' 6 | if [ "$1" = "--fatalDiff" ]; then 7 | fatalDiff='yes' 8 | fi 9 | 10 | # Filters lines from the README to output only lines of code enclosed by scala markdown code tags 11 | filter_code() { 12 | awk '/```/{e=0}/^```scala/{gsub("^```scala","",$0);e=1}{if(e==1){print}}' 13 | } 14 | 15 | format_code() { 16 | cat < $test_folder/$test_name.scala 55 | } 56 | 57 | echo "Verifying readme sanity check..." 58 | mv $test_folder/$test_name.scala $test_folder/$test_name.old 59 | update_sanity_check 60 | diff $test_folder/$test_name.scala $test_folder/$test_name.old 61 | status="$?" 62 | rm $test_folder/$test_name.old 63 | if [ ! "$status" -eq 0 ]; then 64 | if [ "$fatalDiff" = "yes" ]; then 65 | cat <