├── .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 | 
3 | [](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 |