├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src ├── main ├── scala-2.11 │ └── uzhttp │ │ └── header │ │ └── CompatMap.scala ├── scala-2.12 │ └── uzhttp │ │ └── header │ │ └── CompatMap.scala ├── scala-2.13 │ └── uzhttp │ │ └── header │ │ └── CompatMap.scala ├── scala-3 │ └── uzhttp │ │ └── header │ │ └── CompatMap.scala └── scala │ └── uzhttp │ ├── HTTPError.scala │ ├── Request.scala │ ├── Response.scala │ ├── Status.scala │ ├── Version.scala │ ├── header │ └── Headers.scala │ ├── package.scala │ ├── server │ ├── Server.scala │ └── package.scala │ └── websocket │ └── Frame.scala └── test ├── resources ├── path-test.txt └── site │ ├── images │ ├── 355px-Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg │ ├── 607px-Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg │ └── Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg │ ├── index.html │ └── style.css └── scala └── uzhttp └── server ├── MockConnectionWriter.scala ├── MockSocketChannel.scala ├── ResponseSpec.scala ├── ServerSpec.scala ├── TestRuntime.scala └── TestServer.scala /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Scala CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up JDK 1.8 17 | uses: actions/setup-java@v1 18 | with: 19 | java-version: 1.8 20 | - name: Run tests 21 | run: sbt +test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | .DS_Store 4 | .bsp 5 | .metals 6 | .bloop 7 | .vscode 8 | metals.sbt 9 | project/project 10 | 11 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.0.0-RC3" 2 | runner.dialect = scala3 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2019 uzhttp contributors 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uzhttp 2 | ![Scala CI](https://github.com/polynote/uzhttp/workflows/Scala%20CI/badge.svg) 3 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.polynote/uzhttp_2.11/badge.svg)](https://mvnrepository.com/artifact/org.polynote/uzhttp) 4 | 5 | This (Micro-Z-HTTP, or "uzi-HTTP" if you like) is a minimal HTTP server using [ZIO](https://github.com/zio/zio). It has 6 | essentially no features. You probably shouldn't use it. 7 | 8 | ## Why? 9 | 10 | This was made to support the HTTP serving needs of [Polynote](https://github.com/polynote/polynote) – which are minimal, 11 | because Polynote is a single-page app that only needs to serve some static files and handle websockets. As a result, 12 | this is basically all that uzhttp supports. 13 | 14 | ## Should I use it? 15 | 16 | Probably not! Here are just a few better options: 17 | 18 | * If you want a full-featured HTTP server which is battle-tested, built on robust technology, supports middleware, and 19 | has a purely functional API with a nice DSL, go for [http4s](https://github.com/http4s/http4s) – it has pretty good 20 | interoperability with ZIO by using [zio-interop-cats](https://github.com/zio/interop-cats). 21 | * If you want the above features but with a native ZIO solution, wait for [zio-web](https://github.com/zio/zio-web). 22 | * If you want a more minimal solution, that's still got a prinicpled, purely functional API but is production-ready and 23 | properly engineered, take a look at [finch](https://github.com/finagle/finch). 24 | 25 | ### I'm still considering uzhttp. What are its features? 26 | 27 | * Uses 100% non-blocking NIO for its networking (after it's bound, anyway), so it won't gobble up your blocking thread 28 | pool. 29 | * Supports the basic HTTP request types as well as basic websockets. 30 | * Has no dependencies other than zio and zio-streams. 31 | 32 | ### What important features does it lack? 33 | 34 | * Does not handle fancy new-fangled HTTP 1.1 things like chunked transfer encoding of requests (or responses, unless you 35 | build it yourself). 36 | * Does not support SSL. Nobody really wants to deal with Java's SSL stuff, so the idea is that the app will be behind a 37 | reverse proxy that deals with things like SSL termination, SSO, etc. 38 | * No fancy routing DSL whatsover. It takes a function that gets a request and returns a `ZIO[R, HTTPError, Response]` 39 | and that's basically it. 40 | * There's nothing else provided for you, either. No authentication stuff built-in (you're using a proxy, remember?). No 41 | pluggable middleware or things like that. It won't even parse request URIs into meaningful pieces for you. It's 42 | request to response; anything else is yours to deal with. 43 | 44 | 45 | ## How do I use it? 46 | 47 | To create a server, you use the `uzhttp.server.Server.builder` constructor. This gives you a builder, which has methods to 48 | specify where to listen, how to respond to requests, and how to handle errors. Once you've done that, you call `serve` 49 | which gives you a `ZManaged[R with Blocking, Throwable, Server]`. You can either `useForever` this (if you don't need 50 | to do anything else with the server), or you can `use` it as long as you end with `awaitShutdown`: 51 | 52 | ```scala 53 | serverM.use { 54 | server => log("It's alive!") *> server.awaitShutdown 55 | } 56 | ``` 57 | 58 | The `Blocking` required for these operations is used for: 59 | - Binding to the given port 60 | - Selecting from NIO 61 | - Some file operations (for generating responses with the `Response` API) which are more efficient when using blocking 62 | (You can avoid these if you wish, and generate your own responses). 63 | 64 | Here's an example: 65 | 66 | ```scala 67 | import java.net.InetSocketAddress 68 | import uzhttp.server.Server 69 | import uzhttp.{Request, Response, RefineOps} 70 | import uzhttp.websocket.Frame 71 | import zio.{App, ZIO, Task} 72 | 73 | object ExampleServer extends App { 74 | override def run(args: List[String]): ZIO[zio.ZEnv, Nothing, Int] = 75 | Server.builder(new InetSocketAddress("127.0.0.1", 8080)) 76 | .handleSome { 77 | case req if req.uri.getPath startsWith "/static" => 78 | // deliver a static file from an application resource 79 | Response.fromResource(s"staticFiles${req.uri}", req).refineHTTP(req) 80 | case req if req.uri.getPath == "/" => 81 | // deliver a constant HTML response 82 | ZIO.succeed(Response.html("

Hello world!

")) 83 | case req@Request.WebsocketRequest(_, uri, _, _, inputFrames) 84 | if uri.getPath startsWith "/ws" => 85 | // inputFrames are the incoming frames; construct a websocket session 86 | // by giving a stream of output frames 87 | Response.websocket(req, inputFrames.mapM(respondToWebsocketFrame)) 88 | }.serve.useForever.orDie 89 | 90 | def respondToWebsocketFrame(frame: Frame): Task[Frame] = ??? 91 | } 92 | ``` 93 | 94 | ## Can I make a pull request? 95 | 96 | Absolutely! Please follow the [Polynote contributing guide](https://github.com/polynote/polynote/blob/master/CONTRIBUTING.md). 97 | We'll gladly accept bugfixes, performance improvements, and tests; and we'd love to see features like: 98 | 99 | * Better support of HTTP features (e.g. `Transfer-Encoding`). 100 | * Better support of websockets (e.g. `permessage-deflate`). 101 | 102 | uzhttp would like to stay reasonably minimal. At the time of initial commit, uzhttp was under 1k lines of code! If 103 | you've got a great routing DSL or other conveniences, we'll gladly take a look if it's pretty small. But we might 104 | suggest that it live as a separate library. 105 | 106 | ## License 107 | 108 | This project is licensed under the Apache 2 license: 109 | 110 | > Apache License 111 | > ============== 112 | > 113 | > _Version 2.0, January 2004_ 114 | > _[http://www.apache.org/licenses/](http://www.apache.org/licenses/)_ 115 | > 116 | > ### Terms and Conditions for use, reproduction, and distribution 117 | > 118 | > #### 1. Definitions 119 | > 120 | > “License” shall mean the terms and conditions for use, reproduction, and 121 | > distribution as defined by Sections 1 through 9 of this document. 122 | > 123 | > “Licensor” shall mean the copyright owner or entity authorized by the copyright 124 | > owner that is granting the License. 125 | > 126 | > “Legal Entity” shall mean the union of the acting entity and all other entities 127 | > that control, are controlled by, or are under common control with that entity. 128 | > For the purposes of this definition, “control” means **(i)** the power, direct or 129 | > indirect, to cause the direction or management of such entity, whether by 130 | > contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 131 | > outstanding shares, or **(iii)** beneficial ownership of such entity. 132 | > 133 | > “You” (or “Your”) shall mean an individual or Legal Entity exercising 134 | > permissions granted by this License. 135 | > 136 | > “Source” form shall mean the preferred form for making modifications, including 137 | > but not limited to software source code, documentation source, and configuration 138 | > files. 139 | > 140 | > “Object” form shall mean any form resulting from mechanical transformation or 141 | > translation of a Source form, including but not limited to compiled object code, 142 | > generated documentation, and conversions to other media types. 143 | > 144 | > “Work” shall mean the work of authorship, whether in Source or Object form, made 145 | > available under the License, as indicated by a copyright notice that is included 146 | > in or attached to the work (an example is provided in the Appendix below). 147 | > 148 | > “Derivative Works” shall mean any work, whether in Source or Object form, that 149 | > is based on (or derived from) the Work and for which the editorial revisions, 150 | > annotations, elaborations, or other modifications represent, as a whole, an 151 | > original work of authorship. For the purposes of this License, Derivative Works 152 | > shall not include works that remain separable from, or merely link (or bind by 153 | > name) to the interfaces of, the Work and Derivative Works thereof. 154 | > 155 | > “Contribution” shall mean any work of authorship, including the original version 156 | > of the Work and any modifications or additions to that Work or Derivative Works 157 | > thereof, that is intentionally submitted to Licensor for inclusion in the Work 158 | > by the copyright owner or by an individual or Legal Entity authorized to submit 159 | > on behalf of the copyright owner. For the purposes of this definition, 160 | > “submitted” means any form of electronic, verbal, or written communication sent 161 | > to the Licensor or its representatives, including but not limited to 162 | > communication on electronic mailing lists, source code control systems, and 163 | > issue tracking systems that are managed by, or on behalf of, the Licensor for 164 | > the purpose of discussing and improving the Work, but excluding communication 165 | > that is conspicuously marked or otherwise designated in writing by the copyright 166 | > owner as “Not a Contribution.” 167 | > 168 | > “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 169 | > of whom a Contribution has been received by Licensor and subsequently 170 | > incorporated within the Work. 171 | > 172 | > #### 2. Grant of Copyright License 173 | > 174 | > Subject to the terms and conditions of this License, each Contributor hereby 175 | > grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 176 | > irrevocable copyright license to reproduce, prepare Derivative Works of, 177 | > publicly display, publicly perform, sublicense, and distribute the Work and such 178 | > Derivative Works in Source or Object form. 179 | > 180 | > #### 3. Grant of Patent License 181 | > 182 | > Subject to the terms and conditions of this License, each Contributor hereby 183 | > grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 184 | > irrevocable (except as stated in this section) patent license to make, have 185 | > made, use, offer to sell, sell, import, and otherwise transfer the Work, where 186 | > such license applies only to those patent claims licensable by such Contributor 187 | > that are necessarily infringed by their Contribution(s) alone or by combination 188 | > of their Contribution(s) with the Work to which such Contribution(s) was 189 | > submitted. If You institute patent litigation against any entity (including a 190 | > cross-claim or counterclaim in a lawsuit) alleging that the Work or a 191 | > Contribution incorporated within the Work constitutes direct or contributory 192 | > patent infringement, then any patent licenses granted to You under this License 193 | > for that Work shall terminate as of the date such litigation is filed. 194 | > 195 | > #### 4. Redistribution 196 | > 197 | > You may reproduce and distribute copies of the Work or Derivative Works thereof 198 | > in any medium, with or without modifications, and in Source or Object form, 199 | > provided that You meet the following conditions: 200 | > 201 | > * **(a)** You must give any other recipients of the Work or Derivative Works a copy of 202 | > this License; and 203 | > * **(b)** You must cause any modified files to carry prominent notices stating that You 204 | > changed the files; and 205 | > * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 206 | > all copyright, patent, trademark, and attribution notices from the Source form 207 | > of the Work, excluding those notices that do not pertain to any part of the 208 | > Derivative Works; and 209 | > * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 210 | > Derivative Works that You distribute must include a readable copy of the 211 | > attribution notices contained within such NOTICE file, excluding those notices 212 | > that do not pertain to any part of the Derivative Works, in at least one of the 213 | > following places: within a NOTICE text file distributed as part of the 214 | > Derivative Works; within the Source form or documentation, if provided along 215 | > with the Derivative Works; or, within a display generated by the Derivative 216 | > Works, if and wherever such third-party notices normally appear. The contents of 217 | > the NOTICE file are for informational purposes only and do not modify the 218 | > License. You may add Your own attribution notices within Derivative Works that 219 | > You distribute, alongside or as an addendum to the NOTICE text from the Work, 220 | > provided that such additional attribution notices cannot be construed as 221 | > modifying the License. 222 | > 223 | > You may add Your own copyright statement to Your modifications and may provide 224 | > additional or different license terms and conditions for use, reproduction, or 225 | > distribution of Your modifications, or for any such Derivative Works as a whole, 226 | > provided Your use, reproduction, and distribution of the Work otherwise complies 227 | > with the conditions stated in this License. 228 | > 229 | > #### 5. Submission of Contributions 230 | > 231 | > Unless You explicitly state otherwise, any Contribution intentionally submitted 232 | > for inclusion in the Work by You to the Licensor shall be under the terms and 233 | > conditions of this License, without any additional terms or conditions. 234 | > Notwithstanding the above, nothing herein shall supersede or modify the terms of 235 | > any separate license agreement you may have executed with Licensor regarding 236 | > such Contributions. 237 | > 238 | > #### 6. Trademarks 239 | > 240 | > This License does not grant permission to use the trade names, trademarks, 241 | > service marks, or product names of the Licensor, except as required for 242 | > reasonable and customary use in describing the origin of the Work and 243 | > reproducing the content of the NOTICE file. 244 | > 245 | > #### 7. Disclaimer of Warranty 246 | > 247 | > Unless required by applicable law or agreed to in writing, Licensor provides the 248 | > Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 249 | > WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 250 | > including, without limitation, any warranties or conditions of TITLE, 251 | > NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 252 | > solely responsible for determining the appropriateness of using or 253 | > redistributing the Work and assume any risks associated with Your exercise of 254 | > permissions under this License. 255 | > 256 | > #### 8. Limitation of Liability 257 | > 258 | > In no event and under no legal theory, whether in tort (including negligence), 259 | > contract, or otherwise, unless required by applicable law (such as deliberate 260 | > and grossly negligent acts) or agreed to in writing, shall any Contributor be 261 | > liable to You for damages, including any direct, indirect, special, incidental, 262 | > or consequential damages of any character arising as a result of this License or 263 | > out of the use or inability to use the Work (including but not limited to 264 | > damages for loss of goodwill, work stoppage, computer failure or malfunction, or 265 | > any and all other commercial damages or losses), even if such Contributor has 266 | > been advised of the possibility of such damages. 267 | > 268 | > #### 9. Accepting Warranty or Additional Liability 269 | > 270 | > While redistributing the Work or Derivative Works thereof, You may choose to 271 | > offer, and charge a fee for, acceptance of support, warranty, indemnity, or 272 | > other liability obligations and/or rights consistent with this License. However, 273 | > in accepting such obligations, You may act only on Your own behalf and on Your 274 | > sole responsibility, not on behalf of any other Contributor, and only if You 275 | > agree to indemnify, defend, and hold each Contributor harmless for any liability 276 | > incurred by, or claims asserted against, such Contributor by reason of your 277 | > accepting any such warranty or additional liability. 278 | > 279 | > _END OF TERMS AND CONDITIONS_ 280 | > 281 | > ### APPENDIX: How to apply the Apache License to your work 282 | > 283 | > To apply the Apache License to your work, attach the following boilerplate 284 | > notice, with the fields enclosed by brackets `[]` replaced with your own 285 | > identifying information. (Don't include the brackets!) The text should be 286 | > enclosed in the appropriate comment syntax for the file format. We also 287 | > recommend that a file or class name and description of purpose be included on 288 | > the same “printed page” as the copyright notice for easier identification within 289 | > third-party archives. 290 | > 291 | > Copyright 2020 uzhttp contributors 292 | > 293 | > Licensed under the Apache License, Version 2.0 (the "License"); 294 | > you may not use this file except in compliance with the License. 295 | > You may obtain a copy of the License at 296 | > 297 | > http://www.apache.org/licenses/LICENSE-2.0 298 | > 299 | > Unless required by applicable law or agreed to in writing, software 300 | > distributed under the License is distributed on an "AS IS" BASIS, 301 | > WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 302 | > See the License for the specific language governing permissions and 303 | > limitations under the License. 304 | > 305 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | organization := "org.polynote" 2 | name := "uzhttp" 3 | version := "0.3.0-RC1" 4 | scalaVersion := "2.13.6" 5 | crossScalaVersions := Seq("2.11.12", "2.12.12", "2.13.7", "3.1.0") 6 | ThisBuild / versionScheme := Some("early-semver") 7 | 8 | //val zioVersion = "2.0.0-M4+21-503ceef7-SNAPSHOT" 9 | val zioVersion = "2.0.0-RC1" 10 | val sttpClientVersion = "3.3.16" 11 | val scalaTestVersion = "3.2.9" 12 | 13 | libraryDependencies := Seq( 14 | "dev.zio" %% "zio" % zioVersion, 15 | "dev.zio" %% "zio-streams" % zioVersion, 16 | "org.scalatest" %% "scalatest" % scalaTestVersion % "test", 17 | "dev.zio" %% "zio-test" % zioVersion % "test", 18 | "dev.zio" %% "zio-test-sbt" % zioVersion % "test", 19 | 20 | // http client for testing 21 | "com.softwaremill.sttp.client3" %% "core" % sttpClientVersion % "test", 22 | "com.softwaremill.sttp.client3" %% "async-http-client-backend-future" % sttpClientVersion % "test" 23 | ) 24 | 25 | testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") 26 | 27 | scalacOptions ++= { 28 | if (scalaVersion.value != "3.1.0") 29 | Seq("-deprecation", "-feature", "-Ywarn-value-discard", "-Xfatal-warnings") 30 | else 31 | Seq("-deprecation", "-feature", "-Xfatal-warnings") 32 | } 33 | 34 | // publishing settings 35 | publishMavenStyle := true 36 | homepage := Some(url("https://polynote.org")) 37 | licenses := Seq("APL2" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 38 | scmInfo := Some( 39 | ScmInfo( 40 | url("https://github.com/polynote/uzhttp"), 41 | "scm:git@github.com:polynote/uzhttp.git" 42 | ) 43 | ) 44 | publishTo := sonatypePublishToBundle.value 45 | developers := List( 46 | Developer( 47 | id = "jeremyrsmith", 48 | name = "Jeremy Smith", 49 | email = "", 50 | url = url("https://github.com/jeremyrsmith") 51 | ), 52 | Developer( 53 | id = "jonathanindig", 54 | name = "Jonathan Indig", 55 | email = "", 56 | url = url("https://github.com/jonathanindig") 57 | ) 58 | ) 59 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.5.5 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.8") 2 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0") -------------------------------------------------------------------------------- /src/main/scala-2.11/uzhttp/header/CompatMap.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.header 2 | 3 | trait CompatMap[K, V] extends scala.collection.immutable.Map[K, V] { 4 | def removed(key: K): Map[K, V] 5 | final def -(key: K): Map[K, V] = removed(key) 6 | } -------------------------------------------------------------------------------- /src/main/scala-2.12/uzhttp/header/CompatMap.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.header 2 | 3 | trait CompatMap[K, V] extends scala.collection.immutable.Map[K, V] { 4 | def removed(key: K): Map[K, V] 5 | final def -(key: K): Map[K, V] = removed(key) 6 | } -------------------------------------------------------------------------------- /src/main/scala-2.13/uzhttp/header/CompatMap.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.header 2 | 3 | trait CompatMap[K, V] extends scala.collection.immutable.Map[K, V] 4 | -------------------------------------------------------------------------------- /src/main/scala-3/uzhttp/header/CompatMap.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.header 2 | 3 | trait CompatMap[K, V] extends scala.collection.immutable.Map[K, V] 4 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/HTTPError.scala: -------------------------------------------------------------------------------- 1 | package uzhttp 2 | 3 | abstract class HTTPError(val statusCode: Int, val statusText: String, msg: String) extends Throwable(msg) with Status 4 | 5 | abstract class HTTPErrorWithCause(statusCode: Int, statusText: String, msg: String) extends HTTPError(statusCode, statusText, msg) { 6 | def cause: Option[Throwable] 7 | cause.foreach(initCause) 8 | } 9 | 10 | object HTTPError { 11 | def unapply(err: Throwable): Option[(Int, String)] = err match { 12 | case err: HTTPError => Some(((err.statusCode, err.getMessage))) 13 | case _ => None 14 | } 15 | 16 | final case class BadRequest(message: String) extends HTTPError(400, "Bad Request", message) 17 | final case class Unauthorized(message: String) extends HTTPError(401, "Unauthorized", message) 18 | 19 | final case class Forbidden(message: String) extends HTTPError(403, "Forbidden", message) 20 | final case class NotFound(uri: String) extends HTTPError(404, "Not Found", s"The requested URI $uri was not found on this server.") 21 | final case class MethodNotAllowed(message: String) extends HTTPError(405, "Method Not Allowed", message) 22 | final case class RequestTimeout(message: String) extends HTTPError(408, "Request Timeout", message) 23 | final case class PayloadTooLarge(message: String) extends HTTPError(413, "Payload Too Large", message) 24 | 25 | final case class InternalServerError(message: String, cause: Option[Throwable] = None) extends HTTPErrorWithCause(500, "Internal Server Error", message) 26 | 27 | final case class NotImplemented(message: String) extends HTTPError(501, "Not Implemented", message) 28 | final case class HTTPVersionNotSupported(message: String) extends HTTPError(505, "HTTP Version Not Supported", message) 29 | } 30 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/Request.scala: -------------------------------------------------------------------------------- 1 | package uzhttp 2 | 3 | import java.net.URI 4 | 5 | import uzhttp.header.Headers 6 | import uzhttp.websocket.Frame 7 | import uzhttp.HTTPError.BadRequest 8 | import zio.stream.{Stream, Take, ZStream} 9 | import zio.{Chunk, Queue, Ref, UIO, ZIO} 10 | import zio.ZTraceElement 11 | 12 | trait Request { 13 | def method: Request.Method 14 | def uri: URI 15 | def version: Version 16 | def headers: Map[String, String] 17 | def body: Option[Stream[HTTPError, Byte]] 18 | def addHeader(name: String, value: String): Request 19 | def addHeaders(headers: (String, String)*): Request = headers.foldLeft(this) { 20 | case (r, (k, v)) => r.addHeader(k, v) 21 | } 22 | def removeHeader(name: String): Request 23 | } 24 | 25 | trait ContinuingRequest extends Request { 26 | def submitBytes(chunk: Chunk[Byte]): UIO[Unit] 27 | def channelClosed(): UIO[Unit] 28 | def bytesRemaining: UIO[Long] 29 | def noBufferInput: Boolean 30 | } 31 | 32 | object Request { 33 | 34 | sealed abstract class Method(val name: String) 35 | 36 | object Method { 37 | val Methods: List[Method] = 38 | List(GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH) 39 | def parse(str: String): Method = { 40 | val strU = str.toUpperCase 41 | Methods.find(_.name == strU).getOrElse(throw BadRequest("Invalid method")) 42 | } 43 | 44 | def parseEither(str: String): Either[BadRequest, Method] = { 45 | val strU = str.toUpperCase 46 | Methods 47 | .find(_.name == strU) 48 | .map(Right(_)) 49 | .getOrElse(Left(BadRequest("Invalid method"))) 50 | } 51 | 52 | case object GET extends Method("GET") 53 | case object HEAD extends Method("HEAD") 54 | case object POST extends Method("POST") 55 | case object PUT extends Method("PUT") 56 | case object DELETE extends Method("DELETE") 57 | case object TRACE extends Method("TRACE") 58 | case object OPTIONS extends Method("OPTIONS") 59 | case object CONNECT extends Method("CONNECT") 60 | case object PATCH extends Method("PATCH") 61 | } 62 | 63 | private[uzhttp] final case class ReceivingBody( 64 | method: Method, 65 | uri: URI, 66 | version: Version, 67 | headers: Headers, 68 | bodyQueue: Queue[Take[HTTPError, Byte]], 69 | received: Ref[Long], 70 | contentLength: Long 71 | ) extends ContinuingRequest { 72 | override val body: Option[Stream[HTTPError, Byte]] = Some( 73 | Stream.fromQueue(bodyQueue).flattenTake 74 | ) 75 | override val noBufferInput: Boolean = false 76 | override def addHeader(name: String, value: String): Request = 77 | copy(headers = headers + (name -> value)) 78 | override def removeHeader(name: String): Request = 79 | copy(headers = headers.removed(name)) 80 | override def bytesRemaining: UIO[Long] = received.get.map(contentLength - _) 81 | override def channelClosed(): UIO[Unit] = bodyQueue.offer(Take.end).unit 82 | override def submitBytes(chunk: Chunk[Byte]): UIO[Unit] = bodyQueue.offer( 83 | Take.chunk(chunk) 84 | ) *> received.updateAndGet(_ + chunk.length).flatMap { 85 | case received if received >= contentLength => 86 | bodyQueue.offer(Take.end).unit 87 | case _ => ZIO.unit 88 | } 89 | } 90 | 91 | private[uzhttp] object ReceivingBody { 92 | private[uzhttp] def create( 93 | method: Method, 94 | uri: URI, 95 | version: Version, 96 | headers: Headers, 97 | contentLength: Long 98 | ): UIO[ReceivingBody] = 99 | (Queue.unbounded[Take[HTTPError, Byte]] <*> Ref.make[Long](0L)).map { 100 | case (body, received) => 101 | new ReceivingBody( 102 | method, 103 | uri, 104 | version, 105 | headers, 106 | body, 107 | received, 108 | contentLength 109 | ) 110 | } 111 | } 112 | 113 | private final case class ConstBody( 114 | method: Method, 115 | uri: URI, 116 | version: Version, 117 | headers: Headers, 118 | bodyChunk: Chunk[Byte] 119 | ) extends Request { 120 | override def body: Option[Stream[Nothing, Byte]] = Some( 121 | Stream.fromChunk(bodyChunk) 122 | ) 123 | override def addHeader(name: String, value: String): Request = 124 | copy(headers = headers + (name -> value)) 125 | override def removeHeader(name: String): Request = 126 | copy(headers = headers.removed(name)) 127 | } 128 | 129 | private[uzhttp] final case class NoBody( 130 | method: Method, 131 | uri: URI, 132 | version: Version, 133 | headers: Headers 134 | ) extends Request { 135 | override val body: Option[Stream[Nothing, Byte]] = None 136 | override def addHeader(name: String, value: String): Request = 137 | copy(headers = headers + (name -> value)) 138 | override def removeHeader(name: String): Request = 139 | copy(headers = headers.removed(name)) 140 | } 141 | 142 | private[uzhttp] object NoBody { 143 | def fromReqString(str: String): Either[BadRequest, NoBody] = 144 | str.linesWithSeparators 145 | .map(_.stripLineEnd) 146 | .dropWhile(_.isEmpty) 147 | .toList match { 148 | case Nil => Left(BadRequest("Empty request")) 149 | case first :: rest => 150 | first.split(' ').toList match { 151 | case List(methodStr, uri, versionStr) => 152 | for { 153 | uri <- 154 | try Right(new URI(uri)) 155 | catch { 156 | case _: Throwable => 157 | Left(BadRequest("Malformed request URI")) 158 | } 159 | method <- Method.parseEither(methodStr) 160 | version <- Version.parseEither(versionStr) 161 | } yield NoBody(method, uri, version, Headers.fromLines(rest)) 162 | 163 | case _ => Left(BadRequest("Malformed request header")) 164 | } 165 | } 166 | } 167 | 168 | // Produce an empty GET request on "/" with "Connection: close". Mainly for testing. 169 | def empty( 170 | method: Method = Method.GET, 171 | version: Version = Version.Http11, 172 | uri: String = "/" 173 | ): Request = 174 | NoBody(method, new URI(uri), version, Headers("Connection" -> "close")) 175 | 176 | final class WebsocketRequest( 177 | val method: Method, 178 | val uri: URI, 179 | val version: Version, 180 | val headers: Headers, 181 | chunks: Queue[Take[Nothing, Byte]] 182 | ) extends ContinuingRequest { 183 | override def addHeader(name: String, value: String): Request = 184 | new WebsocketRequest( 185 | method, 186 | uri, 187 | version, 188 | headers + (name -> value), 189 | chunks 190 | ) 191 | override def removeHeader(name: String): Request = new WebsocketRequest( 192 | method, 193 | uri, 194 | version, 195 | headers = headers.removed(name), 196 | chunks 197 | ) 198 | override val body: Option[Stream[HTTPError, Byte]] = None 199 | override val bytesRemaining: UIO[Long] = ZIO.succeed(Long.MaxValue) 200 | override def submitBytes(chunk: Chunk[Byte]): UIO[Unit] = 201 | chunks.offer(Take.chunk(chunk)).unit 202 | override def channelClosed(): UIO[Unit] = chunks.offer(Take.end).unit 203 | override val noBufferInput: Boolean = true 204 | lazy val frames: Stream[Throwable, Frame] = 205 | Frame.parse(ZStream.fromQueue(chunks).flattenTake) 206 | } 207 | 208 | object WebsocketRequest { 209 | def apply( 210 | method: Method, 211 | uri: URI, 212 | version: Version, 213 | headers: Headers 214 | ): UIO[WebsocketRequest] = 215 | Queue.unbounded[Take[Nothing, Byte]].map { chunks => 216 | new WebsocketRequest(method, uri, version, headers, chunks) 217 | } 218 | 219 | def unapply( 220 | req: Request 221 | ): Option[(Method, URI, Version, Headers, Stream[Throwable, Frame])] = 222 | req match { 223 | case req: WebsocketRequest => 224 | Some((req.method, req.uri, req.version, req.headers, req.frames)) 225 | case _ => None 226 | } 227 | } 228 | 229 | } 230 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/Response.scala: -------------------------------------------------------------------------------- 1 | package uzhttp 2 | 3 | import java.io.InputStream 4 | import java.net.URI 5 | import java.nio.{ByteBuffer, MappedByteBuffer} 6 | import java.nio.channels.FileChannel 7 | import java.nio.charset.{Charset, StandardCharsets} 8 | import java.nio.file.attribute.BasicFileAttributes 9 | import java.nio.file.{Files, Path, Paths, StandardOpenOption} 10 | import java.security.MessageDigest 11 | import java.time.format.DateTimeFormatter 12 | import java.time.{Instant, ZoneOffset, ZonedDateTime} 13 | import java.util.Base64 14 | import java.util.concurrent.ConcurrentHashMap 15 | 16 | import uzhttp.header.Headers 17 | import Headers.{ 18 | CacheControl, 19 | ContentLength, 20 | ContentType, 21 | IfModifiedSince, 22 | LastModified 23 | } 24 | import uzhttp.server.Server 25 | import uzhttp.HTTPError.{BadRequest, NotFound} 26 | import uzhttp.Request.Method 27 | import uzhttp.server.Server.ConnectionWriter 28 | import uzhttp.websocket.Frame 29 | 30 | import zio._ 31 | import zio.stream._ 32 | 33 | trait Response { 34 | def headers: Headers 35 | def status: Status 36 | 37 | /** Size of response body (excluding headers) 38 | */ 39 | def size: Long 40 | 41 | def addHeaders(headers: (String, String)*): Response 42 | def addHeader(name: String, value: String): Response = addHeaders( 43 | (name, value) 44 | ) 45 | def removeHeader(name: String): Response 46 | 47 | /** Add cache-control header enabling modification time checking on the client 48 | */ 49 | def withCacheControl: Response = 50 | addHeader(CacheControl, "max-age=0, must-revalidate") 51 | 52 | /** Cache the response lazily in memory, for repeated use. Be careful when 53 | * using this – keep in mind that this reponse could have been tailored for a 54 | * particular request and won't work for a different request (e.g. it could 55 | * be a 304 Not Modified response due to the request's If-Modified-Since 56 | * header) 57 | * 58 | * @return 59 | * A new Response which caches this response's body in memory. 60 | */ 61 | def cached: UIO[Response] = Response.CachedResponse.make(this) 62 | def cachedManaged: ZManaged[Any, Nothing, Response] = 63 | Response.CachedResponse.managed(this) 64 | 65 | /** Terminate the response, if it's still writing. 66 | */ 67 | def close: UIO[Unit] = ZIO.unit 68 | 69 | private[uzhttp] def writeTo( 70 | connection: Server.ConnectionWriter 71 | ): Task[Unit] 72 | private[uzhttp] def closeAfter: Boolean = headers.exists { case (k, v) => 73 | k.toLowerCase == "connection" && v.toLowerCase == "close" 74 | } 75 | } 76 | 77 | object Response { 78 | def plain( 79 | body: String, 80 | status: Status = Status.Ok, 81 | headers: List[(String, String)] = Nil, 82 | charset: Charset = StandardCharsets.UTF_8 83 | ): Response = 84 | const( 85 | body.getBytes(charset), 86 | status, 87 | contentType = s"text/plain; charset=${charset.name()}", 88 | headers = headers 89 | ) 90 | 91 | def html( 92 | body: String, 93 | status: Status = Status.Ok, 94 | headers: List[(String, String)] = Nil, 95 | charset: Charset = StandardCharsets.UTF_8 96 | ): Response = 97 | const( 98 | body.getBytes(charset), 99 | status, 100 | contentType = s"text/html; charset=${charset.name()}", 101 | headers = headers 102 | ) 103 | 104 | def const( 105 | body: Array[Byte], 106 | status: Status = Status.Ok, 107 | contentType: String = "application/octet-stream", 108 | headers: List[(String, String)] = Nil 109 | ): Response = 110 | ConstResponse(status, body, repHeaders(contentType, body.length, headers)) 111 | 112 | lazy val notModified: Response = 113 | ConstResponse(Status.NotModified, Array.emptyByteArray, Nil) 114 | 115 | private def getModifiedTime(path: Path): Task[Instant] = 116 | ZIO.attemptBlocking(Files.getLastModifiedTime(path).toInstant) 117 | 118 | private def localPath(uri: URI): UIO[Option[Path]] = uri match { 119 | case uri if uri.getScheme == "file" => 120 | ZIO.attemptBlocking(Paths.get(uri)).option 121 | case uri if uri.getScheme == "jar" => 122 | ZIO 123 | .attempt(new URI(uri.getSchemeSpecificPart.takeWhile(_ != '!'))) 124 | .flatMap(localPath) 125 | .orElseSucceed(None) 126 | case _ => ZIO.none 127 | } 128 | 129 | private def parseModDate(rfc1123: String): IO[Option[Nothing], Instant] = ZIO 130 | .attempt( 131 | ZonedDateTime 132 | .parse(rfc1123, DateTimeFormatter.RFC_1123_DATE_TIME) 133 | .toInstant 134 | ) 135 | .orElseFail(None) 136 | private def parseModDateOpt( 137 | rfc1123: Option[String] 138 | ): IO[Option[Nothing], Instant] = 139 | ZIO 140 | .fromOption(rfc1123) 141 | .flatMap(str => 142 | ZIO 143 | .attempt( 144 | ZonedDateTime 145 | .parse(str, DateTimeFormatter.RFC_1123_DATE_TIME) 146 | .toInstant 147 | ) 148 | .orElseFail(None) 149 | ) 150 | 151 | private def checkModifiedSince( 152 | path: Path, 153 | ifModifiedSince: Option[String] 154 | ): IO[Option[Nothing], Response] = ZIO.fromOption { 155 | ifModifiedSince.map { dateStr => 156 | parseModDate(dateStr).flatMap { ifModifiedSinceInstant => 157 | getModifiedTime(path).orElseFail(None).flatMap { 158 | case mtime if mtime.isAfter(ifModifiedSinceInstant) => ZIO.fail(None) 159 | case _ => ZIO.succeed(notModified) 160 | } 161 | } 162 | } 163 | }.flatten 164 | 165 | private def formatInstant(instant: Instant): String = 166 | DateTimeFormatter.RFC_1123_DATE_TIME.format( 167 | instant.atOffset(ZoneOffset.UTC) 168 | ) 169 | 170 | private def checkExists( 171 | path: Path, 172 | uri: String 173 | ): IO[NotFound, Unit] = 174 | ZIO 175 | .attemptBlocking(Option(path.toFile.exists()).filter(identity)) 176 | .orDie 177 | .someOrFail(NotFound(uri)) 178 | .unit 179 | 180 | /** Read a response from a path. Uses blocking I/O, so that a file on the 181 | * local filesystem can be directly transferred to the connection using 182 | * OS-level primitives when possible. 183 | * 184 | * Note that you mustn't use this method if the response may be cached, as 185 | * (depending on the request) it may produce a `304 Not Modified` response. 186 | * You don't want that being served to other clients! Use 187 | * 188 | * @param path 189 | * A Path pointing to the file on the filesystem. 190 | * @param request 191 | * The request to respond to. This is used: 192 | * - To check if the `If-Modified-Since` header value included in the 193 | * request for this file. If given (in RFC 1123 format), an attempt will 194 | * be made to determine if the file has been modified since the requested 195 | * timestamp. If it hasn't, then the response returned will be a 304 Not 196 | * Modified response with no body. 197 | * - To provide the URI for a NotFound error, in case the path does not 198 | * exist. 199 | * @param contentType 200 | * The `Content-Type` header to use for the response. Defaults to 201 | * `application/octet-stream`. 202 | * @param status 203 | * The status of the response. Defaults to `Ok` (HTTP 200) 204 | * @param headers 205 | * Any additional headers to include in the response. 206 | * @return 207 | * A ZIO value which, when evaluated, will attempt to locate the given 208 | * resource and provide an appropriate [[Response]]. If the resource isn't 209 | * present, it will fail with [[HTTPError.NotFound]]. Since this response 210 | * interacts with the filesystem, it can fail with other arbitrary 211 | * Throwable failures; you'll probably need to catch these and convert them 212 | * to [[HTTPError]] failures. 213 | */ 214 | def fromPath( 215 | path: Path, 216 | request: Request, 217 | contentType: String = "application/octet-stream", 218 | status: Status = Status.Ok, 219 | headers: List[(String, String)] = Nil 220 | ): IO[Throwable, Response] = 221 | checkExists(path, request.uri.toString) *> checkModifiedSince( 222 | path, 223 | request.headers.get(IfModifiedSince) 224 | ).orElse { 225 | for { 226 | size <- ZIO.attemptBlocking(path.toFile.length()) 227 | modified <- getModifiedTime(path).map(formatInstant).option 228 | } yield PathResponse( 229 | status, 230 | path, 231 | size, 232 | modified.map(LastModified -> _).toList ::: repHeaders( 233 | contentType, 234 | size, 235 | headers 236 | ) 237 | ) 238 | } 239 | 240 | /** Read a response from a resource. Uses blocking I/O, so that a file on the 241 | * local filesystem can be directly transferred to the connection using 242 | * OS-level primitives when possible. 243 | * 244 | * @param name 245 | * The name (path) of the resource 246 | * @param request 247 | * The request to respond to. This is used: 248 | * - To check if the `If-Modified-Since` header value included in the 249 | * request for this file. If given (in RFC 1123 format), an attempt will 250 | * be made to determine if the file has been modified since the requested 251 | * timestamp. If it hasn't, then the response returned will be a 304 Not 252 | * Modified response with no body. 253 | * - To provide the URI for a NotFound error, in case the path does not 254 | * exist. 255 | * @param classLoader 256 | * The class loader which can find the resource (defaults to this class's 257 | * class loader) 258 | * @param contentType 259 | * The `Content-Type` header to use for the response. Defaults to 260 | * `application/octet-stream`. 261 | * @param status 262 | * The status of the response. Defaults to `Ok` (HTTP 200) 263 | * @param headers 264 | * Any additional headers to include in the response. 265 | * @return 266 | * A ZIO value which, when evaluated, will attempt to locate the given 267 | * resource and provide an appropriate [[Response]]. If the resource isn't 268 | * present, it will fail with [[HTTPError.NotFound]]. Since this response 269 | * interacts with the filesystem, it can fail with other arbitrary 270 | * Throwable failures; you'll probably need to catch these and convert them 271 | * to [[HTTPError]] failures. 272 | */ 273 | def fromResource( 274 | name: String, 275 | request: Request, 276 | classLoader: ClassLoader = getClass.getClassLoader, 277 | contentType: String = "application/octet-stream", 278 | status: Status = Status.Ok, 279 | headers: List[(String, String)] = Nil 280 | ): IO[Throwable, Response] = ZIO 281 | .attemptBlocking(Option(classLoader.getResource(name))) 282 | .someOrFail(NotFound(request.uri.toString)) 283 | .flatMap { resource => 284 | localPath(resource.toURI).some 285 | .tap(checkExists(_, request.uri.toString)) 286 | .flatMap(path => 287 | checkModifiedSince(path, request.headers.get(IfModifiedSince)) 288 | ) orElse { 289 | resource match { 290 | case url if url.getProtocol == "file" => 291 | for { 292 | path <- ZIO.attemptBlocking(Paths.get(url.toURI)) 293 | modified <- getModifiedTime(path).map(formatInstant) 294 | size <- ZIO.attemptBlocking(Files.size(path)) 295 | } yield PathResponse( 296 | status, 297 | path, 298 | size, 299 | (LastModified -> modified) :: repHeaders( 300 | contentType, 301 | size, 302 | headers 303 | ) 304 | ) 305 | case url => 306 | for { 307 | conn <- ZIO.attemptBlocking(url.openConnection()) 308 | _ <- ZIO.attemptBlocking(conn.connect()) 309 | modified = Option(conn.getLastModified) 310 | .map(Instant.ofEpochMilli) 311 | .map(formatInstant) 312 | size <- ZIO.attemptBlocking(conn.getContentLengthLong) 313 | rep <- fromInputStream( 314 | ZIO 315 | .attemptBlocking(conn.getInputStream) 316 | .toManagedWith(is => ZIO.succeed(is.close())), 317 | size = size, 318 | status = status, 319 | headers = modified 320 | .map(LastModified -> _) 321 | .toList ::: repHeaders(contentType, size, headers) 322 | ) 323 | } yield rep 324 | } 325 | } 326 | } 327 | 328 | def fromInputStream( 329 | stream: ZManaged[Any, Throwable, InputStream], 330 | size: Long, 331 | contentType: String = "application/octet-stream", 332 | status: Status = Status.Ok, 333 | headers: List[(String, String)] = Nil 334 | ): UIO[Response] = ZIO.succeed( 335 | InputStreamResponse( 336 | status, 337 | stream, 338 | size, 339 | repHeaders(contentType, size, headers) 340 | ) 341 | ) 342 | 343 | def fromStream( 344 | stream: Stream[Nothing, Chunk[Byte]], 345 | size: Long, 346 | contentType: String = "application/octet-stream", 347 | status: Status = Status.Ok, 348 | ifModifiedSince: Option[String] = None, 349 | headers: List[(String, String)] = Nil 350 | ): UIO[Response] = 351 | ZIO.succeed( 352 | ByteStreamResponse( 353 | status, 354 | size, 355 | stream.map(_.toArray), 356 | repHeaders(contentType, size, headers) 357 | ) 358 | ) 359 | 360 | /** Start a websocket request from a stream of [[uzhttp.websocket.Frame]] s. 361 | * @param req 362 | * The websocket request that initiated this response. 363 | * @param output 364 | * A stream of websocket [[uzhttp.websocket.Frame]] s to be sent to the 365 | * client. 366 | * @param headers 367 | * Any additional headers to include in the response. 368 | */ 369 | def websocket( 370 | req: Request, 371 | output: Stream[Throwable, Frame], 372 | headers: List[(String, String)] = Nil 373 | ): IO[BadRequest, WebsocketResponse] = { 374 | val handshakeHeaders = ZIO 375 | .succeed(req.headers.get("Sec-WebSocket-Key")) 376 | .someOrFail(BadRequest("Missing Sec-WebSocket-Key")) 377 | .map { acceptKey => 378 | val acceptHash = Base64.getEncoder.encodeToString { 379 | MessageDigest 380 | .getInstance("SHA-1") 381 | .digest( 382 | (acceptKey ++ "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") 383 | .getBytes(StandardCharsets.US_ASCII) 384 | ) 385 | } 386 | ("Upgrade", "websocket") :: ("Connection", "upgrade") :: ( 387 | "Sec-WebSocket-Accept", 388 | acceptHash 389 | ) :: headers 390 | } 391 | 392 | for { 393 | closed <- Promise.make[Throwable, Unit] 394 | headers <- handshakeHeaders 395 | _ = println("Creating Web socket response") 396 | } yield WebsocketResponse(output, closed, headers) 397 | } 398 | 399 | private def repHeaders( 400 | contentType: String, 401 | contentLength: Long, 402 | headers: List[(String, String)] 403 | ): List[(String, String)] = 404 | (ContentType -> contentType) :: (ContentLength -> contentLength.toString) :: headers 405 | 406 | private[uzhttp] def headerBytes(response: Response): Array[Byte] = { 407 | val statusLine = 408 | s"HTTP/1.1 ${response.status.statusCode} ${response.status.statusText}\r\n" 409 | val headers = response.headers.map { case (name, value) => 410 | s"$name: $value\r\n" 411 | }.mkString 412 | 413 | (statusLine + headers + "\r\n").getBytes(StandardCharsets.US_ASCII) 414 | } 415 | 416 | private final case class ByteStreamResponse( 417 | status: Status, 418 | size: Long, 419 | body: Stream[Nothing, Array[Byte]], 420 | headers: Headers 421 | ) extends Response { 422 | override def addHeaders(headers: (String, String)*): ByteStreamResponse = 423 | copy(headers = this.headers ++ headers) 424 | override def removeHeader(name: String): Response = 425 | copy(headers = headers.removed(name)) 426 | override private[uzhttp] def writeTo( 427 | connection: Server.ConnectionWriter 428 | ): IO[Throwable, Unit] = 429 | connection.writeByteArrays( 430 | Stream(Response.headerBytes(this)).concat(body) 431 | ) 432 | } 433 | 434 | private final case class ConstResponse( 435 | status: Status, 436 | body: Array[Byte], 437 | headers: Headers 438 | ) extends Response { 439 | override val size: Long = body.length.toLong 440 | override def addHeaders(headers: (String, String)*): ConstResponse = 441 | copy(headers = this.headers ++ headers) 442 | override def removeHeader(name: String): Response = 443 | copy(headers = headers.removed(name)) 444 | override private[uzhttp] def writeTo( 445 | connection: Server.ConnectionWriter 446 | ): IO[Throwable, Unit] = 447 | connection.writeByteArrays(Stream(Response.headerBytes(this), body)) 448 | } 449 | 450 | final case class PathResponse private[uzhttp] ( 451 | status: Status, 452 | path: Path, 453 | size: Long, 454 | headers: Headers 455 | ) extends Response { 456 | override def addHeaders(headers: (String, String)*): Response = 457 | copy(headers = this.headers ++ headers) 458 | override def removeHeader(name: String): Response = 459 | copy(headers = headers.removed(name)) 460 | override private[uzhttp] def writeTo( 461 | connection: Server.ConnectionWriter 462 | ): Task[Unit] = { 463 | ZIO 464 | .attemptBlocking(FileChannel.open(path, StandardOpenOption.READ)) 465 | .toManagedWith(chan => ZIO.succeed(chan.close())) 466 | .use { chan => 467 | connection.transferFrom( 468 | ByteBuffer.wrap(Response.headerBytes(this)), 469 | chan 470 | ) 471 | } 472 | } 473 | 474 | /** Produce a memory-mapped version of this response. NOTE: This will leak, 475 | * because it can't be unmapped. Only do this if you intend to keep the 476 | * memory-mapped response for the duration of your app. 477 | */ 478 | def mmap(uri: String): Task[Response] = for { 479 | _ <- checkExists(path, uri) 480 | modified <- getModifiedTime(path).map(formatInstant).option 481 | channel <- ZIO.attempt(FileChannel.open(path, StandardOpenOption.READ)) 482 | buffer <- ZIO.attempt(channel.map(FileChannel.MapMode.READ_ONLY, 0, size)) 483 | } yield MappedPathResponse( 484 | status, 485 | size, 486 | headers +? (LastModified, modified), 487 | buffer 488 | ) 489 | 490 | /** Like [[mmap]], but returns a managed resource that closes the file 491 | * channel after use. 492 | */ 493 | def mmapManaged(uri: String): ZManaged[Any, Throwable, Response] = { 494 | (checkExists(path, uri) *> getModifiedTime(path) 495 | .map(formatInstant) 496 | .option).toManaged.flatMap { modified => 497 | ZIO 498 | .attempt(FileChannel.open(path, StandardOpenOption.READ)) 499 | .toManagedWith(c => ZIO.succeed(c.close())) 500 | .mapZIO { channel => 501 | ZIO 502 | .attempt(channel.map(FileChannel.MapMode.READ_ONLY, 0, size)) 503 | .map { buffer => 504 | MappedPathResponse( 505 | status, 506 | size, 507 | headers +? (LastModified, modified), 508 | buffer 509 | ) 510 | } 511 | } 512 | } 513 | } 514 | } 515 | 516 | private final case class MappedPathResponse( 517 | status: Status, 518 | size: Long, 519 | headers: Headers, 520 | mappedBuf: MappedByteBuffer 521 | ) extends Response { 522 | override def addHeaders(headers: (String, String)*): Response = 523 | copy(headers = this.headers ++ headers) 524 | override def removeHeader(name: String): Response = 525 | copy(headers = headers.removed(name)) 526 | override private[uzhttp] def writeTo( 527 | connection: ConnectionWriter 528 | ): IO[Throwable, Unit] = 529 | connection.writeByteBuffers( 530 | Stream(ByteBuffer.wrap(headerBytes(this)), mappedBuf.duplicate()) 531 | ) 532 | } 533 | 534 | private final case class InputStreamResponse( 535 | status: Status, 536 | getInputStream: ZManaged[Any, Throwable, InputStream], 537 | size: Long, 538 | headers: Headers 539 | ) extends Response { 540 | override def addHeaders(headers: (String, String)*): Response = 541 | copy(headers = this.headers ++ headers) 542 | override def removeHeader(name: String): Response = 543 | copy(headers = headers.removed(name)) 544 | override private[uzhttp] def writeTo( 545 | connection: Server.ConnectionWriter 546 | ): Task[Unit] = 547 | getInputStream.use { is => 548 | connection.pipeFrom( 549 | ByteBuffer.wrap(Response.headerBytes(this)), 550 | is, 551 | if (size < 8192) size.toInt else 8192 552 | ) 553 | } 554 | } 555 | 556 | final case class WebsocketResponse private[uzhttp] ( 557 | frames: Stream[Throwable, Frame], 558 | closed: Promise[Throwable, Unit], 559 | headers: Headers 560 | ) extends Response { 561 | override val size: Long = -1L 562 | override val status: Status = Status.SwitchingProtocols 563 | override def addHeaders(headers: (String, String)*): Response = 564 | copy(headers = this.headers ++ headers) 565 | override def removeHeader(name: String): Response = 566 | copy(headers = headers.removed(name)) 567 | override def close: UIO[Unit] = closed.succeed(()).unit 568 | override private[uzhttp] val closeAfter = true 569 | 570 | override private[uzhttp] def writeTo( 571 | connection: Server.ConnectionWriter 572 | ): Task[Unit] = { 573 | connection.writeByteBuffers( 574 | Stream(ByteBuffer.wrap(Response.headerBytes(this))) ++ frames 575 | .map(_.toBytes) 576 | .haltWhen(closed) 577 | ) 578 | } 579 | } 580 | 581 | /** A response that passes through an underlying response the first time, 582 | * while caching it in memory for any future outputs. 583 | */ 584 | private final class CachedResponse( 585 | underlying: Response, 586 | contents: Ref[Option[Promise[Throwable, ByteBuffer]]] 587 | ) extends Response { 588 | override def size: Long = underlying.size 589 | override def addHeaders(headers: (String, String)*): Response = 590 | new CachedResponse(underlying.addHeaders(headers: _*), contents) 591 | override def removeHeader(name: String): Response = 592 | new CachedResponse(underlying.removeHeader(name), contents) 593 | override def status: Status = underlying.status 594 | override def headers: Headers = underlying.headers 595 | override private[uzhttp] def writeTo( 596 | connection: ConnectionWriter 597 | ): Task[Unit] = contents.get.flatMap { 598 | case Some(promise) => 599 | promise.await.flatMap(buf => connection.write(buf.duplicate())) 600 | case None => 601 | Promise 602 | .make[Throwable, ByteBuffer] 603 | .flatMap { promise => 604 | contents 605 | .updateSomeAndGet { case None => 606 | Some(promise) 607 | } 608 | .someOrFail(new IllegalStateException("Promise should exist")) 609 | .flatMap { promise => 610 | connection.tap 611 | .flatMap { tappedConnection => 612 | underlying.writeTo(tappedConnection).flatMap { _ => 613 | tappedConnection.finish.flatMap(c => promise.succeed(c)) 614 | } 615 | } 616 | .tapError(promise.fail) 617 | } 618 | } 619 | .unit 620 | } 621 | 622 | def free: UIO[Unit] = contents.setAsync(None) 623 | } 624 | 625 | private object CachedResponse { 626 | def make(underlying: Response): UIO[CachedResponse] = 627 | Ref.make[Option[Promise[Throwable, ByteBuffer]]](None).map { promise => 628 | new CachedResponse(underlying, promise) 629 | } 630 | 631 | def managed(underlying: Response): ZManaged[Any, Nothing, CachedResponse] = 632 | make(underlying).toManagedWith(_.free) 633 | } 634 | 635 | /** A cache that memoizes responses for eligible requests, and can cache 636 | * response bodies of eligible responses 637 | */ 638 | class PermanentCache( 639 | shouldMemoize: Request => Boolean, 640 | cachedResponse: (Request, Response) => ZIO[Any, Unit, Response], 641 | cacheKey: Request => String, 642 | requestHandler: PartialFunction[Request, IO[HTTPError, Response]] 643 | ) extends PartialFunction[Request, IO[HTTPError, Response]] { 644 | private val cache: ConcurrentHashMap[String, Promise[HTTPError, Response]] = 645 | new ConcurrentHashMap() 646 | 647 | override def isDefinedAt(request: Request): Boolean = 648 | requestHandler.isDefinedAt(request) 649 | override def apply(request: Request): IO[HTTPError, Response] = if ( 650 | shouldMemoize(request) 651 | ) { 652 | val key = cacheKey(request) 653 | cache.get(key) match { 654 | case null => 655 | Promise.make[HTTPError, Response].flatMap { promise => 656 | cache.putIfAbsent(key, promise) 657 | val p = cache.get(key) 658 | requestHandler(request.removeHeader(IfModifiedSince)) 659 | .tapError(p.fail) 660 | .flatMap { response => 661 | cachedResponse(request, response).orElseSucceed(response) 662 | } 663 | .tap(p.succeed) 664 | } 665 | case promise => 666 | promise.await.flatMap { response => 667 | ( 668 | parseModDateOpt( 669 | response.headers.get(LastModified) 670 | ) <*> parseModDateOpt(request.headers.get(IfModifiedSince)) 671 | ).map { case (t1, t2) => t1 isBefore t2 } 672 | .filterOrFail(_ == false)(None) 673 | .as(notModified) 674 | .orElseSucceed(response) 675 | } 676 | } 677 | } else requestHandler(request) 678 | 679 | } 680 | 681 | object PermanentCache { 682 | def defaultCachedResponse( 683 | mmapThreshold: Int = 1 << 20 684 | ): (Request, Response) => ZIO[Any, Unit, Response] = 685 | (req, rep) => 686 | rep match { 687 | case rep if rep.size >= 0 && rep.size < mmapThreshold => 688 | CachedResponse.make(rep) 689 | case rep: PathResponse => rep.mmap(req.uri.toString).orElseFail(()) 690 | case _ => ZIO.fail(()) 691 | } 692 | 693 | val alwaysCache: (Request, Response) => ZIO[Any, Unit, Response] = 694 | (req, rep) => rep.cached 695 | 696 | def defaultShouldMemoize: Request => Boolean = req => 697 | req.method == Method.GET && !req.headers 698 | .get("Upgrade") 699 | .contains("websocket") 700 | 701 | case class Builder[R] private[uzhttp] ( 702 | cachedResponse: (Request, Response) => ZIO[R, Unit, Response], 703 | requestHandler: PartialFunction[Request, ZIO[R, HTTPError, Response]] = 704 | PartialFunction.empty, 705 | shouldMemoize: Request => Boolean = defaultShouldMemoize, 706 | cacheKey: Request => String = _.uri.toString 707 | ) { 708 | 709 | /** @see 710 | * [[uzhttp.server.Server.Builder.handleSome]] 711 | */ 712 | def handleSome[R1 <: R]( 713 | handler: PartialFunction[Request, ZIO[R1, HTTPError, Response]] 714 | ): Builder[R1] = copy(requestHandler = requestHandler orElse handler) 715 | 716 | /** @see 717 | * [[uzhttp.server.Server.Builder.handleAll]] 718 | */ 719 | def handleAll[R1 <: R]( 720 | handler: Request => ZIO[R1, HTTPError, Response] 721 | ): Builder[R1] = copy(requestHandler = requestHandler orElse { case req => 722 | handler(req) 723 | }) 724 | 725 | /** Provide a test which decides whether or not to memoize the response 726 | * for a given request. The default is to memoize all GET requests (other 727 | * than websocket requests) and not memoize any other requests. 728 | */ 729 | def memoizeIf(test: Request => Boolean): Builder[R] = 730 | copy(shouldMemoize = test) 731 | 732 | /** Provide a function which generates a cached response given a request 733 | * and response. The default behavior is: 734 | * - If the response is smaller than ~1MB (`2^20` bytes) then cache it 735 | * in memory 736 | * - If the response is larger than 1MB and is a [[PathResponse]], 737 | * permanently memory-map it rather than storing it on heap (kernel 738 | * manages the cache) 739 | * - Otherwise, just memoize the response (don't cache it) 740 | */ 741 | def cacheWith[R1 <: R]( 742 | fn: (Request, Response) => ZIO[R1, Unit, Response] 743 | ): Builder[R1] = copy(cachedResponse = fn) 744 | 745 | /** Provide a function which extracts a String cache key from a request. 746 | * The default is to use the request's entire URI as the cache key. 747 | */ 748 | def withCacheKey(key: Request => String): Builder[R] = 749 | copy(cacheKey = key) 750 | 751 | /** Return the configured cache as a ZManaged value 752 | */ 753 | def build: ZManaged[R, Nothing, PermanentCache] = 754 | ZManaged.environment[R].map { env => 755 | new PermanentCache( 756 | shouldMemoize, 757 | (req, rep) => cachedResponse(req, rep).provideEnvironment(env), 758 | cacheKey, 759 | requestHandler.andThen(_.provideEnvironment(env)) 760 | ) 761 | } 762 | } 763 | } 764 | 765 | /** Build a caching layer which can memoize and cache responses in memory for 766 | * the duration of the server's lifetime. 767 | */ 768 | def permanentCache: PermanentCache.Builder[Any] = 769 | PermanentCache.Builder(cachedResponse = 770 | PermanentCache.defaultCachedResponse() 771 | ) 772 | 773 | } 774 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/Status.scala: -------------------------------------------------------------------------------- 1 | package uzhttp 2 | 3 | import uzhttp.Status.Inst 4 | 5 | trait Status { 6 | def statusCode: Int 7 | def statusText: String 8 | 9 | override def toString: String = s"$statusCode $statusText" 10 | } 11 | 12 | object Status { 13 | class Inst(val statusCode: Int, val statusText: String) extends Status with Serializable 14 | def apply(statusCode: Int, statusText: String): Status = new Inst(statusCode, statusText) 15 | 16 | object Continue extends Inst(100, "Continue") 17 | object SwitchingProtocols extends Inst(101, "Switching Protocols") 18 | 19 | object Ok extends Inst(200, "OK") 20 | object Created extends Inst(201, "Created") 21 | object Accepted extends Inst(202, "Accepted") 22 | 23 | object MultipleChoices extends Inst(300, "Multiple Choices") 24 | object MovedPermanently extends Inst(301, "Moved Permanently") 25 | object Found extends Inst(302, "Found") 26 | object SeeOther extends Inst(302, "See Other") 27 | object NotModified extends Inst(304, "Not Modified") 28 | object TemporaryRedirect extends Inst(307, "Temporary Redirect") 29 | object PermanentRedirect extends Inst(308, "Permanent Redirect") 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/Version.scala: -------------------------------------------------------------------------------- 1 | package uzhttp 2 | 3 | import uzhttp.HTTPError.BadRequest 4 | 5 | 6 | sealed abstract class Version(val string: String) 7 | object Version { 8 | case object Http09 extends Version("0.9") 9 | case object Http10 extends Version("1.0") 10 | case object Http11 extends Version("1.1") 11 | 12 | def parseEither(str: String): Either[BadRequest, Version] = str.slice(5, 8) match { 13 | case "0.9" => Right(Http09) 14 | case "1.0" => Right(Http10) 15 | case "1.1" => Right(Http11) 16 | case _ => Left(BadRequest("Invalid HTTP version identifier")) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/header/Headers.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.header 2 | 3 | import uzhttp.HTTPError.BadRequest 4 | 5 | import scala.collection.immutable.ListMap 6 | import scala.language.implicitConversions 7 | 8 | final class Headers private (private val mapWithLowerCaseKeys: Map[String, (String, String)]) extends CompatMap[String, String] with scala.collection.immutable.Map[String, String] with Serializable { 9 | override def updated[V1 >: String](key: String, value: V1): Headers = new Headers(mapWithLowerCaseKeys + (key.toLowerCase -> (key -> value.toString))) 10 | override def +[B1 >: String](kv: (String, B1)): Headers = updated(kv._1, kv._2) 11 | override def get(key: String): Option[String] = mapWithLowerCaseKeys.get(key.toLowerCase()).map(_._2) 12 | override def iterator: Iterator[(String, String)] = mapWithLowerCaseKeys.iterator.map { 13 | case (_, origKeyWithValue) => origKeyWithValue 14 | } 15 | override def removed(key: String): Headers = new Headers(mapWithLowerCaseKeys - key.toLowerCase) 16 | 17 | override def contains(key: String): Boolean = mapWithLowerCaseKeys.contains(key.toLowerCase()) 18 | 19 | override def toList: List[(String, String)] = mapWithLowerCaseKeys.toList.map(_._2) 20 | 21 | def ++(kvs: Seq[(String, String)]): Headers = new Headers(mapWithLowerCaseKeys ++ ListMap(kvs.map(kv => kv._1.toLowerCase -> (kv._1 -> kv._2)): _*)) 22 | def ++(headers: Headers): Headers = new Headers(mapWithLowerCaseKeys ++ headers.mapWithLowerCaseKeys) 23 | 24 | def +?(kv: (String, Option[String])): Headers = kv match { 25 | case (key, Some(v)) => this + (key -> v) 26 | case (_, None) => this 27 | } 28 | } 29 | 30 | object Headers { 31 | def apply(kvs: (String, String)*): Headers = new Headers(ListMap(kvs: _*).map(kv => kv._1.toLowerCase -> (kv._1 -> kv._2))) 32 | def apply(map: Map[String, String]): Headers = new Headers(ListMap(map.toSeq: _*).map(kv => kv._1.toLowerCase -> (kv._1 -> kv._2))) 33 | 34 | def fromLines(lines: List[String]): Headers = apply { 35 | lines.map { 36 | str => 37 | str.indexOf(':') match { 38 | case -1 => 39 | throw BadRequest(s"No key separator in header $str") 40 | case n => 41 | str.splitAt(n) match { 42 | case (k, v) => (k, v.drop(1).dropWhile(_.isWhitespace)) 43 | } 44 | } 45 | } 46 | } 47 | 48 | implicit def fromSeq(kvs: Seq[(String, String)]): Headers = apply(kvs: _*) 49 | 50 | val empty: Headers = apply() 51 | 52 | val CacheControl: String = "Cache-Control" 53 | val ContentLength: String = "Content-Length" 54 | val ContentType: String = "Content-Type" 55 | val IfModifiedSince: String = "If-Modified-Since" 56 | val LastModified: String = "Last-Modified" 57 | } -------------------------------------------------------------------------------- /src/main/scala/uzhttp/package.scala: -------------------------------------------------------------------------------- 1 | import java.io.FileNotFoundException 2 | import java.net.URI 3 | import java.nio.file.NoSuchFileException 4 | 5 | import zio.ZIO 6 | 7 | package object uzhttp { 8 | 9 | 10 | private[uzhttp] val CRLF: Array[Byte] = Array('\r', '\n') 11 | 12 | // To provide right-bias in Scala 2.11 13 | private[uzhttp] implicit class EitherCompat[+L, +R](val self: Either[L, R]) extends AnyVal { 14 | def flatMap[L1 >: L, R1](fn: R => Either[L1, R1]): Either[L1, R1] = self match { 15 | case Left(l) => Left(l) 16 | case Right(r) => fn(r) 17 | } 18 | 19 | def map[R1](fn: R => R1): Either[L, R1] = self match { 20 | case Left(l) => Left(l) 21 | case Right(r) => Right(fn(r)) 22 | } 23 | } 24 | 25 | implicit class RefineOps[R](val self: ZIO[R, Throwable, Response]) extends AnyVal { 26 | /** 27 | * A default mapping of arbitrary Throwable to HTTP error. If it's a FileNotFoundException or NoSuchFileException, 28 | * a [[uzhttp.HTTPError.NotFound]] is generated; otherwise the error is wrapped in [[uzhttp.HTTPError.InternalServerError]]. 29 | * 30 | * This is provided for convenience, in case you don't want to handle non-HTTP errors yourself. 31 | */ 32 | def refineHTTP(req: Request): ZIO[R, HTTPError, Response] = self.mapError { 33 | case err: HTTPError => err 34 | case err: FileNotFoundException => HTTPError.NotFound(req.uri.toString) 35 | case err: NoSuchFileException => HTTPError.NotFound(req.uri.toString) 36 | case err => HTTPError.InternalServerError(err.getMessage, Some(err)) 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/server/Server.scala: -------------------------------------------------------------------------------- 1 | package uzhttp 2 | package server 3 | 4 | import java.io.{ByteArrayOutputStream, InputStream} 5 | import java.net.{InetSocketAddress, SocketAddress, URI} 6 | import java.nio.ByteBuffer 7 | import java.nio.channels._ 8 | import java.nio.charset.StandardCharsets 9 | import java.util.concurrent.TimeUnit 10 | import java.util.concurrent.atomic.AtomicReference 11 | 12 | import izumi.reflect.macrortti.LightTypeTag 13 | 14 | import uzhttp.HTTPError.{BadRequest, NotFound, RequestTimeout} 15 | 16 | import zio._ 17 | import zio.stream._ 18 | 19 | class Server private ( 20 | channel: ServerSocketChannel, 21 | requestHandler: Request => IO[HTTPError, Response], 22 | errorHandler: HTTPError => IO[Nothing, Response], 23 | config: Server.Config, 24 | closed: Promise[Throwable, Unit] 25 | ) { 26 | 27 | /** @return 28 | * The bound address of the server. This is useful if the port was 29 | * configured to `0` in order to use an OS-selected free port. 30 | */ 31 | def localAddress: Task[SocketAddress] = 32 | awaitUp *> ZIO.attempt(channel.getLocalAddress) 33 | 34 | /** @return 35 | * A task which will complete when the server's socket channel is open. 36 | */ 37 | def awaitUp: Task[Unit] = 38 | ZIO.attempt(channel.isOpen).repeatUntil(identity).unit 39 | 40 | def uri: UIO[Option[URI]] = localAddress 41 | .map { 42 | case inet: InetSocketAddress => 43 | Some( 44 | new URI("http", null, inet.getHostName, inet.getPort, "/", null, null) 45 | ) 46 | case _ => None 47 | } 48 | .orElse(ZIO.none) 49 | 50 | /** Shut down the server. 51 | */ 52 | def shutdown(): UIO[Unit] = 53 | ZIO.attempt(if (channel.isOpen) channel.close()).intoPromise(closed).unit 54 | 55 | def awaitShutdown: IO[Throwable, Unit] = closed.await 56 | 57 | private def serve(): RIO[Clock, Nothing] = 58 | Server.ChannelSelector(channel, requestHandler, errorHandler, config).use { 59 | selector => 60 | uri 61 | .someOrFail(()) 62 | .flatMap(uri => ZIO.logInfo(s"Server listening on $uri")) 63 | .ignore *> selector.run 64 | } 65 | 66 | } 67 | 68 | object Server { 69 | 70 | case class Config( 71 | maxPending: Int = 0, 72 | responseTimeout: Duration = Duration.Infinity, 73 | connectionIdleTimeout: Duration = Duration.Infinity, 74 | inputBufferSize: Int = 8192 75 | ) 76 | 77 | /** Create a server [[Builder]] using the specified address. 78 | */ 79 | def builder(address: InetSocketAddress): Builder[Any] = Builder(address) 80 | 81 | final case class Builder[-R: Tag: IsNotIntersection] private[Server] ( 82 | address: InetSocketAddress, 83 | config: Config = Config(), 84 | requestHandler: PartialFunction[Request, ZIO[R, HTTPError, Response]] = 85 | PartialFunction.empty, 86 | errorHandler: HTTPError => ZIO[R, Nothing, Response] = 87 | defaultErrorFormatter 88 | ) { 89 | 90 | /** Set the address on which the server should listen, replacing the 91 | * currently set address. Specifying a port of `0` will cause the server to 92 | * bind on an operating-system assigned free port. 93 | */ 94 | def withAddress(address: InetSocketAddress): Builder[R] = 95 | copy(address = address) 96 | 97 | /** Set the maximum number of pending connections. The default of `0` 98 | * specifies a platform-specific default value. 99 | * @see 100 | * ServerSocketChannel#bind(SocketAddress, Int) 101 | */ 102 | def withMaxPending(maxPending: Int): Builder[R] = 103 | copy(config = config.copy(maxPending = maxPending)) 104 | 105 | /** Set the timeout before which the request handler must begin a response. 106 | * The default is no timeout. 107 | */ 108 | def withResponseTimeout(responseTimeout: Duration): Builder[R] = 109 | copy(config = config.copy(responseTimeout = responseTimeout)) 110 | 111 | /** Set the timeout for closing idle connections (if the server receives no 112 | * request within the given timeout). The default is no timeout, relying on 113 | * clients to behave well and close their own idle connections. 114 | */ 115 | def withConnectionIdleTimeout(idleTimeout: Duration): Builder[R] = 116 | copy(config = config.copy(connectionIdleTimeout = idleTimeout)) 117 | 118 | /** Provide a total function which will handle all requests not handled by a 119 | * previously given partial handler given to [[handleSome]]. 120 | */ 121 | def handleAll[R1 <: R: Tag: IsNotIntersection]( 122 | handler: Request => ZIO[R1, HTTPError, Response] 123 | ): Builder[R1] = 124 | copy(requestHandler = requestHandler orElse { case x => handler(x) }) 125 | 126 | /** Provide a partial function which will handle matched requests not 127 | * already handled by a previously given partial handler. 128 | */ 129 | def handleSome[R1 <: R: Tag: IsNotIntersection]( 130 | handler: PartialFunction[Request, ZIO[R1, HTTPError, Response]] 131 | ): Builder[R1] = 132 | copy(requestHandler = requestHandler orElse handler) 133 | 134 | /** Provide an error formatter which turns an [[HTTPError]] from a failed 135 | * request into a [[Response]] to be returned to the client. The default 136 | * error formatter returns a plaintext response indicating the error. 137 | */ 138 | def errorResponse[R1 <: R: Tag: IsNotIntersection]( 139 | errorHandler: HTTPError => URIO[R1, Response] 140 | ): Builder[R1] = 141 | copy(errorHandler = errorHandler) 142 | 143 | private def build: ZManaged[R, Throwable, Server] = 144 | mkSocket(address, config.maxPending) 145 | .flatMap { channel => 146 | Promise.make[Throwable, Unit].toManaged.flatMap { closed => 147 | ZIO 148 | .environment[R] 149 | .flatMap { env => 150 | ZIO.succeed( 151 | new Server( 152 | channel, 153 | (requestHandler orElse unhandled) andThen (_.provideEnvironment( 154 | env 155 | )), 156 | errorHandler andThen (_.provideEnvironment(env)), 157 | config, 158 | closed 159 | ) 160 | ) 161 | } 162 | .toManagedWith { server => 163 | ZIO.logInfo("Shutting down server") *> server.shutdown() 164 | } 165 | } 166 | } 167 | 168 | /** Start the server and begin serving requests. The returned [[Server]] 169 | * reference can be used to wait for the server to be up, to retrieve the 170 | * bound address, and to shut it down if desired. After using the returned 171 | * ZManaged, the server will be shut down (you can use ZManaged.useForever 172 | * to keep the server running until termination of your app) 173 | */ 174 | def serve: ZManaged[R with Clock, Throwable, Server] = { 175 | val res = ZManaged.environment[R with Clock].flatMap { env => 176 | build 177 | .tap(_.serve().forkManaged) 178 | } 179 | 180 | res 181 | } 182 | 183 | } 184 | 185 | private[uzhttp] trait ConnectionWriter { 186 | def withWriteLock[R, E]( 187 | fn: WritableByteChannel => ZIO[R, E, Unit] 188 | ): ZIO[R, E, Unit] 189 | private def writeInternal( 190 | channel: WritableByteChannel, 191 | bytes: ByteBuffer 192 | ): Task[Unit] = 193 | ZIO 194 | .attempt(channel.write(bytes)) 195 | .as(bytes) 196 | .repeatWhile(_.hasRemaining) 197 | .unit 198 | def write(bytes: ByteBuffer): Task[Unit] = 199 | withWriteLock(channel => writeInternal(channel, bytes)) 200 | def write(bytes: Array[Byte]): Task[Unit] = write(ByteBuffer.wrap(bytes)) 201 | def writeByteBuffers(buffers: Stream[Throwable, ByteBuffer]): Task[Unit] = 202 | withWriteLock(channel => 203 | buffers.foreach(buf => writeInternal(channel, buf)) 204 | ) 205 | def writeByteArrays(arrays: Stream[Throwable, Array[Byte]]): Task[Unit] = 206 | writeByteBuffers(arrays.map(ByteBuffer.wrap)) 207 | def transferFrom( 208 | header: ByteBuffer, 209 | src: FileChannel 210 | ): IO[Throwable, Unit] = withWriteLock { channel => 211 | writeInternal(channel, header) *> ZIO 212 | .attemptBlocking(src.size()) 213 | .flatMap { size => 214 | writeInternal(channel, header) *> ZIO.attemptBlocking { 215 | var pos = 0L 216 | while (pos < size) 217 | pos += src.transferTo(pos, size - pos, channel) 218 | } 219 | } 220 | } 221 | 222 | def pipeFrom( 223 | header: ByteBuffer, 224 | is: InputStream, 225 | bufSize: Int 226 | ): IO[Throwable, Unit] = withWriteLock { channel => 227 | writeInternal(channel, header) *> ZIO.attemptBlocking { 228 | val buf = new Array[Byte](bufSize) 229 | val byteBuf = ByteBuffer.wrap(buf) 230 | var numRead = is.read(buf) 231 | while (numRead != -1) { 232 | byteBuf.limit(numRead) 233 | byteBuf.position(0) 234 | channel.write(byteBuf) 235 | numRead = is.read(buf) 236 | } 237 | } 238 | } 239 | 240 | def tap: UIO[ConnectionWriter.TappedWriter] = 241 | ZIO.succeed(new ConnectionWriter.TappedWriter(this)) 242 | } 243 | 244 | private[uzhttp] object ConnectionWriter { 245 | private final class TappedChannel( 246 | val underlying: AtomicReference[WritableByteChannel] = 247 | new AtomicReference(null) 248 | ) extends WritableByteChannel { 249 | val output: ByteArrayOutputStream = new ByteArrayOutputStream() 250 | val outputChannel: WritableByteChannel = Channels.newChannel(output) 251 | 252 | override def write(src: ByteBuffer): Int = { 253 | val dup = src.duplicate() 254 | val written = underlying.get.write(src) 255 | dup.limit(src.position()) 256 | outputChannel.write(dup) 257 | written 258 | } 259 | 260 | override def isOpen: Boolean = underlying.get.isOpen 261 | override def close(): Unit = underlying.get.close() 262 | } 263 | 264 | final class TappedWriter(underlying: ConnectionWriter) 265 | extends ConnectionWriter { 266 | private val tappedChannel = new TappedChannel() 267 | override def withWriteLock[R, E]( 268 | fn: WritableByteChannel => ZIO[R, E, Unit] 269 | ): ZIO[R, E, Unit] = underlying.withWriteLock { underlyingChannel => 270 | tappedChannel.underlying.set(underlyingChannel) 271 | fn(tappedChannel) 272 | } 273 | def finish: UIO[ByteBuffer] = ZIO 274 | .succeed( 275 | tappedChannel.outputChannel.close() 276 | ) 277 | .as(ByteBuffer.wrap(tappedChannel.output.toByteArray)) 278 | } 279 | } 280 | 281 | private[uzhttp] final class Connection private ( 282 | inputBuffer: ByteBuffer, 283 | curReq: Ref[Either[(Int, List[String]), ContinuingRequest]], 284 | curRep: Ref[Option[Response]], 285 | requestHandler: Request => IO[HTTPError, Response], 286 | errorHandler: HTTPError => UIO[Response], 287 | config: Config, 288 | private[Server] val channel: ReadableByteChannel 289 | with WritableByteChannel 290 | with SelectableChannel, 291 | locks: Connection.Locks, 292 | shutdown: Promise[Throwable, Unit], 293 | idleTimeoutFiber: Ref[Fiber.Runtime[Nothing, Option[Unit]]] 294 | ) extends ConnectionWriter { 295 | 296 | import config._ 297 | import locks._ 298 | 299 | override def withWriteLock[R, E]( 300 | fn: WritableByteChannel => ZIO[R, E, Unit] 301 | ): ZIO[R, E, Unit] = writeLock.withPermit(fn(channel)) 302 | 303 | /** Take n bytes from the top of the input buffer, and shift the remaining 304 | * bytes (up to the buffer's position), if any, to the beginning of the 305 | * buffer. Afterward, the buffer's position will be after the end of the 306 | * remaining bytes. 307 | * 308 | * PERF: A low-hanging performance improvement here would be to not shift 309 | * and rewind the buffer until it reaches the end. That would add a bit 310 | * more complexity, but could really boost performance. 311 | */ 312 | private def takeAndRewind(n: Int) = { 313 | val arr = new Array[Byte](n) 314 | val pos = inputBuffer.position() 315 | val remainderLength = pos - n 316 | inputBuffer.rewind() 317 | inputBuffer.get(arr) 318 | 319 | if (remainderLength > 0) { 320 | val rem = inputBuffer.slice() 321 | rem.limit(remainderLength) 322 | inputBuffer.rewind() 323 | inputBuffer.put(rem) 324 | } else { 325 | inputBuffer.rewind() 326 | } 327 | arr 328 | } 329 | 330 | private val timeoutRequest: Request => ZIO[Clock, HTTPError, Response] = 331 | responseTimeout match { 332 | case Duration.Infinity => requestHandler 333 | case duration if duration.isZero => requestHandler 334 | case duration => 335 | requestHandler andThen 336 | (_.timeoutFail( 337 | RequestTimeout( 338 | s"Request could not be handled within ${duration.render}" 339 | ) 340 | )(duration)) 341 | } 342 | 343 | private def handleRequest(req: Request) = requestLock.withPermit { 344 | timeoutRequest(req) 345 | .catchAll(errorHandler) 346 | .timed 347 | .tap { case (dur, rep) => 348 | ZIO.logDebug { 349 | val size = rep.headers 350 | .get("Content-Length") 351 | .map(cl => 352 | try cl.toLong 353 | catch { case _: Throwable => -1 } 354 | ) 355 | .filterNot(_ < 0) 356 | .map(humanReadableByteCountSI) 357 | .getOrElse("(Unknown size)") 358 | s"${req.uri} ${rep.status} $size (${dur.render} to start)" 359 | } 360 | } 361 | .map { case (dur, rep) => 362 | val shouldClose = req.version match { 363 | case Version.Http09 => true 364 | case Version.Http10 => 365 | !req.headers.get("Connection").contains("keepalive") 366 | case Version.Http11 => 367 | req.headers.get("Connection").contains("close") 368 | } 369 | if (!rep.closeAfter && shouldClose) 370 | dur -> (req, rep.addHeader("Connection", "close")) 371 | else dur -> (req, rep) 372 | } 373 | .flatMap { case (startDuration, (req, rep)) => 374 | curRep.set(Some(rep)) *> rep 375 | .writeTo(this) 376 | .onTermination { cause => 377 | ZIO.logError( 378 | s"Error writing response; closing connection [${cause}]" 379 | ) *> close() 380 | } 381 | .ensuring { 382 | if (rep.closeAfter) 383 | close() 384 | else 385 | ZIO.unit 386 | } 387 | .timed 388 | .flatMap { case (finishDuration, _) => 389 | curReq.set(Left(0 -> Nil)) 390 | } 391 | } 392 | } 393 | 394 | val doRead: RIO[Clock, Unit] = 395 | readLock.withPermit { 396 | def bytesReceived: ZIO[Clock, HTTPError, Unit] = if ( 397 | inputBuffer.position() > 0 398 | ) { 399 | val numBytes = inputBuffer.position() 400 | 401 | def readNext( 402 | state: Either[(Int, List[String]), ContinuingRequest] 403 | ): ZIO[Clock, HTTPError, Unit] = 404 | state match { 405 | case Right(req) => 406 | req.bytesRemaining.flatMap { 407 | case bytesRemaining if bytesRemaining <= numBytes => 408 | val remainderLength = (numBytes - bytesRemaining).toInt 409 | val takeLength = numBytes - remainderLength 410 | val chunk = takeAndRewind(takeLength) 411 | req.submitBytes(Chunk.fromArray(chunk)) *> curReq.set( 412 | Left(0 -> Nil) 413 | ) *> bytesReceived 414 | 415 | case _ if !inputBuffer.hasRemaining || req.noBufferInput => 416 | // take a chunk of data iff the buffer is full 417 | val chunk = takeAndRewind(numBytes) 418 | req.submitBytes(Chunk.fromArray(chunk)) 419 | 420 | case _ => 421 | ZIO.unit // wait for more data 422 | } 423 | 424 | case Left((prevPos, headerChunks)) => 425 | // search for \r\n\r\n in the buffer to mark end of headers 426 | var found = -1 427 | var takeLimit = -1 428 | var idx = math.max(0, prevPos - 4) 429 | val end = inputBuffer.position() - 3 430 | while (found < 0 && idx < end) { 431 | if (inputBuffer.get(idx) == '\r') { 432 | takeLimit = idx - 1 433 | idx += 1 434 | if (inputBuffer.get(idx) == '\n') { 435 | idx += 1 436 | if (inputBuffer.get(idx) == '\r') { 437 | idx += 1 438 | if (inputBuffer.get(idx) == '\n') { 439 | found = idx 440 | } else { 441 | return Connection.mismatchCRLFError 442 | } 443 | } else { 444 | idx += 1 445 | } 446 | } else { 447 | return Connection.mismatchCRLFError 448 | } 449 | } else { 450 | idx += 1 451 | takeLimit = -1 452 | } 453 | } 454 | if (found >= 0) { 455 | // finished the headers – decide what kind of request it is and build the request 456 | val chunk = takeAndRewind(found + 1) 457 | val reqString = (new String( 458 | chunk, 459 | StandardCharsets.US_ASCII 460 | ) :: headerChunks).reverse.mkString.trim() 461 | val mkReq = IO 462 | .fromEither(Request.NoBody.fromReqString(reqString)) 463 | .flatMap { 464 | case Request.NoBody(method, uri, version, headers) 465 | if headers.get("Upgrade").contains("websocket") => 466 | for { 467 | request <- Request.WebsocketRequest( 468 | method, 469 | uri, 470 | version, 471 | headers 472 | ) 473 | _ <- curReq.set(Right(request)) 474 | _ <- handleRequest(request).forkDaemon 475 | _ <- stopIdleTimeout 476 | } yield () 477 | 478 | case Request.NoBody(method, uri, version, headers) 479 | if headers.contains("Content-Length") && headers( 480 | "Content-Length" 481 | ) != "0" => 482 | for { 483 | contentLength <- IO(headers("Content-Length").toLong) 484 | .orElseFail( 485 | BadRequest("Couldn't parse Content-Length") 486 | ) 487 | request <- Request.ReceivingBody.create( 488 | method, 489 | uri, 490 | version, 491 | headers, 492 | contentLength 493 | ) 494 | _ <- curReq.set(Right(request)) 495 | _ <- handleRequest(request).forkDaemon 496 | } yield () 497 | 498 | case request => 499 | handleRequest(request).forkDaemon 500 | } 501 | 502 | mkReq *> bytesReceived 503 | } else if (!inputBuffer.hasRemaining) { // only take a chunk of headers when the buffer is full 504 | if (takeLimit > 0) { 505 | // can safely take this chunk of header data and rewind the buffer – only take up to a \r to avoid splitting across the empty line chars 506 | val chunk = takeAndRewind(takeLimit) 507 | val remainderLength = inputBuffer.position() 508 | curReq.set( 509 | Left( 510 | remainderLength -> (new String( 511 | chunk, 512 | StandardCharsets.US_ASCII 513 | ) :: headerChunks) 514 | ) 515 | ) *> bytesReceived 516 | } else if (takeLimit < 0) { 517 | // can safely take the whole data and rewind the buffer 518 | val chunk = takeAndRewind(numBytes) 519 | curReq.set( 520 | Left( 521 | 0 -> (new String( 522 | chunk, 523 | StandardCharsets.US_ASCII 524 | ) :: headerChunks) 525 | ) 526 | ) 527 | } else { 528 | // This can only happen if the buffer is catastrophically small (like 2 bytes and only contains \r\n) 529 | Connection.mismatchCRLFError 530 | } 531 | } else ZIO.unit 532 | } 533 | curReq.get.flatMap(readNext) 534 | } else ZIO.yieldNow 535 | 536 | ZIO 537 | .attempt(channel.read(inputBuffer)) 538 | .flatMap { 539 | case -1 => close() 540 | case _ => resetIdleTimeout &> bytesReceived 541 | } 542 | .catchAll { 543 | case err: ClosedChannelException => 544 | ZIO.logError( 545 | s"Client closed connection unexpectedly [${err.getMessage()}]" 546 | ) *> close() 547 | case err => 548 | ZIO.logError( 549 | s"Closing connection due to read error [${err.getMessage()}]" 550 | ) *> close() 551 | } 552 | } 553 | 554 | private def endCurrentRequest = curReq.get.flatMap { 555 | case Right(req) => req.channelClosed() 556 | case _ => ZIO.unit 557 | } 558 | 559 | private def closeResponse = curRep.get.flatMap { 560 | case Some(rep) => rep.close 561 | case None => ZIO.unit 562 | } 563 | 564 | def close(): UIO[Unit] = 565 | shutdown.succeed(()).flatMap { 566 | case true => 567 | ZIO.logDebug(s"Closing connection") *> 568 | endCurrentRequest *> 569 | stopIdleTimeout *> 570 | closeResponse *> 571 | withWriteLock(channel => ZIO.succeed(channel.close())) 572 | 573 | case false => ZIO.unit 574 | } 575 | 576 | val awaitShutdown: IO[Throwable, Unit] = shutdown.await 577 | 578 | val resetIdleTimeout: URIO[Clock, Unit] = 579 | config.connectionIdleTimeout match { 580 | case Duration.Infinity => ZIO.unit 581 | case duration => 582 | val timeoutClose = ZIO.when(channel.isOpen) { 583 | (ZIO.logDebug( 584 | s"Closing connection $this due to idle timeout (${config.connectionIdleTimeout.render})" 585 | ) *> 586 | close()).delay(duration) 587 | } 588 | 589 | locks.timeoutLock.withPermit { 590 | for { 591 | nextFiber <- timeoutClose.forkDaemon 592 | prevFiber <- idleTimeoutFiber.getAndSet(nextFiber) 593 | _ <- prevFiber.interruptFork 594 | } yield () 595 | } 596 | } 597 | 598 | def stopIdleTimeout: UIO[Unit] = 599 | idleTimeoutFiber.get.flatMap(_.interrupt).unit 600 | 601 | } 602 | 603 | private object Connection { 604 | case class Locks( 605 | readLock: Semaphore, 606 | writeLock: Semaphore, 607 | requestLock: Semaphore, 608 | timeoutLock: Semaphore 609 | ) 610 | object Locks { 611 | def make: UIO[Locks] = ( 612 | Semaphore.make(1) <*> 613 | Semaphore.make(1) <*> 614 | Semaphore.make(1) <*> 615 | Semaphore.make(1) 616 | ).map(t => Locks.apply(t._1, t._2, t._3, t._4)) 617 | } 618 | 619 | def apply( 620 | channel: SocketChannel, 621 | requestHandler: Request => IO[HTTPError, Response], 622 | errorHandler: HTTPError => UIO[Response], 623 | config: Config 624 | ): ZManaged[Clock, Nothing, Connection] = { 625 | for { 626 | curReq <- Ref.make[Either[(Int, List[String]), ContinuingRequest]]( 627 | Left(0 -> Nil) 628 | ) 629 | curRep <- Ref.make[Option[Response]](None) 630 | locks <- Locks.make 631 | shutdown <- Promise.make[Throwable, Unit] 632 | idleTimeout <- ZIO.unit 633 | .map(u => Some(u)) 634 | .fork 635 | .flatMap(f => Ref.make[Fiber.Runtime[Nothing, Option[Unit]]](f)) 636 | connection = new Connection( 637 | ByteBuffer.allocate(config.inputBufferSize), 638 | curReq, 639 | curRep, 640 | requestHandler, 641 | errorHandler, 642 | config, 643 | channel, 644 | locks, 645 | shutdown, 646 | idleTimeout 647 | ) 648 | } yield connection 649 | }.toManagedWith(_.close()).tapZIO { conn => 650 | config.connectionIdleTimeout match { 651 | case Duration.Infinity => ZIO.unit 652 | case _ => conn.resetIdleTimeout 653 | } 654 | } 655 | 656 | private val mismatchCRLFError: IO[BadRequest, Nothing] = 657 | ZIO.fail(BadRequest("Header contains \\r without \\n")) 658 | } 659 | 660 | private class ChannelSelector( 661 | selector: Selector, 662 | serverSocket: ServerSocketChannel, 663 | ConnectKey: SelectionKey, 664 | requestHandler: Request => IO[HTTPError, Response], 665 | errorHandler: HTTPError => UIO[Response], 666 | config: Config 667 | ) { 668 | 669 | private def register( 670 | connection: Connection 671 | ): ZManaged[Any, Throwable, SelectionKey] = 672 | ZIO 673 | .attempt( 674 | connection.channel 675 | .register(selector, SelectionKey.OP_READ, connection) 676 | ) 677 | .toManagedWith(key => ZIO.succeed(key.cancel())) 678 | 679 | private def selectedKeys = ZIO.attempt { 680 | selector.synchronized { 681 | val k = selector.selectedKeys() 682 | val ks = k.toArray(Array.empty[SelectionKey]) 683 | ks.foreach(k.remove) 684 | ks 685 | } 686 | } 687 | 688 | def select: URIO[Clock, Unit] = 689 | ZIO 690 | .attemptBlockingCancelable(selector.select(500))( 691 | ZIO.succeed(selector.wakeup()).unit 692 | ) 693 | .flatMap { 694 | case 0 => 695 | ZIO.unit 696 | case _ => 697 | selectedKeys.flatMap { keys => 698 | ZIO.foreachParDiscard(keys) { 699 | case ConnectKey => 700 | ZIO 701 | .attempt(Option(serverSocket.accept())) 702 | .tapError(err => 703 | ZIO.logError( 704 | s"Error accepting connection; server socket is closed [${err.getMessage()}]" 705 | ) *> 706 | close() 707 | ) 708 | .someOrFail(()) 709 | .flatMap { conn => 710 | conn.configureBlocking(false) 711 | Connection(conn, requestHandler, errorHandler, config) 712 | .tap(register(_).orDie) 713 | .use(_.awaitShutdown) 714 | .forkDaemon 715 | .unit 716 | } 717 | .forever 718 | .ignore 719 | case key => 720 | ZIO 721 | .attempt(key.attachment().asInstanceOf[Server.Connection]) 722 | .flatMap { conn => 723 | conn.doRead.catchAll { err => 724 | ZIO.logError( 725 | s"Error reading from connection ${err.getMessage()}" 726 | ) <* conn.close().forkDaemon 727 | } 728 | } 729 | } 730 | } 731 | } 732 | .catchAll { err => 733 | ZIO.logDebug( 734 | s"Error selecting channels: ${err}\n" + err.getStackTrace 735 | .mkString("\n\tat ") 736 | ) 737 | } 738 | .onInterrupt { 739 | ZIO.logDebug("Selector interrupted") 740 | } 741 | 742 | def close(): UIO[Unit] = 743 | ZIO.logDebug("Stopping selector") *> 744 | ZIO 745 | .foreach(selector.keys().toIterable)(k => ZIO.attempt(k.cancel())) 746 | .orDie *> 747 | ZIO.attempt(selector.close()).orDie *> 748 | ZIO.attempt(serverSocket.close()).orDie 749 | 750 | def run: RIO[Clock, Nothing] = 751 | (select *> ZIO.yieldNow).forever.onInterrupt { 752 | ZIO.logDebug("Selector loop interrupted") 753 | } 754 | } 755 | 756 | private object ChannelSelector { 757 | def apply( 758 | serverChannel: ServerSocketChannel, 759 | requestHandler: Request => IO[HTTPError, Response], 760 | errorHandler: HTTPError => UIO[Response], 761 | config: Config 762 | ): ZManaged[Any, Throwable, ChannelSelector] = 763 | ZIO 764 | .attempt(Selector.open()) 765 | .toManagedWith(s => ZIO.succeed(s.close())) 766 | .flatMap { selector => 767 | serverChannel.configureBlocking(false) 768 | val connectKey = 769 | serverChannel.register(selector, SelectionKey.OP_ACCEPT) 770 | ZIO 771 | .succeed( 772 | new ChannelSelector( 773 | selector, 774 | serverChannel, 775 | connectKey, 776 | requestHandler, 777 | errorHandler, 778 | config 779 | ) 780 | ) 781 | .toManagedWith(_.close()) 782 | } 783 | } 784 | 785 | private[uzhttp] val unhandled 786 | : PartialFunction[Request, ZIO[Any, HTTPError, Nothing]] = { case req => 787 | ZIO.fail(NotFound(req.uri.toString)) 788 | } 789 | 790 | private val defaultErrorFormatter: HTTPError => ZIO[Any, Nothing, Response] = 791 | err => 792 | ZIO.succeed( 793 | Response.plain( 794 | s"${err.statusCode} ${err.statusText}\n${err.getMessage}", 795 | status = err 796 | ) 797 | ) 798 | 799 | private def mkSocket( 800 | address: InetSocketAddress, 801 | maxPending: Int 802 | ): ZManaged[Any, Throwable, ServerSocketChannel] = ZIO 803 | .attempt { 804 | val socket = ServerSocketChannel.open() 805 | socket.configureBlocking(false) 806 | socket 807 | } 808 | .toManagedWith { channel => 809 | ZIO.attempt(channel.close()).orDie 810 | } 811 | .mapZIO { channel => 812 | ZIO.attemptBlocking(channel.bind(address, maxPending)) 813 | } 814 | 815 | } 816 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/server/package.scala: -------------------------------------------------------------------------------- 1 | package uzhttp 2 | 3 | import java.nio.channels.SelectionKey 4 | 5 | import zio._ 6 | 7 | package object server { 8 | 9 | private[server] val EmptyLine: Array[Byte] = CRLF ++ CRLF 10 | 11 | // The most copy-pasted StackOverflow snippet of all time, adapted to unprincipled Scala! 12 | private[server] def humanReadableByteCountSI(bytes: Long): String = { 13 | val s = if (bytes < 0) "-" else "" 14 | var b = if (bytes == Long.MinValue) Long.MaxValue else Math.abs(bytes) 15 | if (b < 1000L) return bytes.toString + " B" 16 | if (b < 999950L) return "%s%.1f kB".format(s, b / 1e3) 17 | b /= 1000 18 | if (b < 999950L) return "%s%.1f MB".format(s, b / 1e3) 19 | b /= 1000 20 | if (b < 999950L) return "%s%.1f GB".format(s, b / 1e3) 21 | b /= 1000 22 | 23 | "%s%.1f TB".format(s, b / 1e3) 24 | } 25 | 26 | private[server] implicit class IterateKeys( 27 | val self: java.util.Set[SelectionKey] 28 | ) extends AnyVal { 29 | def toIterable: Iterable[SelectionKey] = new Iterable[SelectionKey] { 30 | override def iterator: Iterator[SelectionKey] = { 31 | val jIterator = self.iterator() 32 | new Iterator[SelectionKey] { 33 | override def hasNext: Boolean = jIterator.hasNext 34 | override def next(): SelectionKey = jIterator.next() 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/uzhttp/websocket/Frame.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.websocket 2 | 3 | import java.nio.ByteBuffer 4 | import java.nio.charset.StandardCharsets 5 | 6 | import zio._ 7 | import zio.stream._ 8 | 9 | import Frame.frameBytes 10 | 11 | import scala.annotation.tailrec 12 | 13 | sealed trait Frame { 14 | def toBytes: ByteBuffer 15 | } 16 | 17 | object Frame { 18 | 19 | def apply(fin: Boolean, opcode: Byte, body: Array[Byte]): Frame = 20 | opcode match { 21 | case 0 => Continuation(body, fin) 22 | case 1 => Text(new String(body, StandardCharsets.UTF_8), fin) 23 | case 2 => Binary(body, fin) 24 | case 8 => Close 25 | case 9 => Ping 26 | case 10 => Pong 27 | case _ => throw new IllegalArgumentException("Invalid frame opcode") 28 | } 29 | 30 | final case class FrameHeader( 31 | fin: Boolean, 32 | opcode: Byte, 33 | mask: Boolean, 34 | lengthIndicator: Byte 35 | ) 36 | 37 | /** Parsing using mutable state. That's not good, but it is several times 38 | * faster than the immutable state version. 39 | */ 40 | private object FastParsing { 41 | val NeedHeader = 0 42 | val NeedShortLength = 1 43 | val NeedLongLength = 2 44 | val NeedMask = 3 45 | val ReceivingBytes = 4 46 | val LengthTooLong = 5 47 | 48 | // this is mutable in order to avoid a lot of allocation during parsing 49 | final class State( 50 | var parsingState: Int = NeedHeader, 51 | var header: FrameHeader = null, 52 | var length: Int = -1, 53 | var maskKey: Int = 0, 54 | var remainder: Chunk[Byte] = Chunk.empty, 55 | var parsedFrames: Chunk[Frame] = Chunk.empty 56 | ) { 57 | val bufArray: Array[Byte] = new Array[Byte](10) 58 | val buf: ByteBuffer = ByteBuffer.wrap(bufArray) 59 | def reset(): Unit = { 60 | this.parsingState = NeedHeader 61 | this.header = null 62 | this.length = -1 63 | this.maskKey = 0 64 | () 65 | } 66 | 67 | def emit(): Chunk[Frame] = { 68 | val result = this.parsedFrames 69 | this.parsedFrames = Chunk.empty 70 | result 71 | } 72 | } 73 | 74 | @tailrec def updateState(state: State): Unit = { 75 | val bytes = state.remainder 76 | state.parsingState match { 77 | case NeedHeader if bytes.size >= 2 => 78 | val b0 = bytes.head 79 | val b1 = bytes(1) 80 | val lengthIndicator = (b1 & 127).toByte 81 | val mask = b1 < 0 82 | state.header = 83 | FrameHeader(b0 < 0, (b0 & 0xf).toByte, mask, lengthIndicator) 84 | state.parsingState = lengthIndicator match { 85 | case 127 => NeedLongLength 86 | case 126 => NeedShortLength 87 | case n if mask => 88 | state.length = n 89 | state.remainder = state.remainder.drop(2) 90 | NeedMask 91 | case n => 92 | state.length = n 93 | ReceivingBytes 94 | } 95 | updateState(state) 96 | case NeedShortLength if bytes.size >= 4 => 97 | state.bufArray(0) = bytes(2) 98 | state.bufArray(1) = bytes(3) 99 | state.length = java.lang.Short.toUnsignedInt(state.buf.getShort(0)) 100 | state.remainder = state.remainder.drop(4) 101 | state.parsingState = 102 | if (state.header.mask) NeedMask else ReceivingBytes 103 | updateState(state) 104 | case NeedLongLength if bytes.size >= 10 => 105 | bytes.copyToArray(state.bufArray, 0, 10) 106 | val length = state.buf.getLong(2) 107 | if (length > Int.MaxValue) { 108 | state.parsingState = LengthTooLong 109 | } else { 110 | state.length = length.toInt 111 | state.remainder = state.remainder.drop(10) 112 | state.parsingState = 113 | if (state.header.mask) NeedMask else ReceivingBytes 114 | updateState(state) 115 | } 116 | case NeedMask if bytes.size >= 4 => 117 | bytes.copyToArray(state.bufArray, 0, 4) 118 | state.maskKey = state.buf.getInt(0) 119 | state.remainder = state.remainder.drop(4) 120 | state.parsingState = ReceivingBytes 121 | updateState(state) 122 | case ReceivingBytes if bytes.size >= state.length => 123 | val body = bytes.take(state.length).toArray 124 | if (state.header.mask && state.maskKey != 0) { 125 | applyMask(body, state.maskKey) 126 | } 127 | state.remainder = state.remainder.drop(state.length) 128 | state.parsedFrames = state.parsedFrames :+ Frame( 129 | state.header.fin, 130 | state.header.opcode, 131 | body 132 | ) 133 | state.reset() 134 | updateState(state) 135 | case _ => 136 | } 137 | } 138 | 139 | def channel: ZChannel[Any, Throwable, Chunk[Byte], Any, FrameError, Chunk[ 140 | Frame 141 | ], Any] = { 142 | ZChannel.unwrapManaged[Any, Throwable, Chunk[ 143 | Byte 144 | ], Any, FrameError, Chunk[Frame], Any]( 145 | ZManaged.succeed(new State()).map { state => 146 | ZChannel 147 | .readWithCause[Any, Throwable, Chunk[Byte], Any, FrameError, Chunk[ 148 | Frame 149 | ], Unit]( 150 | in = bytes => { 151 | state.remainder = state.remainder ++ bytes 152 | updateState(state) 153 | if (state.parsingState == LengthTooLong) 154 | ZChannel.fail(FrameTooLong(state.buf.getLong(2))) 155 | else 156 | ZChannel.write(state.emit()) 157 | }, 158 | halt = err => { 159 | ZChannel.fail(StreamHalted(err)) 160 | }, 161 | done = _ => { 162 | updateState(state) 163 | if (state.parsingState == LengthTooLong) 164 | ZChannel.fail(FrameTooLong(state.buf.getLong(2))) 165 | else 166 | ZChannel.succeed(()) 167 | } 168 | ) 169 | } 170 | ) 171 | } 172 | } 173 | 174 | // mask the given bytes with the given key, mutating the input array 175 | private def applyMask(bytes: Array[Byte], maskKey: Int): Unit = { 176 | val maskBytes = Array[Byte]( 177 | (maskKey >> 24).toByte, 178 | ((maskKey >> 16) & 0xff).toByte, 179 | ((maskKey >> 8) & 0xff).toByte, 180 | (maskKey & 0xff).toByte 181 | ) 182 | var i = 0 183 | while (i < bytes.length - 4) { 184 | bytes(i) = (bytes(i) ^ maskBytes(0)).toByte 185 | bytes(i + 1) = (bytes(i + 1) ^ maskBytes(1)).toByte 186 | bytes(i + 2) = (bytes(i + 2) ^ maskBytes(2)).toByte 187 | bytes(i + 3) = (bytes(i + 3) ^ maskBytes(3)).toByte 188 | i += 4 189 | } 190 | 191 | while (i < bytes.length) { 192 | bytes(i) = (bytes(i) ^ maskBytes(i % 4)).toByte 193 | i += 1 194 | } 195 | } 196 | 197 | // Parses websocket frames from the bytestream using the parseFrame transducer 198 | private[uzhttp] def parse( 199 | stream: ZStream[Any, Throwable, Byte] 200 | ): ZStream[Any, Throwable, Frame] = 201 | stream 202 | .pipeThroughChannel[Any, FrameError, Frame](FastParsing.channel) 203 | 204 | sealed abstract class FrameError(msg: String) extends Throwable(msg) 205 | // We don't handle frames that are over 2GB, because Java can't handle their length. 206 | final case class FrameTooLong(length: Long) 207 | extends FrameError(s"Frame length $length exceeds Int.MaxValue") 208 | 209 | final case class StreamHalted(cause: Cause[Throwable]) 210 | extends FrameError(cause.prettyPrint) 211 | 212 | private[websocket] def frameSize(payloadLength: Int) = 213 | if (payloadLength < 126) 214 | 2 + payloadLength 215 | else if (payloadLength <= 0xffff) 216 | 4 + payloadLength 217 | else 218 | 10 + payloadLength 219 | 220 | private[websocket] def writeLength(len: Int, buf: ByteBuffer) = 221 | if (len < 126) { 222 | buf.put(len.toByte) 223 | } else if (len <= 0xffff) { 224 | buf.put(126.toByte) 225 | buf.putShort(len.toShort) 226 | } else { 227 | buf.put(127.toByte) 228 | buf.putLong(len.toLong) 229 | } 230 | 231 | private[websocket] def frameBytes( 232 | op: Byte, 233 | payload: Array[Byte], 234 | fin: Boolean = true 235 | ) = { 236 | val buf = ByteBuffer.allocate(frameSize(payload.length)) 237 | buf.put(if (fin) (op | 128).toByte else op) 238 | writeLength(payload.length, buf) 239 | buf.put(payload) 240 | buf.rewind() 241 | buf 242 | } 243 | } 244 | 245 | final case class Continuation(data: Array[Byte], isLast: Boolean = true) 246 | extends Frame { 247 | override def toBytes: ByteBuffer = frameBytes(0, data, isLast) 248 | } 249 | 250 | final case class Text(data: String, isLast: Boolean = true) extends Frame { 251 | override def toBytes: ByteBuffer = 252 | frameBytes(1, data.getBytes(StandardCharsets.UTF_8), isLast) 253 | } 254 | 255 | final case class Binary(data: Array[Byte], isLast: Boolean = true) 256 | extends Frame { 257 | override def toBytes: ByteBuffer = frameBytes(2, data, isLast) 258 | } 259 | 260 | case object Close extends Frame { 261 | override val toBytes: ByteBuffer = frameBytes(8, Array.empty) 262 | } 263 | 264 | case object Ping extends Frame { 265 | override val toBytes: ByteBuffer = frameBytes(9, Array.empty) 266 | } 267 | 268 | case object Pong extends Frame { 269 | override val toBytes: ByteBuffer = frameBytes(10, Array.empty) 270 | } 271 | -------------------------------------------------------------------------------- /src/test/resources/path-test.txt: -------------------------------------------------------------------------------- 1 | This is a text file that will be read by Response.fromPath. -------------------------------------------------------------------------------- /src/test/resources/site/images/355px-Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polynote/uzhttp/9f3b8883231df5422dbc09ba3fd9b4c8d487d447/src/test/resources/site/images/355px-Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg -------------------------------------------------------------------------------- /src/test/resources/site/images/607px-Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polynote/uzhttp/9f3b8883231df5422dbc09ba3fd9b4c8d487d447/src/test/resources/site/images/607px-Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg -------------------------------------------------------------------------------- /src/test/resources/site/images/Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polynote/uzhttp/9f3b8883231df5422dbc09ba3fd9b4c8d487d447/src/test/resources/site/images/Willow_in_the_Red_Zone,_Christchurch,_New_Zealand.jpg -------------------------------------------------------------------------------- /src/test/resources/site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | It's a test! 5 | 6 | 7 | 8 |

It's a test!

9 |

10 | What we're doing here is testing out the webserver with an HTML site that contains some images, stylesheets, etc. 11 | The point is to see what happens when this is loaded in a browser and how easily it can be broken. 12 |

13 |

14 | Here's a bunch of paragraphs with some images embedded. Some of them are pretty sizable. They all came from 15 | WikiMedia Commons – the link goes to the original and the title includes the attribution. 16 |

17 | 18 |

19 | 20 | Willow - Preview size 21 | 22 | 23 | 24 | Willow - Larger size 25 | 26 | 27 | 28 | Willow - Huge size 29 | 30 |

31 | 32 | -------------------------------------------------------------------------------- /src/test/resources/site/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", "Segoe UI", Verdana, sans-serif; 3 | font-size: 14pt; 4 | background: white; 5 | color: #444; 6 | line-height: 1.5; 7 | } 8 | 9 | h1 { 10 | border-bottom: 1px solid #666; 11 | font-size: 200%; 12 | padding-bottom: 7pt; 13 | margin-bottom: 7pt; 14 | } -------------------------------------------------------------------------------- /src/test/scala/uzhttp/server/MockConnectionWriter.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.server 2 | 3 | import java.nio.ByteBuffer 4 | import java.nio.channels.WritableByteChannel 5 | 6 | import zio.{Chunk, RIO, Ref, Semaphore, ZIO} 7 | import TestRuntime.runtime.unsafeRun 8 | 9 | case class MockConnectionWriter(writtenBytes: Ref[Chunk[Byte]] = unsafeRun(Ref.make(Chunk.empty))) extends Server.ConnectionWriter { 10 | private val writeLock: Semaphore = unsafeRun(Semaphore.make(1L)) 11 | private val channel = new WritableByteChannel { 12 | override def write(src: ByteBuffer): Int = if (src.hasRemaining) { 13 | val chunk = Chunk.fromByteBuffer(src) 14 | src.position(src.position() + chunk.size) 15 | unsafeRun(writtenBytes.update(_ ++ chunk).as(chunk.size)) 16 | } else 0 17 | override def isOpen: Boolean = true 18 | override def close(): Unit = () 19 | } 20 | 21 | override def withWriteLock[R, E](fn: WritableByteChannel => ZIO[R, E, Unit]): ZIO[R, E, Unit] = writeLock.withPermit(fn(channel)) 22 | } -------------------------------------------------------------------------------- /src/test/scala/uzhttp/server/MockSocketChannel.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.server 2 | 3 | import java.nio.ByteBuffer 4 | import java.nio.channels.spi.SelectorProvider 5 | import java.nio.channels.{ReadableByteChannel, SelectableChannel, SelectionKey, Selector, WritableByteChannel} 6 | 7 | import scala.util.Random 8 | 9 | class MockSocketChannel( 10 | input: ByteBuffer, 11 | private var outputs: List[Array[Byte]], 12 | maxInputBytes: Int => Int = _ => Random.nextInt(128), 13 | maxOutputBytes: Int => Int = _ => Random.nextInt(128) 14 | ) extends SelectableChannel with ReadableByteChannel with WritableByteChannel { 15 | override def read(dst: ByteBuffer): Int = synchronized { 16 | val start = dst.position() 17 | val amountTransferred = math.min(input.remaining(), maxInputBytes(input.position())) 18 | if (amountTransferred == 0) 19 | return 0 20 | 21 | val dup = input.duplicate() 22 | dup.limit(dup.position() + amountTransferred) 23 | dst.put(dup) 24 | dst.position() - start 25 | } 26 | 27 | override def write(src: ByteBuffer): Int = synchronized { 28 | val amountTransferred = math.min(src.remaining(), maxOutputBytes(src.position())) 29 | val data = new Array[Byte](amountTransferred) 30 | src.get(data) 31 | amountTransferred 32 | } 33 | 34 | override def provider(): SelectorProvider = ??? 35 | override def validOps(): Int = ??? 36 | override def isRegistered: Boolean = ??? 37 | override def keyFor(sel: Selector): SelectionKey = ??? 38 | override def register(sel: Selector, ops: Int, att: Any): SelectionKey = ??? 39 | override def configureBlocking(block: Boolean): SelectableChannel = ??? 40 | override def isBlocking: Boolean = ??? 41 | override def blockingLock(): AnyRef = ??? 42 | override def implCloseChannel(): Unit = ??? 43 | } 44 | -------------------------------------------------------------------------------- /src/test/scala/uzhttp/server/ResponseSpec.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.server 2 | 3 | import java.io.{FileOutputStream, InputStream} 4 | import java.net.{URI, URLClassLoader} 5 | import java.nio.ByteBuffer 6 | import java.nio.channels.FileChannel 7 | import java.nio.charset.StandardCharsets 8 | import java.nio.file.{Files, Paths} 9 | import java.time.{Instant, ZoneOffset} 10 | import java.time.format.DateTimeFormatter 11 | import java.time.temporal.{TemporalAccessor, TemporalQuery} 12 | import java.util.concurrent.atomic.AtomicReference 13 | import java.util.jar.JarOutputStream 14 | import java.util.zip.ZipEntry 15 | 16 | import org.scalatest.freespec.AnyFreeSpec 17 | import org.scalatest.matchers.must.Matchers 18 | import zio.{Chunk, Ref, Task, ZIO, stream} 19 | import ZIO.effect 20 | import org.scalatest.Assertion 21 | import uzhttp.{Request, Response, Version} 22 | import uzhttp.header.Headers, Headers.{ContentLength, ContentType, LastModified} 23 | 24 | class ResponseSpec extends AnyFreeSpec with Matchers { 25 | import TestRuntime.runtime.unsafeRun 26 | 27 | private def splitRequest(req: Chunk[Byte]) = { 28 | val arr = req.toArray 29 | val splitPoint = arr.indexOfSlice(Seq('\r', '\n', '\r', '\n')) 30 | val headerLines = new String( 31 | arr, 32 | 0, 33 | splitPoint, 34 | StandardCharsets.US_ASCII 35 | ).linesWithSeparators.map(_.stripLineEnd).toList 36 | ( 37 | headerLines.head, 38 | Headers.fromLines(headerLines.tail), 39 | req.drop(splitPoint + 4) 40 | ) 41 | } 42 | 43 | private def verify( 44 | rep: Response 45 | )(fn: (String, Headers, Chunk[Byte]) => Assertion): Assertion = { 46 | val conn = MockConnectionWriter() 47 | unsafeRun(rep.writeTo(conn)) 48 | val (status, headers, body) = splitRequest(unsafeRun(conn.writtenBytes.get)) 49 | fn(status, headers, body) 50 | } 51 | 52 | private val toInstant: TemporalQuery[Instant] = new TemporalQuery[Instant] { 53 | override def queryFrom(temporal: TemporalAccessor): Instant = 54 | Instant.from(temporal) 55 | } 56 | 57 | private def timeStr(inst: Instant): String = 58 | DateTimeFormatter.RFC_1123_DATE_TIME.format(inst.atZone(ZoneOffset.UTC)) 59 | private def parseTime(str: String): Instant = 60 | DateTimeFormatter.RFC_1123_DATE_TIME.parse(str, toInstant) 61 | private def trunc(inst: Instant): Instant = parseTime(timeStr(inst)) 62 | private val currentTimeStr: String = timeStr(Instant.now()) 63 | 64 | "Constant response" - { 65 | "writes to connection" in { 66 | val bytes = Array.tabulate(256)(_.toByte) 67 | verify(Response.const(bytes, headers = (List("Flerg" -> "Blerg")))) { 68 | (status, headers, body) => 69 | status mustEqual "HTTP/1.1 200 OK" 70 | headers("Flerg") mustEqual "Blerg" 71 | headers(ContentLength) mustEqual "256" 72 | body.toArray must contain theSameElementsInOrderAs bytes 73 | } 74 | } 75 | } 76 | 77 | "Path response" - { 78 | val path = 79 | Paths.get(getClass.getClassLoader.getResource("path-test.txt").toURI) 80 | val modified = Files.getLastModifiedTime(path).toInstant 81 | val expected = Files.readAllBytes(path) 82 | 83 | val req = Request.NoBody( 84 | Request.Method.GET, 85 | new URI("/path-test.txt"), 86 | Version.Http11, 87 | Headers.empty 88 | ) 89 | 90 | "writes to connection" in { 91 | verify( 92 | unsafeRun( 93 | Response.fromPath( 94 | path, 95 | req, 96 | contentType = "text/plain", 97 | headers = List("Flerg" -> "Blerg") 98 | ) 99 | ) 100 | ) { (status, headers, body) => 101 | status mustEqual "HTTP/1.1 200 OK" 102 | headers("Flerg") mustEqual "Blerg" 103 | headers(ContentLength) mustEqual expected.length.toString 104 | headers(ContentType) mustEqual "text/plain" 105 | parseTime(headers(LastModified)) mustEqual trunc(modified) 106 | body.toArray must contain theSameElementsInOrderAs expected 107 | } 108 | } 109 | 110 | "respects if-modified-since" - { 111 | "when it is after the modification date" in { 112 | verify( 113 | unsafeRun( 114 | Response.fromPath( 115 | path, 116 | req.addHeader("If-Modified-Since", currentTimeStr) 117 | ) 118 | ) 119 | ) { (status, headers, body) => 120 | status mustEqual "HTTP/1.1 304 Not Modified" 121 | // 304 MAY send content-length, but if it does it must equal the length of the omitted body (not zero) 122 | assert( 123 | !headers.get(ContentLength).exists(_ != expected.length.toString) 124 | ) 125 | assert(body.isEmpty) 126 | } 127 | } 128 | 129 | "when it is before the modification date" in { 130 | verify( 131 | unsafeRun( 132 | Response.fromPath( 133 | path, 134 | req.addHeader( 135 | "If-Modified-Since", 136 | timeStr(modified.minusSeconds(5)) 137 | ) 138 | ) 139 | ) 140 | ) { (status, headers, body) => 141 | status mustEqual "HTTP/1.1 200 OK" 142 | headers(ContentLength) mustEqual expected.length.toString 143 | body.toArray must contain theSameElementsInOrderAs expected 144 | } 145 | } 146 | } 147 | } 148 | 149 | "Resource response" - { 150 | val resource = "path-test.txt" 151 | val path = Paths.get(getClass.getClassLoader.getResource(resource).toURI) 152 | val modified = Files.getLastModifiedTime(path).toInstant 153 | val expected = Files.readAllBytes(path) 154 | val req = Request.NoBody( 155 | Request.Method.GET, 156 | new URI("/path-test.txt"), 157 | Version.Http11, 158 | Headers.empty 159 | ) 160 | 161 | "when resource is un-jarred" - { 162 | "writes to connection" in { 163 | verify( 164 | unsafeRun( 165 | Response.fromResource( 166 | resource, 167 | req, 168 | contentType = "text/plain", 169 | headers = List("Flerg" -> "Blerg") 170 | ) 171 | ) 172 | ) { (status, headers, body) => 173 | status mustEqual "HTTP/1.1 200 OK" 174 | headers("Flerg") mustEqual "Blerg" 175 | headers(ContentLength) mustEqual expected.length.toString 176 | headers(ContentType) mustEqual "text/plain" 177 | parseTime(headers(LastModified)) mustEqual trunc(modified) 178 | body.toArray must contain theSameElementsInOrderAs expected 179 | } 180 | } 181 | 182 | "respects if-modified-since" - { 183 | "when it is after the modification date" in { 184 | verify( 185 | unsafeRun( 186 | Response.fromResource( 187 | resource, 188 | req.addHeader("If-Modified-Since", currentTimeStr) 189 | ) 190 | ) 191 | ) { (status, headers, body) => 192 | status mustEqual "HTTP/1.1 304 Not Modified" 193 | // 304 MAY send content-length, but if it does it must equal the length of the omitted body (not zero) 194 | assert( 195 | !headers.get(ContentLength).exists(_ != expected.length.toString) 196 | ) 197 | assert(body.isEmpty) 198 | } 199 | } 200 | 201 | "when it is before the modification date" in { 202 | verify( 203 | unsafeRun( 204 | Response.fromResource( 205 | resource, 206 | req.addHeader( 207 | "If-Modified-Since", 208 | timeStr(modified.minusSeconds(5)) 209 | ) 210 | ) 211 | ) 212 | ) { (status, headers, body) => 213 | status mustEqual "HTTP/1.1 200 OK" 214 | headers(ContentLength) mustEqual expected.length.toString 215 | body.toArray must contain theSameElementsInOrderAs expected 216 | } 217 | } 218 | } 219 | } 220 | 221 | "when resource is inside a JAR" - { 222 | val jarFile = Files.createTempFile("resources", ".jar") 223 | val stream = new JarOutputStream(new FileOutputStream(jarFile.toFile)) 224 | try { 225 | stream.putNextEntry(new ZipEntry("path-test.txt")) 226 | stream.write(expected) 227 | stream.finish() 228 | } finally { 229 | stream.close() 230 | } 231 | 232 | jarFile.toFile.deleteOnExit() 233 | 234 | val jarModified = Files.getLastModifiedTime(jarFile).toInstant 235 | val cl = new URLClassLoader(Array(jarFile.toUri.toURL), null) 236 | 237 | "writes to connection" in { 238 | verify( 239 | unsafeRun( 240 | Response.fromResource( 241 | resource, 242 | req, 243 | classLoader = cl, 244 | contentType = "text/plain", 245 | headers = List("Flerg" -> "Blerg") 246 | ) 247 | ) 248 | ) { (status, headers, body) => 249 | status mustEqual "HTTP/1.1 200 OK" 250 | headers("Flerg") mustEqual "Blerg" 251 | headers(ContentLength) mustEqual expected.length.toString 252 | headers(ContentType) mustEqual "text/plain" 253 | parseTime(headers(LastModified)) mustEqual trunc(jarModified) 254 | body.toArray must contain theSameElementsInOrderAs expected 255 | } 256 | } 257 | 258 | "respects if-modified-since" - { 259 | "when it is after the modification date" in { 260 | verify( 261 | unsafeRun( 262 | Response.fromResource( 263 | resource, 264 | req.addHeader( 265 | "If-Modified-Since", 266 | timeStr(jarModified.plusSeconds(5)) 267 | ), 268 | classLoader = cl 269 | ) 270 | ) 271 | ) { (status, headers, body) => 272 | status mustEqual "HTTP/1.1 304 Not Modified" 273 | // 304 MAY send content-length, but if it does it must equal the length of the omitted body (not zero) 274 | assert( 275 | !headers.get(ContentLength).exists(_ != expected.length.toString) 276 | ) 277 | assert(body.isEmpty) 278 | } 279 | } 280 | 281 | "when it is before the modification date" in { 282 | verify( 283 | unsafeRun( 284 | Response.fromResource( 285 | resource, 286 | req.addHeader( 287 | "If-Modified-Since", 288 | timeStr(jarModified.minusSeconds(5)) 289 | ), 290 | classLoader = cl 291 | ) 292 | ) 293 | ) { (status, headers, body) => 294 | status mustEqual "HTTP/1.1 200 OK" 295 | headers(ContentLength) mustEqual expected.length.toString 296 | body.toArray must contain theSameElementsInOrderAs expected 297 | } 298 | } 299 | } 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/test/scala/uzhttp/server/ServerSpec.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.server 2 | 3 | import java.net.InetSocketAddress 4 | import java.security.MessageDigest 5 | 6 | import zio._ 7 | import zio.stream._ 8 | import org.asynchttpclient.DefaultAsyncHttpClientConfig 9 | import org.scalatest.BeforeAndAfterAll 10 | import org.scalatest.freespec.AnyFreeSpec 11 | import org.scalatest.matchers.must.Matchers 12 | import uzhttp.{HTTPError, Request, Response, websocket} 13 | import websocket.Binary 14 | import sttp.client3.SttpBackend 15 | import sttp.client3._ 16 | import sttp.client3.internal.ws.WebSocketEvent 17 | import sttp.capabilities.WebSockets 18 | import sttp.monad.syntax.MonadErrorOps 19 | import sttp.ws.WebSocketFrame 20 | import sttp.ws.WebSocket 21 | import org.scalatest.compatible.Assertion 22 | import sttp.client3.asynchttpclient.AsyncHttpClientBackend 23 | import sttp.client3.asynchttpclient.future.AsyncHttpClientFutureBackend 24 | import scala.concurrent.Future 25 | import scala.concurrent.ExecutionContext 26 | 27 | class ServerSpec extends AnyFreeSpec with Matchers with BeforeAndAfterAll { 28 | import TestRuntime.runtime.unsafeRun 29 | 30 | private val runningServerRef: Ref[Option[Server]] = unsafeRun(Ref.make(None)) 31 | private implicit val ec: ExecutionContext = 32 | scala.concurrent.ExecutionContext.global 33 | 34 | private val backend = AsyncHttpClientFutureBackend.usingConfig( 35 | new DefaultAsyncHttpClientConfig.Builder() 36 | .setWebSocketMaxFrameSize(Int.MaxValue) 37 | .setWebSocketMaxBufferSize(8192) 38 | .build() 39 | ) 40 | 41 | private val serverTask = unsafeRun { 42 | val managed = Server 43 | .builder(new InetSocketAddress("127.0.0.1", 0)) 44 | .handleAll { 45 | case req @ Request.WebsocketRequest(_, _, _, _, frames) => 46 | Response.websocket( 47 | req, 48 | frames.flatMap { frame => 49 | Stream(frame, frame) 50 | } 51 | ) 52 | 53 | case req => 54 | if (req.headers.get("Content-Length").map(_.toInt).exists(_ > 0)) { 55 | req.body match { 56 | case Some(body) => 57 | body.runCollect.map { bytes => 58 | Response.const( 59 | Chunk.fromIterable(bytes).toArray, 60 | headers = req.headers.toList.map { case (name, value) => 61 | s"x-echoed-$name" -> value 62 | } ++ List( 63 | "x-echoed-request-uri" -> req.uri.getPath, 64 | "x-echoed-method" -> req.method.name, 65 | "Content-Type" -> "application/octet-stream" 66 | ) 67 | ) 68 | } 69 | case None => 70 | ZIO.fail(HTTPError.BadRequest("Request has no body!")) 71 | } 72 | } else { 73 | ZIO.succeed { 74 | Response.plain( 75 | s"${req.method.name} ${req.uri} HTTP/${req.version.string}\r\n${req.headers.toList 76 | .map { case (k, v) => s"$k: $v" } 77 | .mkString("\r\n")}", 78 | headers = req.headers.toList.map { case (name, value) => 79 | s"x-echoed-$name" -> value 80 | } ++ List( 81 | "x-echoed-request-uri" -> req.uri.getPath, 82 | "x-echoed-method" -> req.method.name 83 | ) 84 | ) 85 | } 86 | } 87 | 88 | } 89 | .errorResponse { err => 90 | ZIO.succeed( 91 | Response 92 | .plain(s"HTTP Error: ${err.statusCode} ${err.statusText}", err) 93 | ) 94 | } 95 | .serve 96 | 97 | managed 98 | .tapZIO(server => runningServerRef.set(Some(server))) 99 | .useForever 100 | .forkDaemon 101 | } 102 | 103 | private val runningServer: Server = unsafeRun( 104 | runningServerRef.get 105 | .repeatUntil(_.nonEmpty) 106 | .flatMap(server => server.get.awaitUp.as(server.get)) 107 | ) 108 | 109 | private val port = unsafeRun(runningServer.localAddress) 110 | .asInstanceOf[InetSocketAddress] 111 | .getPort 112 | 113 | "Server" - { 114 | 115 | "Handles basic requests" - { 116 | "GET" in { 117 | basicRequest 118 | .get(uri"http://localhost:$port/basicReq") 119 | .send(backend) 120 | .map { rep => 121 | rep.code.code mustEqual 200 122 | val headers = 123 | rep.headers.map(h => h.name.toLowerCase -> h.value).toMap 124 | headers("x-echoed-request-uri") mustEqual "/basicReq" 125 | headers("x-echoed-method") mustEqual "GET" 126 | } 127 | } 128 | 129 | "POST" - { 130 | "small body" in { 131 | val body = Array.tabulate(256)(_.toByte) 132 | basicRequest 133 | .post(uri"http://localhost:$port/basicReq") 134 | .body(body) 135 | .response(asByteArrayAlways) 136 | .send(backend) 137 | .map { rep => 138 | rep.code.code mustEqual 200 139 | rep.body must contain theSameElementsInOrderAs body 140 | } 141 | } 142 | 143 | "large body" in { 144 | val body = Array.tabulate(Short.MaxValue)(_.toByte) 145 | basicRequest 146 | .post(uri"http://localhost:$port/basicReq") 147 | .body(body) 148 | .response(asByteArrayAlways) 149 | .send(backend) 150 | .map { rep => 151 | rep.code.code mustEqual 200 152 | rep.body must contain theSameElementsInOrderAs body 153 | } 154 | } 155 | } 156 | } 157 | 158 | "Decodes URI" in { 159 | basicRequest 160 | .get(uri"http://localhost:$port/basic%20request") 161 | .send(backend) 162 | .map { rep => 163 | rep.code.code mustEqual 200 164 | val headers = 165 | rep.headers.map(h => h.name.toLowerCase -> h.value).toMap 166 | headers("x-echoed-request-uri") mustEqual "/basic request" 167 | headers("x-echoed-method") mustEqual "GET" 168 | } 169 | } 170 | 171 | "handles websocket request" in { 172 | val smallBinaryData = 173 | Array.tabulate(64)(i => i.toByte) // covers no length indicator 174 | val binaryData = Array.tabulate(256)(i => 175 | i.toByte 176 | ) // covers length indicator 126, length < 32767 177 | val bigBinaryData = 178 | new Array[Byte]( 179 | Short.MaxValue + 50 180 | ) // covers length indicator 126, length > 32767 181 | val hugeBinaryData = 182 | new Array[Byte]( 183 | Short.MaxValue + Short.MaxValue + 2 184 | ) // covers length indicator 127 185 | scala.util.Random.nextBytes(bigBinaryData) 186 | val strData = "This is a string to the websocket" 187 | 188 | def errOnClose[A](zio: Task[A]): Task[A] = 189 | zio.mapError(e => 190 | new Exception(s"Websocket closed early ${e.getMessage}") 191 | ) 192 | 193 | basicRequest 194 | .get(uri"ws://localhost:$port/websocketTest") 195 | .response( 196 | asWebSocketAlways[Task, Assertion](ws => 197 | for { 198 | _ <- ws.send(WebSocketFrame.binary(smallBinaryData)) 199 | small1 <- errOnClose(ws.receiveBinary(true)) 200 | small2 <- errOnClose(ws.receiveBinary(true)) 201 | _ <- ws.send(WebSocketFrame.binary(binaryData)) 202 | mid1 <- errOnClose(ws.receiveBinary(true)) 203 | mid2 <- errOnClose(ws.receiveBinary(true)) 204 | _ <- ws.send(WebSocketFrame.binary(bigBinaryData)) 205 | big1 <- errOnClose(ws.receiveBinary(true)) 206 | big2 <- errOnClose(ws.receiveBinary(true)) 207 | _ <- ws.send(WebSocketFrame.binary(hugeBinaryData)) 208 | huge1 <- errOnClose(ws.receiveBinary(true)) 209 | huge2 <- errOnClose(ws.receiveBinary(true)) 210 | _ <- ws.send(WebSocketFrame.text(strData)) 211 | string1 <- errOnClose(ws.receiveText(true)) 212 | string2 <- errOnClose(ws.receiveText(true)) 213 | _ <- ws.send(WebSocketFrame.close) 214 | } yield { 215 | small1 must contain theSameElementsInOrderAs smallBinaryData 216 | small2 must contain theSameElementsInOrderAs smallBinaryData 217 | mid1 must contain theSameElementsInOrderAs binaryData 218 | mid2 must contain theSameElementsInOrderAs binaryData 219 | big1 must contain theSameElementsInOrderAs bigBinaryData 220 | big2 must contain theSameElementsInOrderAs bigBinaryData 221 | huge1 must contain theSameElementsInOrderAs hugeBinaryData 222 | huge2 must contain theSameElementsInOrderAs hugeBinaryData 223 | string1 mustEqual strData 224 | string2 mustEqual strData 225 | } 226 | ) 227 | ) 228 | } 229 | } 230 | 231 | private def md5(data: Array[Byte]): Array[Byte] = 232 | MessageDigest.getInstance("MD5").digest(data) 233 | 234 | override def afterAll(): Unit = { 235 | unsafeRun(runningServer.shutdown()) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/test/scala/uzhttp/server/TestRuntime.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.server 2 | 3 | import zio._ 4 | 5 | object TestRuntime { 6 | val runtime: Runtime[ZEnv] = Runtime.default 7 | } 8 | -------------------------------------------------------------------------------- /src/test/scala/uzhttp/server/TestServer.scala: -------------------------------------------------------------------------------- 1 | package uzhttp.server 2 | 3 | import java.lang.{System => JSystem} 4 | 5 | import java.net.{InetSocketAddress, URLConnection} 6 | import java.nio.file.Paths 7 | import java.util.concurrent.TimeUnit 8 | 9 | import uzhttp.websocket.{Binary, Close, Continuation, Frame, Ping, Pong, Text} 10 | import uzhttp.{HTTPError, Request, Response} 11 | import zio._ 12 | import zio.stream._ 13 | 14 | object TestServer extends ZIOAppDefault { 15 | private lazy val resourcePath = 16 | Paths.get(getClass.getClassLoader.getResource("site").toURI) 17 | 18 | private val cacheHeaders = 19 | ("Cache-Control" -> "max-age=0, must-revalidate") :: Nil 20 | 21 | private def contentType(uri: String): String = 22 | if (uri.endsWith(".css")) 23 | "text/css" 24 | else 25 | Option(URLConnection.guessContentTypeFromName(uri)) 26 | .getOrElse("application/octet-stream") match { 27 | case textType if textType startsWith "text/" => 28 | textType + "; charset=UTF-8" 29 | case typ => typ 30 | } 31 | 32 | private def handleErrors( 33 | fn: Request => IO[Throwable, Response] 34 | ): Request => IO[HTTPError, Response] = 35 | fn andThen (_.catchAll { 36 | case err: HTTPError => ZIO.fail(err) 37 | case err => ZIO.fail(HTTPError.InternalServerError(err.getMessage)) 38 | }) 39 | 40 | private def uri(req: Request) = req.uri.toString match { 41 | case "/" | "" => "/index.html" 42 | case uri => uri 43 | } 44 | 45 | private def servePath(req: Request): IO[Throwable, Response] = 46 | Response.fromPath( 47 | resourcePath.resolve(uri(req).stripPrefix("/")), 48 | req, 49 | contentType = contentType(uri(req)), 50 | headers = cacheHeaders 51 | ) 52 | 53 | private def serveResource(req: Request): IO[Throwable, Response] = 54 | Response.fromResource( 55 | s"site${uri(req)}", 56 | req, 57 | contentType = contentType(uri(req)), 58 | headers = cacheHeaders 59 | ) 60 | 61 | private def requestHandler( 62 | args: List[String] 63 | ): Request => IO[HTTPError, Response] = 64 | if (args contains ("--from-resources")) 65 | handleErrors(serveResource) 66 | else 67 | handleErrors(servePath) 68 | 69 | private def responseCache(args: List[String]) = 70 | Response.permanentCache.handleAll(requestHandler(args)).build 71 | 72 | private def log(str: String): UIO[Unit] = 73 | ZIO.succeed(JSystem.err.println(s"[APP] $str")) 74 | 75 | private def formatHex(bytes: Array[Byte]) = 76 | bytes.map(b => String.format("%02x", Byte.box(b))).mkString 77 | 78 | private def handleWebsocketFrame( 79 | frame: Frame 80 | ): UIO[Stream[Nothing, Take[Nothing, Frame]]] = frame match { 81 | case frame @ Binary(data, _) => 82 | log(s"Binary frame: 0x${formatHex(data)}") as Stream(Take.single(frame)) 83 | case frame @ Text(data, _) => 84 | log(s"Text frame: $data") as Stream(Take.single(frame)) 85 | case frame @ Continuation(data, _) => 86 | log(s"Continuation frame: 0x${formatHex(data)}") as Stream( 87 | Take.single(frame) 88 | ) 89 | case Ping => log("Ping!") as Stream(Take.single(Pong)) 90 | case Pong => log("Pong!") as Stream.empty 91 | case Close => log("Close") as Stream(Take.single(Close), Take.end) 92 | } 93 | 94 | override def run = 95 | ZIO.serviceWith[ZIOAppArgs] { args => 96 | responseCache(args.getArgs.toList).use { cache => 97 | Server 98 | .builder(new InetSocketAddress("127.0.0.1", 9121)) 99 | .handleSome { 100 | case req @ Request.WebsocketRequest(_, uri, _, _, inputFrames) 101 | if uri.getPath startsWith "/ws" => 102 | for { 103 | ws <- Response.websocket( 104 | req, 105 | inputFrames 106 | .mapZIO(handleWebsocketFrame) 107 | .flatMap(_.flattenTake) 108 | ) 109 | } yield ws 110 | } 111 | .handleSome(cache) 112 | .withMaxPending(Short.MaxValue) 113 | //.withConnectionIdleTimeout(Duration(5, TimeUnit.SECONDS)) 114 | .serve 115 | .use { server => 116 | server.awaitShutdown.as(ExitCode.success) 117 | } 118 | .orDie 119 | } 120 | } 121 | } 122 | --------------------------------------------------------------------------------