├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── TUTORIAL.md ├── erlang.mk ├── include └── http_status.hrl ├── priv ├── mime.types └── skel │ ├── Makefile │ ├── erlang.mk │ └── src │ ├── Makefile │ ├── app_id.app.src │ ├── app_id.erl │ ├── app_id_app.erl │ └── app_id_http.erl ├── psycho-mkapp └── src ├── Makefile ├── proc.erl ├── psycho.app.src ├── psycho.erl ├── psycho_auth.erl ├── psycho_datetime.erl ├── psycho_debug.erl ├── psycho_devmode.erl ├── psycho_erlydtl.erl ├── psycho_handler.erl ├── psycho_handler_sup.erl ├── psycho_log.erl ├── psycho_mime.erl ├── psycho_multipart.erl ├── psycho_opt.erl ├── psycho_reloader.erl ├── psycho_route.erl ├── psycho_server.erl ├── psycho_socket.erl ├── psycho_static.erl ├── psycho_static2.erl ├── psycho_tests.erl ├── psycho_util.erl ├── sample_echo.erl ├── sample_hello.erl ├── sample_middleware.erl ├── sample_proc.erl ├── sample_rest.erl └── sample_static.erl /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.pyc 3 | deps 4 | ebin 5 | erl_crash.dump 6 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT = psycho 2 | COMPILE_FIRST = proc 3 | 4 | ERLC_OPTS = +debug_info +warn_export_all +warn_export_vars \ 5 | +warn_shadow_vars +warn_obsolete_guard 6 | SHELL_OPTS = -s psycho_reloader 7 | 8 | include erlang.mk 9 | 10 | test: 11 | erl -pa ebin -eval 'psycho_tests:run()' -s init stop -noshell 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Psycho 2 | 3 | Yes, another Erlang web server! 4 | 5 | ## Technical Goals 6 | 7 | - Small, focused 8 | - Bug free 9 | - Reasonable performance 10 | - Trivial to embed 11 | - Support ecosystem of plugins/middleware 12 | - No external dependencies 13 | 14 | I'm not happy with the state of "small and focused" in HTTP Erlang land. I'd 15 | like something similar to Mochiweb that uses a more canonical interface. 16 | 17 | This is also an experiment to create a useful HTTP middleware interface in 18 | Erlang, which is similar to CGI, WSGI, Servlet, etc. in other languages. My 19 | theory is that a simple HTTP interface that can be used for clients, servers 20 | and middleware, we might be able to kick off a productive web ecosystem for 21 | Erlang. 22 | 23 | Performance is important, but is secondary to rock solidness. Above all, users 24 | should be confident that the server will behave predictably under a wide range 25 | of load/usage scenarios. 26 | 27 | ## Personal Goals 28 | 29 | - Drunken Stumble 30 | - Use proc (simplified gen_server) 31 | 32 | I initially tried to get away with using only proc_lib but that's impractical 33 | -- you need a minimal behavior to get proper OTP compliance. 34 | 35 | gen_server however is bloated and a pain to use. So there's "proc" -- a 36 | minimally viable proc_lib wrapper. See what happens! 37 | 38 | ## Non-Goals 39 | 40 | - One stop shopping 41 | 42 | In the interest of small-and-focused plus ecosystem, this project will remain 43 | only a web server -- in the spirit of Mochiweb and the CherryPy HTTP 44 | server. Other features should come by way of separate projects. The idea is 45 | that Psycho provides a drop-dead simple interface and makes adding new 46 | functionality very easy. The rest is up to users to piece together as needed. 47 | 48 | - Rubber Room Safety 49 | 50 | For now, there's very minimal type checking. Request handlers should be 51 | isolated from one another, so at worst only a particular request will blow 52 | up. But it's easy to do (e.g. provide a status reason that contains CRLF). I'd 53 | rather not spend the energy on the type checking and let things crash, either 54 | with the server or via a protocol violation. 55 | 56 | ## Supported Ecosystem Features 57 | 58 | Psycho will provide a bare bones HTTP server with perhaps some basic support for 59 | serving static content. The rest will come by way of compatible apps. 60 | 61 | Examples: 62 | 63 | - Routing schemes 64 | - Magic bindings with erlydtl (or other) templates 65 | - Cookie based authentication 66 | - Alternative static content serving 67 | - Fancy error reporting and debugging tools 68 | - Adapters to other servers or frameworks 69 | - Virtual hosting 70 | - RESTful mappers 71 | 72 | ## WSGI Inspiration 73 | 74 | Psycho steals liberally from [WSGI](http://www.python.org/dev/peps/pep-0333/) 75 | and [Python Web3 Interface](http://www.python.org/dev/peps/pep-0444/). 76 | 77 | Previous attempts to implement a WSGI like standard in Erlang failed because 78 | they didn't recognize that WSGI is a *protocol* -- not a library. 79 | 80 | The WSGI Python interface is an odd duck, to be sure. So we'll clean things up 81 | in Erlang. 82 | 83 | Interface design notes: 84 | 85 | - *No built in side effects* -- the interface should pure 86 | - Handle streamed or deferred responses through function callbacks 87 | - No server managed state (see Server State below) 88 | - No need to provide semantic compatibility with CGI 89 | 90 | ## WSGI Check List 91 | 92 | This is a scratch pad for documenting WSGI features vis-a-vis Psycho. 93 | 94 | ### Environ 95 | 96 | #### REQUEST_METHOD 97 | 98 | > The HTTP request method, such as "GET" or "POST". This cannot ever be an 99 | > empty string, and so is always required. 100 | 101 | In environ as `request_method` - same semantics. 102 | 103 | #### SCRIPT_NAME 104 | 105 | > The initial portion of the request URL's "path" that corresponds to the 106 | > application object, so that the application knows its virtual 107 | > "location". This may be an empty string, if the application corresponds to 108 | > the "root" of the server. 109 | 110 | Not implemented yet. Not sure about this one. It feels like this logic belongs 111 | in middleware. 112 | 113 | #### PATH_INFO 114 | 115 | > The remainder of the request URL's "path", designating the virtual "location" 116 | > of the request's target within the application. This may be an empty string, 117 | > if the request URL targets the application root and does not have a trailing 118 | > slash. 119 | 120 | Counter part to `SCRIPT_NAME` - not implemented. 121 | 122 | `request_path` is the full path provided in the HTTP request. 123 | 124 | #### QUERY_STRING 125 | 126 | > The portion of the request URL that follows the "?", if any. May be empty or 127 | > absent. 128 | 129 | Not implemented. 130 | 131 | If we parse the request path at all, I'm inclined to provide a fully parsed 132 | query string as proplist. This is also something that could be provided via 133 | middleware 134 | 135 | #### CONTENT_TYPE 136 | 137 | > The contents of any Content-Type fields in the HTTP request. May be empty or 138 | > absent. 139 | 140 | If specified as a header, in environ as `content_type`. 141 | 142 | #### CONTENT_LENGTH 143 | 144 | > The contents of any Content-Length fields in the HTTP request. May be empty 145 | > or absent. 146 | 147 | If specified as a header, in environ as `content_length` integer value. 148 | 149 | #### SERVER_NAME, SERVER_PORT 150 | 151 | > When combined with SCRIPT_NAME and PATH_INFO, these variables can be used to 152 | > complete the URL. Note, however, that HTTP_HOST, if present, should be used 153 | > in preference to SERVER_NAME for reconstructing the request URL. See the URL 154 | > Reconstruction section below for more detail. SERVER_NAME and SERVER_PORT can 155 | > never be empty strings, and so are always required. 156 | 157 | Need this to reconstruct URLs. Note in PEP 444 that port should be a string, 158 | not an integer. 159 | 160 | #### SERVER_PROTOCOL 161 | 162 | > The version of the protocol the client used to send the request. Typically 163 | > this will be something like "HTTP/1.0" or "HTTP/1.1" and may be used by the 164 | > application to determine how to treat any HTTP request headers. (This 165 | > variable should probably be called REQUEST_PROTOCOL, since it denotes the 166 | > protocol used in the request, and is not necessarily the protocol that will 167 | > be used in the server's response. However, for compatibility with CGI we have 168 | > to keep the existing name.) 169 | 170 | Available in environ as `request_protocol`. Same semantics as WSGI. 171 | 172 | #### HTTP_ Variables 173 | 174 | > Variables corresponding to the client-supplied HTTP request headers (i.e., 175 | > variables whose names begin with "HTTP_"). The presence or absence of these 176 | > variables should correspond with the presence or absence of the appropriate 177 | > HTTP header in the request. 178 | 179 | As we have no need to look like CGI, these are in their own list in Env. 180 | 181 | The complete list of HTTP request headers is available in environ as 182 | `http_headers`. 183 | 184 | #### wsgi.version 185 | 186 | > The tuple (1, 0), representing WSGI version 1.0.wsgi.version 187 | 188 | Not implemented. I'm not yet concerned about versioning this protocol. 189 | 190 | #### wsgi.url_scheme 191 | 192 | > A string representing the "scheme" portion of the URL at which the 193 | > application is being invoked. Normally, this will have the value "http" or 194 | > "https", as appropriate. 195 | 196 | Maybe. 197 | 198 | #### wsgi.input 199 | 200 | > An input stream (file-like object) from which the HTTP request body can be 201 | > read. (The server or gateway may perform reads on-demand as requested by the 202 | > application, or it may pre- read the client's request body and buffer it 203 | > in-memory or on disk, or use any other technique for providing such an input 204 | > stream, according to its preference.) 205 | 206 | We don't want this. We should avoid side effects built into the API. Instead, 207 | we should let an application return a value that indicates it wants to receive 208 | data from the request body. 209 | 210 | At a minimum, the supported values should be: 211 | 212 | {recv_body, App, Env} | {recv_body, App, Env, Options} 213 | App = fun(Data, Env) -> Result 214 | Env = env() 215 | Options = [option()] 216 | option() = {recv_size, integer()} 217 | | {recv_timeout, integer()} 218 | Data :: iolist() 219 | 220 | And, very useful: 221 | 222 | {recv_form_data, App, Env} | {recv_form_data, App, Env, Options} 223 | App = fun(Data, Env) -> Result 224 | Env = env() 225 | Options = [option()] 226 | option() = {recv_size, integer()} 227 | | {recv_timeout, integer()} 228 | | {part_handler, PartHandler} 229 | PartHandler = fun(Part, Env) -> PartHandlerResult 230 | PartHandlerResult = {stop, Reason, Env} | {continue, Part, Env} 231 | Data :: iolist() 232 | 233 | The later should "application/x-www-form-urlencoded" and "multipart/form-data" 234 | content types. 235 | 236 | #### wsgi.errors 237 | 238 | > An output stream (file-like object) to which error output can be written, for 239 | > the purpose of recording program or other errors in a standardized and 240 | > possibly centralized location. This should be a "text mode" stream; i.e., 241 | > applications should use "\n" as a line ending, and assume that it will be 242 | > converted to the correct line ending by the server/gateway. 243 | > 244 | > For many servers, wsgi.errors will be the server's main error 245 | > log. Alternatively, this may be sys.stderr, or a log file of some sort. The 246 | > server's documentation should include an explanation of how to configure this 247 | > or where to find the recorded output. A server or gateway may supply 248 | > different error streams to different applications, if this is desired. 249 | 250 | I *think* we punt on this to the apps. I would recommend logging to 251 | error_logger and letting something like lager intercept those calls, or not. 252 | 253 | #### wsgi.multithread, wsgi.multiprocess 254 | 255 | > This value should evaluate true if the application object may be 256 | > simultaneously invoked by another thread in the same process, and should 257 | > evaluate false otherwise. 258 | 259 | Ah, no. Psycho apps, as Erlang functions, can always be called in parallel. 260 | 261 | #### wsgi.run_once 262 | 263 | > This value should evaluate true if the server or gateway expects (but does 264 | > not guarantee!) that the application will only be invoked this one time 265 | > during the life of its containing process. Normally, this will only be true 266 | > for a gateway based on CGI (or something similar). 267 | 268 | I don't think this applies. 269 | 270 | ### start_response 271 | 272 | [PEP 444](http://www.python.org/dev/peps/pep-0444/) describes how to drop the 273 | start_response function in favor of a functional "body" return value. 274 | 275 | ## Server State 276 | 277 | If an app needs to manage state across requests, it must do so without the help 278 | from Psycho. An app gets a call with a single Environ argument, which is create 279 | anew on each request. 280 | 281 | Some ideas for managing state: 282 | 283 | - Use cookies! 284 | - Use Erlang services 285 | - Use an external server 286 | 287 | Cookies are the best! You get nice round-trip state management that scales 288 | perfectly across millions and millions -- even *billions* -- of users. 289 | 290 | If you *need* to manage state across requests within Psycho, create a 291 | registered service in your OTP app and use it -- so simple! 292 | 293 | Though it's the worst option, if you *need* to share state across Erlang VMs, 294 | use an external data service like memcache, MySQL, or even MongoDB! 295 | 296 | ## Response Iterables 297 | 298 | As an alternative to an iolist, the app can return an iterable style function 299 | that looks like this: 300 | 301 | ``` erlang 302 | app(Env) -> 303 | Db = lookup_db(Env), 304 | {{200, "OK"}, [{"Content-Type", "text/plain"}], {fun stream/1, Db}}. 305 | 306 | stream(Db) -> 307 | case db:lookup(Db, "data") of 308 | {ok, Data} -> 309 | {continue, Data, Db}; 310 | eof -> 311 | db:close(Db), 312 | stop; 313 | {error, Err} -> 314 | log_error(Err), 315 | db:close(Db), 316 | error 317 | end. 318 | ``` 319 | 320 | It's possible to include {fun/1, Arg0} as the body or as any part of a body 321 | iolist. This allows for something like this: 322 | 323 | ``` erlang 324 | ["Loading: ", {fun stream/1, Db}, ""] 325 | ``` 326 | 327 | Spec: fun(State) -> {continue, Data, NextState} | stop | {stop, Data} 328 | 329 | ## Content-Length 330 | 331 | The Content-Length header will be set automatically by Psycho if both these 332 | conditions hold: 333 | 334 | - Content-Length is not already present 335 | - The response body is binary 336 | 337 | PEP 333 has this to say about content length: 338 | 339 | http://www.python.org/dev/peps/pep-0333/#handling-the-content-length-header 340 | 341 | If the body is an iolist, we don't want to spend the cost of iolist_size at the 342 | server level -- this is something the application should do. 343 | 344 | For cases where a response is generated as a binary -- e.g. a template 345 | rendering or json encoding -- we will automatically handle Content-Length. 346 | 347 | ## TODO 348 | 349 | ### SSL 350 | 351 | Abstract the socket implementation to support SSL as well as TCP. This will 352 | come after the basic HTTP support is in place. 353 | 354 | ### URL Parsing 355 | 356 | Parsing the request path on each request seems reasonable. It's probably stupid 357 | optimization thinking, but it might make sense to support disabling this via a 358 | server option. 359 | 360 | psycho_server:start(8080, myapp, [disable_parse_request_path]) 361 | 362 | Unless disabled, the parsed path would land in environ as something like this: 363 | 364 | ``` erlang 365 | {parsed_request_path, {Path, QueryString(), ParsedQueryString()}} 366 | ``` 367 | 368 | E.g. a path like this: 369 | 370 | /foo/bar/?baz=bam 371 | 372 | would be parsed like this: 373 | 374 | {"/foo/bar", "baz=bam", [{"baz", "bam"}]} 375 | 376 | This could be handled lazily as well -- i.e. the first function to use this 377 | would call a helper function like this: 378 | 379 | app(Env0) -> 380 | Env = psycho_util:ensure_parsed_request_path(Env0), 381 | dispatch(Env). 382 | 383 | This would be used primarily by routing applications. 384 | 385 | ### Explicit Chunking 386 | 387 | From PEP 333: 388 | 389 | > And, if the server and client both support HTTP/1.1 "chunked encoding" [3], 390 | > then the server may use chunked encoding to send a chunk for each write() 391 | > call or string yielded by the iterable, thus generating a Content-Length 392 | > header for each chunk. 393 | 394 | In Psycho, that would suggest that response: 395 | 396 | {{200, "OK"}, [], ["Hello, my name is ", Name, "!"]} 397 | 398 | would be chunked in three parts! Of course we can't have this -- it breaks 399 | iolists and forces apps to do absurd things like this: 400 | 401 | {{200, "OK"}, [], "Hello, my name is " ++ Name ++ "!"} 402 | 403 | No! 404 | 405 | But we need a way to get explicit chunking. I think maybe something like this: 406 | 407 | {{200, "OK"}, [], {chunks, [["Hello, my name is ", Name, "!"]]}} 408 | 409 | Notes: 410 | 411 | - The body response itself must be tagged with `chunks` -- elements within the 412 | chunks list cannot be further tagged 413 | - Only elements in the chunks list will be chunked -- i.e. there's no deep list 414 | chunking 415 | - Each element in the list will be evaluated separately in memory to build a 416 | complete chunk (perhaps up to some server configurable limit?) 417 | 418 | The equivalent of WSGI's "iter" pattern in Psycho would look like this: 419 | 420 | {{200, "OK"}, [], {chunks, {fun iter/1, []}}} 421 | 422 | ### Content-Disposition in static 423 | 424 | See CherryPy's static.py for how they do this. 425 | 426 | ### Use sendfile in psycho_static 427 | 428 | This may also be premature optimization thinking, but it may be considerably 429 | more efficient to use file:sendfile rather than read/write the file. 430 | 431 | Measure first. 432 | 433 | ## Notes From CherryPy 434 | 435 | CherryPy provides a straight forward WSGI server implementation. 436 | 437 | It also provides an application level framework on top of it. 438 | 439 | When using the CherryPy framework, you indirectly work with CPWSGIServer, which 440 | wraps the vanilla WSGI server. 441 | 442 | CPWSGIServer provides cherrpy.tree as the WSGI application, which is an 443 | instance of _cptree.Tree. Tree implements __call__ according to the WSGI 444 | interface. The call implementation is basically a pass through to one of the 445 | tree's configured applications based on the script name. 446 | 447 | Apps can be either raw WSGI apps or _cptree.Application, which uses a WSGI app 448 | helper to implement the expected interface. The helper in turn supports a 449 | pipeline of apps, including the provided app as well as any middleware. 450 | 451 | This took a while to sort through. I'm inclined to provide a similar facility 452 | within Psycho, but make it a bit more transparent. 453 | 454 | ## Multipart Messages 455 | 456 | When a form is submitted with content type = "multipart/form-data; 457 | boundary=---xxxx" what support do we give app developers? 458 | 459 | Currently, we support recv_body, which returns chunks of the body to a 460 | specified callback. These chunks are currently opaque binaries to psycho, and 461 | stop up to the number of bytes specified by the content length request header. 462 | 463 | Apps get this callback: 464 | 465 | app(Data, Env) -> Result 466 | 467 | In the case where content type is multipart/form-data, a chunk may contain or 468 | more parts, or no parts, if the chunk is strictly of a previous part. 469 | 470 | Here's an example, which shows chunks and parts overlapping. 471 | 472 | +--- ---abcde 473 | | Content-Disposition: form-data; name="name" 474 | Chunk 475 | | Garrett Smith 476 | | ---abcde 477 | +--- Content-Disposition: form-data; name="email" 478 | | 479 | Chunk g@rre/tt 480 | | ---abcde 481 | | Content-Disposition: form-data; name="file1"; filename="Makefile" 482 | | Content-Type: application/octet-stream 483 | +--- 484 | | ...begin file... 485 | Chunk 486 | | 487 | | 488 | +--- 489 | | 490 | Chunk 491 | | 492 | | 493 | +--- 494 | | 495 | Chunk 496 | | ---abcde 497 | +--- 498 | 499 | Boundaries indicate both the start and the end of a part. 500 | 501 | Given a chunk, we should be able to enuermate: 502 | 503 | - Data preceding the first boundary indicator (leading data) 504 | - One or more parts 505 | - Data following the last boundary indiator (trailing data) 506 | 507 | ---- 508 | 509 | What if we made recv_form_data smarter -- now it knows about 510 | "multipart/form-data" content type, in addition to 511 | "application/x-www-form-urlencoded". 512 | 513 | app(Env) -> 514 | {recv_form_data, fun handle_data/2, Env}. 515 | 516 | %% Note this requires a default chunk size that the server uses. 517 | %% We could make this a part of the result tuple, but this is getting 518 | %% complicated. 519 | %% 520 | %% What about an options proplist? 521 | 522 | handle_data(Data, _Env) -> 523 | do_something_with_data(Data), 524 | {{302, "See Other"}, [{"Location", "/"}]}. 525 | 526 | This would be fine, if it weren't for the possibility of memory overruns from 527 | huge files. We need a way to handle received chunks as they arrive and not save 528 | these in the final Data proplist. 529 | 530 | How? 531 | 532 | What if, in addition to taking an App element in recv_form_data, we supported a 533 | two tuple: 534 | 535 | app(Env) -> 536 | {recv_form_data, {fun handle_multipart/2, fun handle_data/2}, Env}. 537 | 538 | Or: 539 | 540 | app(Env) -> 541 | {recv_form_data, App, Env, [{part_handler, fun handle_part/2}]}. 542 | 543 | 544 | Or: 545 | 546 | app(Env) -> 547 | {recv_form_data, App, Env, [{part_handler, fun handle_part/2}, 548 | {recv_timeout, 60000} 549 | {recv_size, 10240]}. 550 | 551 | I definitely like this options pattern - much easier to scale. 552 | 553 | What about this handle part interface? 554 | 555 | This might be a standard interface for handling file uploads. 556 | 557 | handle_part({part, Name, Headers}, Env) -> 558 | case is_file(Headers) of 559 | {true, Filename} -> 560 | F = open_file(Filename), 561 | {continue, psycho:set_env({open_file, Name}, F, Env)}; 562 | false -> 563 | {continue, Env} 564 | end; 565 | handle_part({data, Name, <<>>}, Env) -> 566 | maybe_close_file(Name, Env), 567 | {continue, Env}; 568 | handle_part({data, Name, Data}, Env) -> 569 | case maybe_write_to_file(Name, Data, Env) of 570 | true -> {drop, Env}; 571 | false -> {continue, Env} 572 | end. 573 | 574 | Notes: 575 | 576 | - A new part is signified by the tuple `{part, Name, Headers}` 577 | 578 | This lets the handler initialize any structures for handling a file 579 | (typically opening a file handle, but could be setting up a connection to a 580 | remote server, etc.) 581 | 582 | The handler must use the Env as state to store any file related artifacts 583 | (e.g. file handles, sockets, etc.) 584 | 585 | The handler can indicate that a part should be dropped altogether by 586 | returning `{drop, Env}`. If so optimizes, this would let the server drop 587 | to the next boundary/part in the request stream (practically this would just 588 | mean iterating through the psycho_multipart filtered data until we reach the 589 | next part). 590 | 591 | The result may optionally contain a modififed part element, in addition to 592 | the. This would let a handler filter name and headers. 593 | 594 | - Part data will be conveyed using the tuple `{data, Name, Data}`. An empty 595 | binary will indicate the end of the part. 596 | 597 | Handlers can indicate that data should be dropped using `{drop, 598 | Env}`. This is an important step for file handlers as it prevents the 599 | potentially large file body from being collected and presented in the file 600 | form data app call. The example shows that, if file data is written, it's 601 | dropped. 602 | 603 | To indicate that the data should be kept and included in the form data 604 | provided to the app, the handler should return `{continue, Env}`. Optionally, 605 | it can return `{continue, NewData, Env}` if it wants to modify the 606 | data. (Also advanced and speculative, but easy to do.) 607 | 608 | Handlers should take care to handle the empty data binary case and close any 609 | open file handles, sockets, etc. 610 | 611 | ### psycho:env_val + psycho:set_env 612 | 613 | This asymmetry is slightly perturbing. Should it now be psycho:get_env? 614 | 615 | get_env(Name, Env) feels wrong. 616 | 617 | ### Handling closed client connections 618 | 619 | If a client decides to close the connection, a Psycho handler will 620 | crash during its response as it's asserting ok results from send 621 | calls. While this is arguably okay, it does result in crash logs for 622 | cases that are probably not worth logging. 623 | 624 | An approach is to wrap send calls and throw an exception (used for 625 | flow control in this case) to short-circuit the response and exit the 626 | process. This is at least explicit (rather than a crash from a 627 | badmatch), whether or not we decide this is a {stop, normal} or {stop, 628 | client_connection_closed} result. 629 | 630 | ### recv_body and recv_length option - BIG surprise 631 | 632 | This is a terrible problem. There's a safeguard in recv_body that 633 | prevents OOM attack vector: the default behavior is to receive a max 634 | of 32768 bytes unless recv_length is specified as an 635 | option. Subsequent uses of recv_body will read more data until an 636 | empty result is returned. This is entirely by design. 637 | 638 | The problem is it's entirely non obvious and will absolutely drive 639 | people nuts. 640 | 641 | Some options: 642 | 643 | - Introduce a recv_body_part that requires a length 644 | - Modify recv_body to use content-length if available or read until 645 | empty 646 | 647 | ## Misc 648 | 649 | Use this for password middleware: 650 | 651 | http://alias.io/2010/01/store-passwords-safely-with-php-and-mysql/ 652 | -------------------------------------------------------------------------------- /TUTORIAL.md: -------------------------------------------------------------------------------- 1 | # Psycho Tutorial 2 | 3 | Psycho is a ridiculously kiss ass Erlang web server that implements a CGI/WSGI 4 | style interface. This may not strike you now as world changing and it's okay 5 | -- soon it will all be clear. 6 | 7 | You are about to embark on a journey that will change your life. If you don't 8 | drink beer, now is the time to start. Let's pour something delicious and begin! 9 | 10 | ## Get and build Psycho 11 | 12 | At a shell prompt (i.e. console or command line), execute the following lines 13 | (don't type the preceding `$` symbol -- this merely indicates the command 14 | should be executed from a system shell): 15 | 16 | $ git clone https://github.com/gar1t/psycho.git 17 | $ cd psycho 18 | $ make check 19 | 20 | This will compile Psycho and run all of the tests. 21 | 22 | ## Create a Psycho App Skeleton 23 | 24 | Run `psycho-mkapp` to generate a new application skeleton: 25 | 26 | $ cd psycho 27 | $ ./psycho-mkapp psytut ~/psytut 28 | 29 | This will create a project skeleton in a directory named "psytut" in your home 30 | directory. If you want to create the project in a different directory, change "~/psytut" 31 | to something else. 32 | 33 | ## Compile the Empty Project 34 | 35 | Change to the new project location and run `make`: 36 | 37 | $ cd ~/psytut 38 | $ make 39 | 40 | This will download the project dependencies (in this case, Psycho itself and 41 | e2, a library that simplifies writing OTP compliant Erlang applications). 42 | 43 | For the rest of this tutorial, you will use your editor to create and modify 44 | Erlang source files, compile them using `make`, and test the result in the 45 | Erlang shell and your browser. 46 | 47 | ## Run the Empty Project 48 | 49 | To start the Erlang shell, which will start your psycho server, run `make` with 50 | the `shell` target: 51 | 52 | $ make shell 53 | 54 | The psycho project skeleton is configured to start a single server listening on 55 | port 8080. If that port is already bound, you'll get a fancy Erlang error that 56 | contains this fragment: 57 | 58 | {failed_to_start_child,psytut_http, 59 | {{listen,eaddrinuse}, ... 60 | 61 | You can change the port the server runs on by editing 62 | `src/psytut_http.erl`. E.g. change the value "8080" to something else, exit the 63 | shell using CTRL-C CTRL-C (twice) and re-running `make shell`. 64 | 65 | When the application is running and bound to the port, open it in a browser: 66 | 67 | http://localhost:8080 68 | 69 | If you changed the port, using the new port value -- this applies to the rest 70 | of the tutorial. 71 | 72 | You should see this simple message: 73 | 74 | Hello psytut 75 | 76 | ## Our First Change 77 | 78 | Let's change this to test the iterative process that we'll use in the 79 | tutorial. Edit `psytut_http.erl` and modify the text "Hello psytut" to "Hello, 80 | Psycho Tutorial!". 81 | 82 | Run `make app` in a separate window or from your editor/IDE to compile the 83 | modified source file. 84 | 85 | Note that in the Erlang shell you'll see: 86 | 87 | Reloading psytut_http... ok 88 | 89 | In the shell, Psycho watches for modified source files and recompiles them 90 | automatically. 91 | 92 | Reload your web browser to see the result. 93 | 94 | ## Psycho and Middleware 95 | 96 | One of Psycho's goals is to enable a middleware ecosystem. Here's a simple 97 | picture of what this means. 98 | 99 | This is a typical request/response cycle in a web application: 100 | 101 | +------------+ request +------------+ 102 | | |<---------+| | 103 | | Server | response | Client | 104 | | |+--------->| | 105 | +------------+ +------------+ 106 | 107 | At the moment, your web browser (the client) requests a path from the Psycho 108 | application (the server). The response is a simple HTML page. 109 | 110 | Let's inject some middleware into this cycle: 111 | 112 | +------------+ request +------------+ request +------------+ 113 | | |<---------+| |<---------+| | 114 | | Server | response | Middleware | response | Client | 115 | | |+--------->| |+--------->| | 116 | +------------+ +------------+ +------------+ 117 | 118 | In this case, the client and server remain the same and a new component sits in 119 | between the two. 120 | 121 | Enough ASCII art! Let's implement this to illustrate. 122 | 123 | In `psytut_http.erl`, find the line that looks like this: 124 | 125 | psycho_server:start(?PORT, ?MODULE). 126 | 127 | Modify this line to be: 128 | 129 | psycho_server:start(?PORT, apply_header_footer(?MODULE)). 130 | 131 | This applies a yet-to-be defined function to add some middleware to our 132 | application. Let's define that function now -- in `psytut_http.erl` add: 133 | 134 | apply_header_footer(App) -> 135 | sample_middleware:header_footer("# Header\n\n", "\n\n# Footer", App). 136 | 137 | Before you test the change in your browser, you need to restart the psytut 138 | application (this particular change does not take effect automatically). 139 | 140 | In the shell, call this function: 141 | 142 | > psytut:restart(). 143 | 144 | Reload your browser. You should see this: 145 | 146 | # Header 147 | 148 | Hello Psycho Tutorial! 149 | 150 | # Footer 151 | 152 | Let's take a moment to understand what happened. 153 | 154 | Our psytut application serves a simple text page with a message. This is our 155 | server in the diagram above. Next we added some middleware that modifies the 156 | page by adding a header and footer. That's the middleware in the diagram 157 | above. This middleware takes the result of our application, modifies it, and 158 | returns a new result to the client. It sits in the middle of the server and the 159 | client -- thus the term "middle..." you get the idea. 160 | 161 | The concept of middleware is like the Dark Side of the Force -- it's more 162 | powerful than you can possibly imagine. You will see. 163 | 164 | ## Adding Authentication 165 | 166 | Our middleware example so far is pretty simple -- we add some content to a 167 | page. Let's do something a bit tricker. 168 | 169 | In `psytut_http.erl` modify `start_link/0` to be: 170 | 171 | start_link() -> 172 | App = apply_auth(apply_header_footer(?MODULE)), 173 | psycho_server:start(?PORT, App). 174 | 175 | In this change we're applying another application to the chain. 176 | 177 | Add this function to the module: 178 | 179 | apply_auth(App) -> 180 | sample_middleware:basic_auth("Psycho", "admin", "sesame", App). 181 | 182 | This function wraps an application with basic authentication functionality, 183 | provided by the sample_middleware module. We'll use the credentials specified 184 | in this call to log into our application. 185 | 186 | Compile your changes and restart the application: 187 | 188 | > psytut:restart(). 189 | 190 | Visit the page again in your browser -- you will be prompted for a user name 191 | and password. Experiment to see how it works. See if you can log in! (Hint: the 192 | credentials you enter must *match* the correct credentials.) 193 | 194 | Wasn't that easy? That's the idea behind middleware -- you simply tack on new 195 | components to modify the way HTTP requests are handled. Can you feel the 196 | telekinetic powers surging through your body now? 197 | 198 | Before we move on to more functionality, let's look at one more aspect of 199 | middleware. In `psytut_http.erl`, modify `app/1` function to be: 200 | 201 | app(Env) -> 202 | {{200, "OK"}, [{"Content-Type", "text/plain"}], page_body(Env)}. 203 | 204 | Add these functions to the module: 205 | 206 | page_body(Env) -> 207 | ["Hello ", user(Env), ", welcome to the Psycho Tutorial!"]. 208 | 209 | user(Env) -> 210 | psycho:env_val(remote_user, Env, "mystery user"). 211 | 212 | Compile your changes and refresh your browser. You should see this page: 213 | 214 | # Header 215 | 216 | Hello admin, welcome to the Psycho Tutorial! 217 | 218 | # Footer 219 | 220 | What devilry is this?? The basic authentication middleware app modified the 221 | `Env` value by adding a remote_user value, which is the authenticated user name 222 | from the basic auth challenge. This illustrates an important concept: 223 | middleware can modify the request env passed to downstream applications as well 224 | as modify the result. We'll see how this simple facility can be used to 225 | implement a huge variety of pluggable functionality for your Erlang based web 226 | application. 227 | 228 | What functionality you say? Here's a list of WSGI (the Python based Web gateway 229 | interface that Psycho follows) libraries: 230 | 231 | http://wsgi.readthedocs.org/en/latest/libraries.html 232 | 233 | ## Beyond Plain Text 234 | 235 | What a lovely ASCII Web application! But can we do more? Can we serve, say, 236 | HTML? 237 | 238 | Oh yeah, we can. Big time. 239 | 240 | In `psytut_http.erl`, modify `apply_header_footer/1` to be: 241 | 242 | apply_header_footer(App) -> 243 | sample_middleware:header_footer(page_header(), page_footer(), App). 244 | 245 | Modify `app/1` to be: 246 | 247 | app(Env) -> 248 | {{200, "OK"}, [{"Content-Type", "text/html"}], page_body(Env)}. 249 | 250 | Add these functions to the module: 251 | 252 | page_header() -> 253 | "

Psycho Tutorial

". 254 | 255 | page_footer() -> 256 | "


Content on this site is licensed under a " 257 | "" 258 | "Creative Commons Attribution 4.0 International license" 259 | "

". 260 | 261 | Compile the changes and restart the application: 262 | 263 | > psytut:restart(). 264 | 265 | Reload your browser. You should see a beautiful circa 1995 Web 1.0 application! 266 | 267 | ## Using Templates 268 | 269 | Psycho is *not* a full featured application platform. We don't need anything of 270 | the sort to create full featured web applications! We add features 271 | *incrementally* -- we don't need no stinking framework! 272 | 273 | We currently have some HTML hacked directly into our application. And that's 274 | okay. When someone tells you have to separate application logic from 275 | presentation logic, here's what you say: When the comet crashes into Earth and 276 | ends life as we know it, will it *really* make any difference that your 277 | application logic is separate from your presentation logic? It won't. 278 | 279 | Yet while there's no moral imperative to move UI code out of Erlang into a 280 | template there are some practical benefits to it. Eh, forget that. Let's just 281 | get some templates working so we can learn -- that's the point of this 282 | tutorial. 283 | 284 | ErlyDTL is one of the best templating libraries around period, much less the 285 | best in the Erlang ecosystem. Let's get it. 286 | 287 | Your Psycho project uses Loic Hoguin straight forward `erlang.mk` helper 288 | thingy. Don't ask what it does -- it just works. 289 | 290 | To add a new Erlang library to your project, you modify `Makefile`. To add 291 | `erlydtl`, you need to make two changes. First, find the line: 292 | 293 | DEPS = e2 psycho 294 | 295 | Change it to be: 296 | 297 | DEPS = e2 psycho erlydtl 298 | 299 | This tells the make system that ErlyDTL is a project dependency (in addition to 300 | the absurdly awesome e2 library and of course Psycho). 301 | 302 | Next, add the this line before line "include erlang.mk": 303 | 304 | dep_erlydtl = https://github.com/erlydtl/erlydtl.git 305 | 306 | This tells the make system to resolve the erlydtl dependency using the git 307 | URL. Now *that's* straight forward. 308 | 309 | Build the project by running `make`. This will download and compile the erlydtl 310 | library. 311 | 312 | We just added some power. Let's use it. 313 | 314 | ## Refactor Page Body 315 | 316 | Let's take the simple step of moving the page body content out of the Erlang 317 | module. We'll put it instead in a template that actually resembles an HTML 318 | file. 319 | 320 | By convention, non source resources used by an Erlang application are stored in 321 | the project local "priv" directory. Create this directory from the system 322 | terminal. Assuming you're in the psytut project directory, run: 323 | 324 | $ mkdir priv 325 | 326 | Next, create the file `priv/body.html`: 327 | 328 | Hello {{user}}, welcome to the Psycho Tutorial! 329 | 330 | Does this look familar? It's the page body, but in straight forward text rather 331 | than Erlang code. We include a reference to a `user` variable -- just like the 332 | current `page_body/1` function. 333 | 334 | Next, modify `psytut_http.erl` to use the template. Change `page_body/1` to 335 | be: 336 | 337 | page_body(Env) -> 338 | psycho_erlydtl:render("priv/body.html", [{user, user(Env)}]). 339 | 340 | Compile your changes. 341 | 342 | Tragically, because we added the ErlyDTL library after we ran `make shell`, we 343 | need to restart the Erlang shell. Actually, we don't *need* to restart it -- 344 | this is Erlang after all. But it's easier. 345 | 346 | Type CTRL-C CTRL-C to terminate the shell and then `make shell` at the terminal 347 | prompt to start your application again. 348 | 349 | Reload your browser to see what happens! 350 | 351 | Well, it *should* be the same -- we merely replaced the Erlang implementation 352 | of the body content with template content. But let's test our new powers! 353 | 354 | Modify `priv/body.html`, save your changes, and reload the web page. Great 355 | Odin's Ravens it changes dynamically! 356 | 357 | ## Refactor Header and Footer 358 | 359 | Let's complete this refactor by moving the header and footer content out of the 360 | Erlang module. In this step, we'll abandon the header/footer middleware as this 361 | will be handled now by our templating scheme. 362 | 363 | Create the file `priv/header.html`: 364 | 365 |

Psycho Tutorial

366 | 367 | Create the file `priv/footer.html`: 368 | 369 |

370 |


371 | Content on this site is licensed under a 372 | 373 | Creative Commons Attribution 4.0 International license 374 | 375 |

376 | 377 | Ah! Now we see the moral prerogative of moving HTML out of an Erlang 378 | module. You can actually read it! True, readability never mattered to PHP and 379 | it did fine -- but PHP never had erlydtl! 380 | 381 | Next, modify `priv/body.html` to include these two files: 382 | 383 | {% include "header.html" %} 384 | 385 | Hello {{user}} welcome to the Psycho Tutorial! 386 | 387 | {% include "footer.html" %} 388 | 389 | Finally, we don't need the header and footer support in `psytut_http.erl`. 390 | 391 | Modify `start_link/1` to be: 392 | 393 | start_link() -> 394 | App = apply_auth(?MODULE), 395 | psycho_server:start(?PORT, App). 396 | 397 | Delete these functions: 398 | 399 | - `apply_header_footer/1` 400 | - `page_header/0` 401 | - `page_footer/0` 402 | 403 | Compile your changes and restart `psytut`: 404 | 405 | > psytut:restart(). 406 | 407 | Reload the page to see the change. Nothing! But if you edit any of the various 408 | templates, you'll see the changes take effect on the next page load. 409 | 410 | ## Handling Errors 411 | 412 | You may have already seen this, but in case you haven't, we're going to 413 | generate an error to see what happens. 414 | 415 | Add the string "{{" anywhere to any of the templates. This is invalid Django 416 | syntax because it doesn't contain a closing "}}" -- it will cause problems. 417 | 418 | Reload the web page. 419 | 420 | What happened? You got a blank page didn't you. That's confusing to a user, but 421 | we're hard core Erlang hackers, so we're not slowed down one lick. 422 | 423 | Using curl we can see what's going on: 424 | 425 | $ curl localhost:8080 --user admin:sesame -i 426 | 427 | This will request the page specifying the correct basic auth credentials and 428 | will print details from the response. 429 | 430 | You should see something like this: 431 | 432 | HTTP/1.1 500 Internal Server Error 433 | Connection: keep-alive 434 | Server: psycho 435 | Date: Wed, 01 Jan 2014 02:24:47 GMT 436 | Transfer-Encoding: chunked 437 | 438 | This means that something went wrong. A 500 should indicate that there's a bug 439 | on the server. In fact there is -- we just introduced it. 440 | 441 | What now? Well, check out the Erlang shell! You'll see something like this: 442 | 443 | ERROR REPORT==== 31-Dec-2013::20:24:47 === 444 | {handler_error,<0.215.0>, 445 | {app_error, 446 | {{erlydtl_compile, 447 | {"priv/footer.html", 448 | [{1,erlydtl_scanner,"Illegal character in column 6"}]}}, 449 | ... 450 | 451 | So this is helpful to us hackers, but what of our hapless users? They just see 452 | this perplexing blank screen, which will prompt them to hit F5 on their browser 453 | repeatedly until their hands are raw, resulting in a DoS attack on your server. 454 | 455 | Let's fix this with psychology! 456 | 457 | All we need to do here is set the `debug` flag on our application to `true` and 458 | we're good to go! 459 | 460 | Haaahhahaha! 461 | 462 | This of course is joke -- there is no `debug` flag in Psycho! This is not some 463 | cushy high level web framework that makes decisions for you. This is raw, 464 | gritty, some would say totally psychotic! 465 | 466 | Calm down, it's not that bad. Control is not bad. It's actually good -- even 467 | great. Now, let's solve this problem with a little middleware. 468 | 469 | ## Error Handler Middleware 470 | 471 | Create a new file `src/psytut_error_http.erl`: 472 | 473 | -module(psytut_error_http). 474 | 475 | -export([error_handler/1]). 476 | 477 | error_handler(App) -> 478 | fun(Env) -> error_handler_app(App, Env) end. 479 | 480 | error_handler_app(App, Env) -> 481 | handle_app_result(catch psycho:call_app(App, Env), Env). 482 | 483 | handle_app_result({'EXIT', Info}, Env) -> 484 | e2_log:error({Info, Env}), 485 | {{500, "Internal Error"}, 486 | [{"Content-Type", "text/html"}], 487 | error_page(Info)}; 488 | handle_app_result({_, _, _}=Result, _Env) -> Result. 489 | 490 | error_page(Err) -> 491 | psycho_erlydtl:render("priv/error.html", [{error, format_error(Err)}]). 492 | 493 | format_error(Err) -> 494 | io_lib:format("~p", [Err]). 495 | 496 | All this code! What's it for? 497 | 498 | This is a Psycho app that provides some application specific middleware powers. 499 | 500 | This module is used to wrap another Psycho application with error handling 501 | logic. The sole exported function `error_handler/1` returns a Psycho "app": 502 | 503 | - It's function that takes a single `Env` argument 504 | 505 | - It complies with the Psycho WSGI style API (this isn't formally defined yet, 506 | but it's described in some detail in the Psycho README and is resonably 507 | stable at this point) 508 | 509 | The app logic is implemented by `error_handler_app/2`, which delegates the call 510 | to the wrapped application. If an error occurs, it's logged and then formatted 511 | for the user. If an error doesn't occur, the handler simply returns the wrapped 512 | app's result. 513 | 514 | It relies on `priv/error.html` so let's create that: 515 | 516 | 517 |

Something terrible happened, but might not be your fault!

518 |

Can you spot the problem?

519 |
{{error}}
520 | 521 | 522 | You might want to provide a different error message, but this has its virtues. 523 | 524 | Compile the project and restart the application: 525 | 526 | > psytut:restart(). 527 | 528 | Refresh your browser. You should now see a nicely formatted error message, 529 | rather than a confusing blank screen. And the psychology of this message will 530 | stop a user dead in his tracks with a sense of foreboding guilt that he might 531 | be breaking something. No more rapid fire F5 refreshes, so your site is safe 532 | from DoS attacks. 533 | 534 | Correct the error that you introduced (the "{{" characters), save the file and 535 | refresh your browser. You should be back to your normal tutorial welcome 536 | screen. 537 | 538 | ## A Word On Performance 539 | 540 | We're using the term "middleware", which sounds scary -- a dark image from an 541 | enterprise architure committee meeting. But this is Erlang! As we saw with our 542 | custom error handler in the previous section, "middleware" is single function! 543 | 544 | That should be the final word on performance: it costs a single extra function 545 | call, plus whatever the middleware app does. 546 | 547 | But what sort of operations can be performed, and does this design scheme make 548 | your web apps slow? Let's take a quick look. 549 | 550 | ### Request Env Modification 551 | 552 | The Psycho request `Env` value is a properties list -- a list of two-tuples 553 | that serves as a multi-key key value store. Like any Erlang list, we can add 554 | items by prepending to the list with the cons operator. This is an efficient 555 | operation in Erlang. In addition to making new values available to the Env, the 556 | cons operation effectively modifies values when you use the proplists or module 557 | or (more efficient) lists:keyfind/3 function. 558 | 559 | ## Routes 560 | 561 | So far our web application just serves a single page, regardless of the 562 | requested path. This is pretty unusual for a web app -- the request method and 563 | path should be used to return specific content. If an unrecognized path is 564 | requested by a client, the server should return a 404 error to indicate that 565 | the resource is "not found". 566 | 567 | We can use Psycho's routing application to wire up our application to handle 568 | specific URLs. 569 | 570 | In its simplest form, the routes applications matches a parsed path as an exact 571 | string and delegates the request to an associated application. The atom '_' can 572 | be used to match any path. Here's a sample route that has three entries: 573 | 574 | [{"/", fun home_page/1}, 575 | {"/users", fun users_page/1}, 576 | {'_', fun other_page/1}] 577 | 578 | Let's try this out! Modify `start_link/0` in `psytut_http.erl` to look like 579 | this: 580 | 581 | start_link() -> 582 | App = apply_error_handler(apply_auth(routes_app())), 583 | psycho_server:start(?PORT, App). 584 | 585 | And define the `routes/0` function this way: 586 | 587 | routes_app() -> 588 | Routes = 589 | [{"/", fun home_page/1}, 590 | {"/users", fun users_page/1}, 591 | {'_', fun other_page/1}], 592 | psycho_route:create_app(Routes). 593 | 594 | This is the base application that we wrap with authentication and error 595 | handlers. The three functions that correspond to the three paths aren't yet 596 | defined. Let's define them now. 597 | 598 | home_page(Env) -> 599 | default_page("Welcome to the home page!", Env). 600 | 601 | users_page(Env) -> 602 | default_page("Welcome to the users page!", Env). 603 | 604 | other_page(Env) -> 605 | {Path, _, _} = psycho:env_val(parsed_request_path, Env), 606 | Msg = ["You asked for ", Path, " - not sure what this is :\\"], 607 | default_page(Msg, Env). 608 | 609 | default_page(Msg, Env) -> 610 | {{200, "OK"}, [{"Content-Type", "text/html"}], page_body(Msg, Env)}. 611 | 612 | Notice that we've modified the `page_body` function to now take a `Msg` 613 | argument. This will be displayed to the user. Modify `page_body` to look like 614 | this: 615 | 616 | page_body(Msg, Env) -> 617 | Vars = 618 | [{msg, Msg}, 619 | {user, user(Env)}], 620 | psycho_erlydtl:render("priv/body.html", Vars). 621 | 622 | And modify `priv/body.html` to look like this: 623 | 624 | {% include "header.html" %} 625 | 626 | Hello {{user}}, welcome to the Psycho Tutorial! 627 | 628 |

{{ msg }}

629 | 630 | {% include "footer.html" %} 631 | 632 | Restart `psytut` from the Erlang shell: 633 | 634 | > psytut:restart(). 635 | 636 | Notice now that the application responds specifically to `/` and `/users` and 637 | generically to any other path. This is the basis for structuring your 638 | application! 639 | 640 | ## Cookie Based Authentication 641 | 642 | Earlier we saw how easy it is to add support for basic authentication -- you 643 | just have to wrap an application in the right middleware! 644 | 645 | Next, we'll step through a simple cookie based authentication. The idea is the 646 | same -- insert some middleware into the chain. 647 | -------------------------------------------------------------------------------- /include/http_status.hrl: -------------------------------------------------------------------------------- 1 | -define(status_continue, {100, "Continue"}). 2 | -define(status_switching_protocols, {101, "Switching Protocols"}). 3 | -define(status_ok, {200, "OK"}). 4 | -define(status_created, {201, "Created"}). 5 | -define(status_accepted, {202, "Accepted"}). 6 | -define(status_non_authoritative_information, 7 | {203, "Non-Authoritative Information"}). 8 | -define(status_no_content, {204, "No Content"}). 9 | -define(status_reset_content, {205, "Reset Content"}). 10 | -define(status_partial_content, {206, "Partial Content"}). 11 | -define(status_multiple_choices, {300, "Multiple Choices"}). 12 | -define(status_moved_permanentaly, {301, "Moved Permanently"}). 13 | -define(status_found, {302, "Found"}). 14 | -define(status_see_other, {303, "See Other"}). 15 | -define(status_not_modified, {304, "Not Modified"}). 16 | -define(status_use_proxy, {305, "Use Proxy"}). 17 | -define(status_temporary_redirect, {307, "Temporary Redirect"}). 18 | -define(status_bad_request, {400, "Bad Request"}). 19 | -define(status_unauthorized, {401, "Unauthorized"}). 20 | -define(status_payment_required, {402, "Payment Required"}). 21 | -define(status_forbidden, {403, "Forbidden"}). 22 | -define(status_not_found, {404, "Not Found"}). 23 | -define(status_method_not_allowed, {405, "Method Not Allowed"}). 24 | -define(status_not_acceptable, {406, "Not Acceptable"}). 25 | -define(status_proxy_authentication_required, 26 | {407, "Proxy Authentication Required"}). 27 | -define(status_request_timeout, {408, "Request Time-out"}). 28 | -define(status_conflict, {409, "Conflict"}). 29 | -define(status_gone, {410, "Gone"}). 30 | -define(status_length_required, {411, "Length Required"}). 31 | -define(status_precondition_failed, {412, "Precondition Failed"}). 32 | -define(status_request_entity_too_large, {413, "Request Entity Too Large"}). 33 | -define(status_request_url_too_large, {414, "Request-URI Too Large"}). 34 | -define(status_unsupported_media_type, {415, "Unsupported Media Type"}). 35 | -define(status_request_range_not_satisfiable, 36 | {416, "Request range not satisfiable"}). 37 | -define(status_expectation_failed, {417, "Expectation Failed"}). 38 | -define(status_internal_server_error, {500, "Internal Server Error"}). 39 | -define(status_not_implemented, {501, "Not Implemented"}). 40 | -define(status_bad_gateway, {502, "Bad Gateway"}). 41 | -define(status_service_unavailable, {503, "Service Unavailable"}). 42 | -define(status_gateway_timeout, {504, "Gateway Time-out"}). 43 | -define(status_http_version_not_supported, 44 | {505, "HTTP Version not supported"}). 45 | -------------------------------------------------------------------------------- /priv/mime.types: -------------------------------------------------------------------------------- 1 | [ 2 | {"text/html", ["html", "htm", "shtml"]}, 3 | 4 | {"text/css", ["css"]}, 5 | {"text/xml", ["xml", "rss"]}, 6 | {"image/gif", ["gif"]}, 7 | {"image/jpeg", ["jpeg", "jpg"]}, 8 | {"application/x-javascript", ["js"]}, 9 | {"application/atom+xml", ["atom"]}, 10 | 11 | {"text/mathml", ["mml"]}, 12 | {"text/plain", ["txt"]}, 13 | {"text/vnd.sun.j2me.app-descriptor", ["jad"]}, 14 | {"text/vnd.wap.wml", ["wml"]}, 15 | {"text/x-component", ["htc"]}, 16 | 17 | {"image/png", ["png"]}, 18 | {"image/tiff", ["tif", "tiff"]}, 19 | {"image/vnd.wap.wbmp", ["wbmp"]}, 20 | {"image/x-icon", ["ico"]}, 21 | {"image/x-jng", ["jng"]}, 22 | {"image/x-ms-bmp", ["bmp"]}, 23 | {"image/svg+xml", ["svg", "svgz"]}, 24 | 25 | {"application/java-archive", ["jar", "war", "ear"]}, 26 | {"application/json", ["json"]}, 27 | {"application/mac-binhex40", ["hqx"]}, 28 | {"application/msword", ["doc"]}, 29 | {"application/pdf", ["pdf"]}, 30 | {"application/postscript", ["ps", "eps", "ai"]}, 31 | {"application/rtf", ["rtf"]}, 32 | {"application/vnd.ms-excel", ["xls"]}, 33 | {"application/vnd.ms-powerpoint", ["ppt"]}, 34 | {"application/vnd.wap.wmlc", ["wmlc"]}, 35 | {"application/vnd.google-earth.kml+xml", ["kml"]}, 36 | {"application/vnd.google-earth.kmz", ["kmz"]}, 37 | {"application/x-7z-compressed", ["7z"]}, 38 | {"application/x-cocoa", ["cco"]}, 39 | {"application/x-java-archive-diff", ["jardiff"]}, 40 | {"application/x-java-jnlp-file", ["jnlp"]}, 41 | {"application/x-makeself", ["run"]}, 42 | {"application/x-perl", ["pl", "pm"]}, 43 | {"application/x-pilot", ["prc", "pdb"]}, 44 | {"application/x-rar-compressed", ["rar"]}, 45 | {"application/x-redhat-package-manager", ["rpm"]}, 46 | {"application/x-sea", ["sea"]}, 47 | {"application/x-shockwave-flash", ["swf"]}, 48 | {"application/x-stuffit", ["sit"]}, 49 | {"application/x-tcl", ["tcl", "tk"]}, 50 | {"application/x-x509-ca-cert", ["der", "pem", "crt"]}, 51 | {"application/x-xpinstall", ["xpi"]}, 52 | {"application/xhtml+xml", ["xhtml"]}, 53 | {"application/zip", ["zip"]}, 54 | 55 | {"application/octet-stream", ["bin", "exe", "dll"]}, 56 | {"application/octet-stream", ["deb"]}, 57 | {"application/octet-stream", ["dmg"]}, 58 | {"application/octet-stream", ["eot"]}, 59 | {"application/octet-stream", ["iso", "img"]}, 60 | {"application/octet-stream", ["msi", "msp", "msm"]}, 61 | {"application/ogg", ["ogx"]}, 62 | 63 | {"audio/midi", ["mid", "midi", "kar"]}, 64 | {"audio/mpeg", ["mpga", "mpega", "mp2", "mp3", "m4a"]}, 65 | {"audio/ogg", ["oga", "ogg", "spx"]}, 66 | {"audio/x-realaudio", ["ra"]}, 67 | {"audio/webm", ["weba"]}, 68 | 69 | {"video/3gpp", ["3gpp", "3gp"]}, 70 | {"video/mp4", ["mp4"]}, 71 | {"video/mpeg", ["mpeg", "mpg", "mpe"]}, 72 | {"video/ogg", ["ogv"]}, 73 | {"video/quicktime", ["mov"]}, 74 | {"video/webm", ["webm"]}, 75 | {"video/x-flv", ["flv"]}, 76 | {"video/x-mng", ["mng"]}, 77 | {"video/x-ms-asf", ["asx", "asf"]}, 78 | {"video/x-ms-wmv", ["wmv"]}, 79 | {"video/x-msvideo", ["avi"]} 80 | ]. 81 | -------------------------------------------------------------------------------- /priv/skel/Makefile: -------------------------------------------------------------------------------- 1 | PROJECT = app_id 2 | DEPS = e2 psycho 3 | dep_e2 = https://github.com/gar1t/e2.git 4 | dep_psycho = https://github.com/gar1t/psycho.git 5 | 6 | include erlang.mk 7 | 8 | shell: all 9 | ERL_LIBS=deps; erl -pa ebin -s psycho_reloader -s app_id 10 | -------------------------------------------------------------------------------- /priv/skel/erlang.mk: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, Loïc Hoguin 2 | # 3 | # Permission to use, copy, modify, and/or distribute this software for any 4 | # purpose with or without fee is hereby granted, provided that the above 5 | # copyright notice and this permission notice appear in all copies. 6 | # 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | 15 | # Project. 16 | 17 | PROJECT ?= $(notdir $(CURDIR)) 18 | 19 | # Packages database file. 20 | 21 | PKG_FILE ?= $(CURDIR)/.erlang.mk.packages.v1 22 | export PKG_FILE 23 | 24 | PKG_FILE_URL ?= https://raw.github.com/extend/erlang.mk/master/packages.v1.tsv 25 | 26 | define get_pkg_file 27 | wget --no-check-certificate -O $(PKG_FILE) $(PKG_FILE_URL) || rm $(PKG_FILE) 28 | endef 29 | 30 | # Verbosity and tweaks. 31 | 32 | V ?= 0 33 | 34 | appsrc_verbose_0 = @echo " APP " $(PROJECT).app.src; 35 | appsrc_verbose = $(appsrc_verbose_$(V)) 36 | 37 | erlc_verbose_0 = @echo " ERLC " $(filter %.erl %.core,$(?F)); 38 | erlc_verbose = $(erlc_verbose_$(V)) 39 | 40 | xyrl_verbose_0 = @echo " XYRL " $(filter %.xrl %.yrl,$(?F)); 41 | xyrl_verbose = $(xyrl_verbose_$(V)) 42 | 43 | dtl_verbose_0 = @echo " DTL " $(filter %.dtl,$(?F)); 44 | dtl_verbose = $(dtl_verbose_$(V)) 45 | 46 | gen_verbose_0 = @echo " GEN " $@; 47 | gen_verbose = $(gen_verbose_$(V)) 48 | 49 | .PHONY: rel clean-rel all clean-all app clean deps clean-deps \ 50 | docs clean-docs build-tests tests build-plt dialyze 51 | 52 | # Release. 53 | 54 | RELX_CONFIG ?= $(CURDIR)/relx.config 55 | 56 | ifneq ($(wildcard $(RELX_CONFIG)),) 57 | 58 | RELX ?= $(CURDIR)/relx 59 | export RELX 60 | 61 | RELX_URL ?= https://github.com/erlware/relx/releases/download/v0.5.2/relx 62 | RELX_OPTS ?= 63 | 64 | define get_relx 65 | wget -O $(RELX) $(RELX_URL) || rm $(RELX) 66 | chmod +x $(RELX) 67 | endef 68 | 69 | rel: clean-rel all $(RELX) 70 | @$(RELX) -c $(RELX_CONFIG) $(RELX_OPTS) 71 | 72 | $(RELX): 73 | @$(call get_relx) 74 | 75 | clean-rel: 76 | @rm -rf _rel 77 | 78 | endif 79 | 80 | # Deps directory. 81 | 82 | DEPS_DIR ?= $(CURDIR)/deps 83 | export DEPS_DIR 84 | 85 | REBAR_DEPS_DIR = $(DEPS_DIR) 86 | export REBAR_DEPS_DIR 87 | 88 | ALL_DEPS_DIRS = $(addprefix $(DEPS_DIR)/,$(DEPS)) 89 | ALL_TEST_DEPS_DIRS = $(addprefix $(DEPS_DIR)/,$(TEST_DEPS)) 90 | 91 | # Application. 92 | 93 | ifeq ($(filter $(DEPS_DIR),$(subst :, ,$(ERL_LIBS))),) 94 | ifeq ($(ERL_LIBS),) 95 | ERL_LIBS = $(DEPS_DIR) 96 | else 97 | ERL_LIBS := $(ERL_LIBS):$(DEPS_DIR) 98 | endif 99 | endif 100 | export ERL_LIBS 101 | 102 | ERLC_OPTS ?= -Werror +debug_info +warn_export_all +warn_export_vars \ 103 | +warn_shadow_vars +warn_obsolete_guard # +bin_opt_info +warn_missing_spec 104 | COMPILE_FIRST ?= 105 | COMPILE_FIRST_PATHS = $(addprefix src/,$(addsuffix .erl,$(COMPILE_FIRST))) 106 | 107 | all: deps app 108 | 109 | clean-all: clean clean-deps clean-docs 110 | $(gen_verbose) rm -rf .$(PROJECT).plt $(DEPS_DIR) logs 111 | 112 | app: ebin/$(PROJECT).app 113 | $(eval MODULES := $(shell find ebin -type f -name \*.beam \ 114 | | sed 's/ebin\///;s/\.beam/,/' | sed '$$s/.$$//')) 115 | $(appsrc_verbose) cat src/$(PROJECT).app.src \ 116 | | sed 's/{modules,[[:space:]]*\[\]}/{modules, \[$(MODULES)\]}/' \ 117 | > ebin/$(PROJECT).app 118 | 119 | define compile_erl 120 | $(erlc_verbose) erlc -v $(ERLC_OPTS) -o ebin/ \ 121 | -pa ebin/ -I include/ $(COMPILE_FIRST_PATHS) $(1) 122 | endef 123 | 124 | define compile_xyrl 125 | $(xyrl_verbose) erlc -v -o ebin/ $(1) 126 | $(xyrl_verbose) erlc $(ERLC_OPTS) -o ebin/ ebin/*.erl 127 | @rm ebin/*.erl 128 | endef 129 | 130 | define compile_dtl 131 | $(dtl_verbose) erl -noshell -pa ebin/ $(DEPS_DIR)/erlydtl/ebin/ -eval ' \ 132 | Compile = fun(F) -> \ 133 | Module = list_to_atom( \ 134 | string:to_lower(filename:basename(F, ".dtl")) ++ "_dtl"), \ 135 | erlydtl_compiler:compile(F, Module, [{out_dir, "ebin/"}]) \ 136 | end, \ 137 | _ = [Compile(F) || F <- string:tokens("$(1)", " ")], \ 138 | init:stop()' 139 | endef 140 | 141 | ebin/$(PROJECT).app: $(shell find src -type f -name \*.erl) \ 142 | $(shell find src -type f -name \*.core) \ 143 | $(shell find src -type f -name \*.xrl) \ 144 | $(shell find src -type f -name \*.yrl) \ 145 | $(shell find templates -type f -name \*.dtl 2>/dev/null) 146 | @mkdir -p ebin/ 147 | $(if $(strip $(filter %.erl %.core,$?)), \ 148 | $(call compile_erl,$(filter %.erl %.core,$?))) 149 | $(if $(strip $(filter %.xrl %.yrl,$?)), \ 150 | $(call compile_xyrl,$(filter %.xrl %.yrl,$?))) 151 | $(if $(strip $(filter %.dtl,$?)), \ 152 | $(call compile_dtl,$(filter %.dtl,$?))) 153 | 154 | clean: 155 | $(gen_verbose) rm -rf ebin/ test/*.beam erl_crash.dump 156 | 157 | # Dependencies. 158 | 159 | define get_dep 160 | @mkdir -p $(DEPS_DIR) 161 | ifeq (,$(findstring pkg://,$(word 1,$(dep_$(1))))) 162 | git clone -n -- $(word 1,$(dep_$(1))) $(DEPS_DIR)/$(1) 163 | else 164 | @if [ ! -f $(PKG_FILE) ]; then $(call get_pkg_file); fi 165 | git clone -n -- `awk 'BEGIN { FS = "\t" }; \ 166 | $$$$1 == "$(subst pkg://,,$(word 1,$(dep_$(1))))" { print $$$$2 }' \ 167 | $(PKG_FILE)` $(DEPS_DIR)/$(1) 168 | endif 169 | cd $(DEPS_DIR)/$(1) ; git checkout -q $(word 2,$(dep_$(1))) 170 | endef 171 | 172 | define dep_target 173 | $(DEPS_DIR)/$(1): 174 | $(call get_dep,$(1)) 175 | endef 176 | 177 | $(foreach dep,$(DEPS),$(eval $(call dep_target,$(dep)))) 178 | 179 | deps: $(ALL_DEPS_DIRS) 180 | @for dep in $(ALL_DEPS_DIRS) ; do \ 181 | if [ -f $$dep/Makefile ] ; then \ 182 | $(MAKE) -C $$dep ; \ 183 | else \ 184 | echo "include $(CURDIR)/erlang.mk" | $(MAKE) -f - -C $$dep ; \ 185 | fi ; \ 186 | done 187 | 188 | clean-deps: 189 | @for dep in $(ALL_DEPS_DIRS) ; do \ 190 | if [ -f $$dep/Makefile ] ; then \ 191 | $(MAKE) -C $$dep clean ; \ 192 | else \ 193 | echo "include $(CURDIR)/erlang.mk" | $(MAKE) -f - -C $$dep clean ; \ 194 | fi ; \ 195 | done 196 | 197 | # Documentation. 198 | 199 | EDOC_OPTS ?= 200 | 201 | docs: clean-docs 202 | $(gen_verbose) erl -noshell \ 203 | -eval 'edoc:application($(PROJECT), ".", [$(EDOC_OPTS)]), init:stop().' 204 | 205 | clean-docs: 206 | $(gen_verbose) rm -f doc/*.css doc/*.html doc/*.png doc/edoc-info 207 | 208 | # Tests. 209 | 210 | $(foreach dep,$(TEST_DEPS),$(eval $(call dep_target,$(dep)))) 211 | 212 | build-test-deps: $(ALL_TEST_DEPS_DIRS) 213 | @for dep in $(ALL_TEST_DEPS_DIRS) ; do $(MAKE) -C $$dep; done 214 | 215 | build-tests: build-test-deps 216 | $(gen_verbose) erlc -v $(ERLC_OPTS) -o test/ \ 217 | $(wildcard test/*.erl test/*/*.erl) -pa ebin/ 218 | 219 | CT_RUN = ct_run \ 220 | -no_auto_compile \ 221 | -noshell \ 222 | -pa $(realpath ebin) $(DEPS_DIR)/*/ebin \ 223 | -dir test \ 224 | -logdir logs 225 | # -cover test/cover.spec 226 | 227 | CT_SUITES ?= 228 | 229 | define test_target 230 | test_$(1): ERLC_OPTS += -DTEST=1 +'{parse_transform, eunit_autoexport}' 231 | test_$(1): clean deps app build-tests 232 | @if [ -d "test" ] ; \ 233 | then \ 234 | mkdir -p logs/ ; \ 235 | $(CT_RUN) -suite $(addsuffix _SUITE,$(1)) ; \ 236 | fi 237 | $(gen_verbose) rm -f test/*.beam 238 | endef 239 | 240 | $(foreach test,$(CT_SUITES),$(eval $(call test_target,$(test)))) 241 | 242 | tests: ERLC_OPTS += -DTEST=1 +'{parse_transform, eunit_autoexport}' 243 | tests: clean deps app build-tests 244 | @if [ -d "test" ] ; \ 245 | then \ 246 | mkdir -p logs/ ; \ 247 | $(CT_RUN) -suite $(addsuffix _SUITE,$(CT_SUITES)) ; \ 248 | fi 249 | $(gen_verbose) rm -f test/*.beam 250 | 251 | # Dialyzer. 252 | 253 | PLT_APPS ?= 254 | DIALYZER_OPTS ?= -Werror_handling -Wrace_conditions \ 255 | -Wunmatched_returns # -Wunderspecs 256 | 257 | build-plt: deps app 258 | @dialyzer --build_plt --output_plt .$(PROJECT).plt \ 259 | --apps erts kernel stdlib $(PLT_APPS) $(ALL_DEPS_DIRS) 260 | 261 | dialyze: 262 | @dialyzer --src src --plt .$(PROJECT).plt --no_native $(DIALYZER_OPTS) 263 | 264 | # Packages. 265 | 266 | $(PKG_FILE): 267 | @$(call get_pkg_file) 268 | 269 | pkg-list: $(PKG_FILE) 270 | @cat $(PKG_FILE) | awk 'BEGIN { FS = "\t" }; { print \ 271 | "Name:\t\t" $$1 "\n" \ 272 | "Repository:\t" $$2 "\n" \ 273 | "Website:\t" $$3 "\n" \ 274 | "Description:\t" $$4 "\n" }' 275 | 276 | ifdef q 277 | pkg-search: $(PKG_FILE) 278 | @cat $(PKG_FILE) | grep -i ${q} | awk 'BEGIN { FS = "\t" }; { print \ 279 | "Name:\t\t" $$1 "\n" \ 280 | "Repository:\t" $$2 "\n" \ 281 | "Website:\t" $$3 "\n" \ 282 | "Description:\t" $$4 "\n" }' 283 | else 284 | pkg-search: 285 | @echo "Usage: make pkg-search q=STRING" 286 | endif 287 | -------------------------------------------------------------------------------- /priv/skel/src/Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | cd ..; make 3 | 4 | %: 5 | cd ..; make $@ 6 | -------------------------------------------------------------------------------- /priv/skel/src/app_id.app.src: -------------------------------------------------------------------------------- 1 | %%% -*-erlang-*- 2 | {application, app_id, 3 | [{description, "Pyscho project"}, 4 | {vsn, "0.0.0"}, 5 | {modules, []}, 6 | {registered, []}, 7 | {applications, [kernel, stdlib]}, 8 | {mod, {e2_application, [app_id_app]}}, 9 | {env, []} 10 | ]}. 11 | -------------------------------------------------------------------------------- /priv/skel/src/app_id.erl: -------------------------------------------------------------------------------- 1 | -module(app_id). 2 | 3 | -export([start/0, restart/0]). 4 | 5 | start() -> 6 | e2_application:start_with_dependencies(app_id). 7 | 8 | restart() -> 9 | application:stop(app_id), 10 | application:start(app_id), 11 | e2_log:info("app_id restarted"). 12 | -------------------------------------------------------------------------------- /priv/skel/src/app_id_app.erl: -------------------------------------------------------------------------------- 1 | -module(app_id_app). 2 | 3 | -behavior(e2_application). 4 | 5 | -export([init/0]). 6 | 7 | init() -> 8 | {ok, [app_id_http]}. 9 | -------------------------------------------------------------------------------- /priv/skel/src/app_id_http.erl: -------------------------------------------------------------------------------- 1 | -module(app_id_http). 2 | 3 | -export([start_link/0, app/1]). 4 | 5 | -define(PORT, 8080). 6 | 7 | start_link() -> 8 | psycho_server:start(?PORT, ?MODULE). 9 | 10 | app(_Env) -> 11 | {{200, "OK"}, [{"Content-Type", "text/plain"}], "Hello app_id"}. 12 | -------------------------------------------------------------------------------- /psycho-mkapp: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | usage() { 4 | echo "usage: psycho-mkapp APP_ID APP_DIR" 5 | exit 1 6 | } 7 | 8 | app_id=$1 9 | if [ "$app_id" = "" ]; then 10 | usage 1 11 | fi 12 | 13 | app_dir=$2 14 | if [ "$app_dir" = "" ]; then 15 | usage 1 16 | fi 17 | 18 | set -u 19 | 20 | app_dir() { 21 | mkdir -p "$app_dir" 22 | } 23 | 24 | project_skel() { 25 | cp -a priv/skel/* "$app_dir/" 26 | for file in `find "$app_dir" -type f`; do 27 | newname=`echo $file | sed "s/app_id/$app_id/"` 28 | if [ "$file" != "$newname" ]; then 29 | mv "$file" "$newname" 30 | fi 31 | sed -i "s/app_id/$app_id/" "$newname" 32 | done 33 | } 34 | 35 | app_dir 36 | project_skel 37 | 38 | echo "Created an empty psycho project in $app_dir" 39 | -------------------------------------------------------------------------------- /src/Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | cd ..; make 3 | 4 | %: 5 | cd ..; make $@ 6 | -------------------------------------------------------------------------------- /src/proc.erl: -------------------------------------------------------------------------------- 1 | -module(proc). 2 | 3 | -export([start/2, start/3, 4 | start_link/2, start_link/3, 5 | call/2, call/3, reply/2, cast/2]). 6 | 7 | -export([init/5]). 8 | 9 | -export([system_continue/3, system_terminate/4, system_code_change/4]). 10 | 11 | -export([behaviour_info/1]). 12 | 13 | behaviour_info(callbacks) -> [{handle_msg, 3}]. 14 | 15 | -record(state, {parent, mod, mod_state, debug}). 16 | -record(init, {starter, name}). 17 | 18 | -define(undef_fun(Mod, Fun), {undef, [{Mod, Fun, _, _}|_]}). 19 | 20 | %%%=================================================================== 21 | %%% Start 22 | %%%=================================================================== 23 | 24 | start(Module, Args) -> 25 | start(Module, Args, []). 26 | 27 | start(Module, Args, Options) -> 28 | start_impl(start, self(), self, Module, Args, Options). 29 | 30 | start_link(Module, Args) -> 31 | start_link(Module, Args, []). 32 | 33 | start_link(Module, Args, Options) -> 34 | start_impl(start_link, self(), self(), Module, Args, Options). 35 | 36 | start_impl(Start, Starter, Parent, Module, Args, Options) -> 37 | Timeout = timeout_option(Options), 38 | SpawnOpts = spawn_options(Options), 39 | InitArgs = [Starter, Parent, Module, Args, Options], 40 | proc_lib:Start(?MODULE, init, InitArgs, Timeout, SpawnOpts). 41 | 42 | timeout_option(Options) -> 43 | proplists:get_value(timeout, Options, infinity). 44 | 45 | spawn_options(Options) -> 46 | proplists:get_value(spawn_opts, Options, []). 47 | 48 | %%%=================================================================== 49 | %%% Init 50 | %%%=================================================================== 51 | 52 | init(Starter, MaybeParent, Mod, Args, Options) -> 53 | Parent = parent_or_self(MaybeParent), 54 | Name = name_option(Options, Mod), 55 | Debug = debug_options(Options), 56 | Init = #init{starter=Starter, name=Name}, 57 | State = #state{parent=Parent, mod=Mod, debug=Debug}, 58 | maybe_register(Init), 59 | handle_mod_init(mod_init(Mod, Args), Init, State). 60 | 61 | parent_or_self(self) -> self(); 62 | parent_or_self(Parent) -> Parent. 63 | 64 | name_option(Options, Mod) -> 65 | case proplists:get_bool(registered, Options) of 66 | true -> Mod; 67 | false -> proplists:get_value(registered, Options) 68 | end. 69 | 70 | debug_options(Options) -> 71 | sys:debug_options(proplists:get_value(debug, Options, [])). 72 | 73 | maybe_register(#init{name=undefined}) -> ok; 74 | maybe_register(#init{name=Name}) -> register(Name, self()). 75 | 76 | mod_init(Mod, Args) -> 77 | handle_undefined_init(catch Mod:init(Args), Mod, Args). 78 | 79 | handle_undefined_init({'EXIT', ?undef_fun(Mod, init)}, Mod, InitArgs) -> 80 | {ok, InitArgs}; 81 | handle_undefined_init(Result, _Mod, _InitArgs) -> 82 | Result. 83 | 84 | handle_mod_init({ok, ModState}, Init, State) -> 85 | init_ack({ok, self()}, Init), 86 | loop(set_mod_state(ModState, State)); 87 | handle_mod_init({ok, ModState, {first_msg, Msg}}, Init, State) -> 88 | init_ack({ok, self()}, Init), 89 | erlang:send(self(), Msg), 90 | loop(set_mod_state(ModState, State)); 91 | handle_mod_init(Other, Init, _State) -> 92 | AckRet = init_exit_ack_ret(Other), 93 | ExitReason = exit_reason(Other), 94 | init_exit(AckRet, ExitReason, Init). 95 | 96 | init_ack(Ret, #init{starter=Starter}) -> 97 | proc_lib:init_ack(Starter, Ret). 98 | 99 | set_mod_state(ModState, State) -> 100 | State#state{mod_state=ModState}. 101 | 102 | init_exit_ack_ret({stop, Reason}) -> {error, Reason}; 103 | init_exit_ack_ret(ignore) -> ignore; 104 | init_exit_ack_ret({'EXIT', Reason}) -> {error, Reason}; 105 | init_exit_ack_ret(Other) -> {error, {bad_return_value, Other}}. 106 | 107 | exit_reason({stop, Reason}) -> Reason; 108 | exit_reason(ignore) -> normal; 109 | exit_reason({'EXIT', Reason}) -> Reason; 110 | exit_reason(Other) -> {bad_return_value, Other}. 111 | 112 | init_exit(AckRet, Reason, Init) -> 113 | maybe_unregister(Init), 114 | init_ack(AckRet, Init), 115 | exit(Reason). 116 | 117 | maybe_unregister(#init{name=undefined}) -> ok; 118 | maybe_unregister(#init{name=Name}) -> unregister(Name). 119 | 120 | %%%=================================================================== 121 | %%% Call 122 | %%%=================================================================== 123 | 124 | call(Proc, Msg) -> 125 | call(Proc, Msg, infinity). 126 | 127 | call(Proc, Msg, Timeout) -> 128 | MRef = erlang:monitor(process, Proc), 129 | erlang:send(Proc, {'$call', {self(), MRef}, Msg}), 130 | receive 131 | {MRef, Reply} -> handle_call_reply(MRef, Reply); 132 | {'DOWN', MRef, _, _, Reason} -> exit(Reason) 133 | after 134 | Timeout -> handle_call_timeout(MRef) 135 | end. 136 | 137 | handle_call_reply(MRef, Reply) -> 138 | erlang:demonitor(MRef, [flush]), 139 | Reply. 140 | 141 | handle_call_timeout(MRef) -> 142 | erlang:demonitor(MRef), 143 | maybe_consume_down(MRef), 144 | exit(timeout). 145 | 146 | maybe_consume_down(MRef) -> 147 | receive 148 | {'DOWN', MRef, _, _, _} -> ok 149 | after 150 | 0 -> ok 151 | end. 152 | 153 | %%%=================================================================== 154 | %%% Reply 155 | %%%=================================================================== 156 | 157 | reply({Pid, Ref}, Reply) -> 158 | erlang:send(Pid, {Ref, Reply}). 159 | 160 | %%%=================================================================== 161 | %%% Cast 162 | %%%=================================================================== 163 | 164 | cast(Proc, Msg) -> 165 | erlang:send(Proc, {'$cast', Msg}). 166 | 167 | %%%=================================================================== 168 | %%% Loop 169 | %%%=================================================================== 170 | 171 | loop(State) -> 172 | #state{parent=Parent} = State, 173 | receive 174 | {system, From, Req} -> 175 | handle_system_msg(From, Req, State); 176 | {'EXIT', Parent, Reason} -> 177 | terminate(Reason, State); 178 | Msg -> 179 | handle_msg(Msg, State) 180 | end. 181 | 182 | %%%=================================================================== 183 | %%% System msg dispatch 184 | %%%=================================================================== 185 | 186 | handle_system_msg(From, Req, State) -> 187 | #state{parent=Parent, debug=Debug} = State, 188 | sys:handle_system_msg(Req, From, Parent, ?MODULE, Debug, State). 189 | 190 | %%%=================================================================== 191 | %%% Terminate 192 | %%%=================================================================== 193 | 194 | terminate(Reason, State) -> 195 | handle_mod_terminate(mod_terminate(Reason, State), Reason). 196 | 197 | mod_terminate(Reason, #state{mod=Mod, mod_state=ModState}) -> 198 | handle_undefined_terminate(catch Mod:terminate(Reason, ModState), Mod). 199 | 200 | handle_undefined_terminate({'EXIT', ?undef_fun(Mod, terminate)}, Mod) -> ok; 201 | handle_undefined_terminate(Other, _Mod) -> Other. 202 | 203 | handle_mod_terminate({'EXIT', Err}, _Reason) -> exit(Err); 204 | handle_mod_terminate(_Result, Reason) -> exit(Reason). 205 | 206 | %%%=================================================================== 207 | %%% Message dispatch 208 | %%%=================================================================== 209 | 210 | handle_msg({'$call', From, Msg}, State) -> 211 | handle_mod_call(mod_call(From, Msg, State), From, State); 212 | handle_msg({'$cast', Msg}, State) -> 213 | handle_mod_dispatch(mod_dispatch(Msg, State), State); 214 | handle_msg(Msg, State) -> 215 | handle_mod_dispatch(mod_dispatch(Msg, State), State). 216 | 217 | mod_call(From, Msg, #state{mod=Mod, mod_state=ModState}) -> 218 | Mod:handle_msg(Msg, From, ModState). 219 | 220 | handle_mod_call({reply, Reply, ModState}, From, State) -> 221 | reply(From, Reply), 222 | loop(set_mod_state(ModState, State)); 223 | handle_mod_call(Other, _From, State) -> 224 | handle_mod_dispatch(Other, State). 225 | 226 | mod_dispatch(Msg, #state{mod=Mod, mod_state=ModState}) -> 227 | Mod:handle_msg(Msg, noreply, ModState). 228 | 229 | handle_mod_dispatch({noreply, ModState}, State) -> 230 | loop(set_mod_state(ModState, State)); 231 | handle_mod_dispatch({stop, Reason}, State) -> 232 | terminate(Reason, State); 233 | handle_mod_dispatch({stop, Reason, ModState}, State) -> 234 | terminate(Reason, set_mod_state(ModState, State)); 235 | handle_mod_dispatch({next_msg, Msg, ModState}, State) -> 236 | erlang:send(self(), Msg), 237 | loop(set_mod_state(ModState, State)); 238 | handle_mod_dispatch(Other, State) -> 239 | terminate({bad_return_value, Other}, State). 240 | 241 | %%%=================================================================== 242 | %%% System callbacks 243 | %%%=================================================================== 244 | 245 | system_continue(_Parent, _Debug, State) -> 246 | loop(State). 247 | 248 | system_terminate(Reason, _Parent, _Debug, State) -> 249 | terminate(Reason, State). 250 | 251 | system_code_change(State, _Module, _OldVsn, _Extra) -> 252 | %% TODO 253 | {ok, State}. 254 | -------------------------------------------------------------------------------- /src/psycho.app.src: -------------------------------------------------------------------------------- 1 | %%% -*-erlang-*- 2 | {application, psycho, 3 | [{description, "Yep, another Erlang web serer!"}, 4 | {vsn, "0.1.0"}, 5 | {registered, []}, 6 | {applications, [kernel, stdlib]}, 7 | {modules, []}, 8 | {env, []} 9 | ]}. 10 | -------------------------------------------------------------------------------- /src/psycho.erl: -------------------------------------------------------------------------------- 1 | -module(psycho). 2 | 3 | -export([call_app/2, call_app_with_data/3, 4 | priv_dir/0, 5 | env/2, env/3, 6 | env_val/2, env_val/3, set_env/3, 7 | env_header/2, env_header/3, 8 | parsed_request_path/1]). 9 | 10 | call_app(M, Env) when is_atom(M) -> 11 | erlang:apply(M, app, [Env]); 12 | call_app({M, F}, Env) when is_atom(M) -> 13 | erlang:apply(M, F, [Env]); 14 | call_app({M, F, A}, Env) when is_atom(M) -> 15 | erlang:apply(M, F, A ++ [Env]); 16 | call_app(F, Env) when is_function(F) -> 17 | erlang:apply(F, [Env]); 18 | call_app({F, A}, Env) when is_function(F) -> 19 | erlang:apply(F, A ++ [Env]). 20 | 21 | call_app_with_data(M, Env, Data) when is_atom(M) -> 22 | erlang:apply(M, app, [Data, Env]); 23 | call_app_with_data({M, F}, Env, Data) when is_atom(M) -> 24 | erlang:apply(M, F, [Data, Env]); 25 | call_app_with_data({M, F, A}, Env, Data) when is_atom(M) -> 26 | erlang:apply(M, F, A ++ [Data, Env]); 27 | call_app_with_data(F, Env, Data) when is_function(F) -> 28 | erlang:apply(F, [Data, Env]); 29 | call_app_with_data({F, A}, Env, Data) when is_function(F) -> 30 | erlang:apply(F, A ++ [Data, Env]). 31 | 32 | priv_dir() -> 33 | priv_dir(code:which(?MODULE)). 34 | 35 | priv_dir(BeamFile) when is_list(BeamFile) -> 36 | filename:join([filename:dirname(BeamFile), "..", "priv"]); 37 | priv_dir(Other) -> 38 | error({psycho_priv_dir, Other}). 39 | 40 | env(Name, Env) -> 41 | env(Name, Env, undefined). 42 | 43 | env(Name, Env, Default) -> 44 | case lists:keyfind(Name, 1, Env) of 45 | {_, Value} -> Value; 46 | _ -> Default 47 | end. 48 | 49 | env_val(Name, Env) -> env(Name, Env). 50 | 51 | env_val(Name, Env, Default) -> env(Name, Env, Default). 52 | 53 | set_env(Name, Value, Env) -> 54 | [{Name, Value}|lists:keydelete(Name, 1, Env)]. 55 | 56 | env_header(Name, Env) -> 57 | env_header(Name, Env, undefined). 58 | 59 | env_header(Name, Env, Default) -> 60 | Headers = env_val(http_headers, Env, []), 61 | case lists:keyfind(Name, 1, Headers) of 62 | {_, Value} -> Value; 63 | _ -> Default 64 | end. 65 | 66 | parsed_request_path(Env) -> 67 | handle_parsed_request_path(env_val(parsed_request_path, Env), Env). 68 | 69 | handle_parsed_request_path(undefined, Env) -> 70 | psycho_util:parse_request_path(env_val(request_path, Env)); 71 | handle_parsed_request_path(Parsed, _Env) -> 72 | Parsed. 73 | -------------------------------------------------------------------------------- /src/psycho_auth.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_auth). 2 | 3 | -export([basic_auth_app/3, basic_auth_app/4]). 4 | 5 | -record(bauth, {realm, users, next_app, not_auth_app}). 6 | 7 | basic_auth_app(Realm, Users, NextApp) -> 8 | basic_auth_app(Realm, Users, NextApp, not_authorized_app(Realm)). 9 | 10 | not_authorized_app(Realm) -> 11 | fun(_Env) -> not_authorized(Realm) end. 12 | 13 | not_authorized(Realm) -> 14 | Headers = 15 | [{"WWW-Authenticate", ["Basic realm=", Realm]}, 16 | {"Content-Type", "text/plain"}], 17 | {{401, "Not Authorized"}, Headers, "Not Authorized\n"}. 18 | 19 | 20 | basic_auth_app(Realm, Users, NextApp, NotAuthApp) -> 21 | BAuth = #bauth{realm=Realm, 22 | users=Users, 23 | next_app=NextApp, 24 | not_auth_app=NotAuthApp}, 25 | fun(Env) -> basic_auth(BAuth, Env) end. 26 | 27 | basic_auth(BAuth, Env) -> 28 | AuthResult = basic_authenticate(basic_creds(Env), BAuth), 29 | handle_basic_authenticate(AuthResult, BAuth, Env). 30 | 31 | basic_creds(Env) -> 32 | basic_creds_from_auth_header(psycho:env_header("Authorization", Env)). 33 | 34 | basic_creds_from_auth_header("Basic " ++ Base64Encoded) -> 35 | parse_decoded_auth_header(base64:decode(Base64Encoded)); 36 | basic_creds_from_auth_header(undefined) -> 37 | undefined. 38 | 39 | parse_decoded_auth_header(Header) -> 40 | [User|PwdParts] = binary:split(Header, <<":">>), 41 | {User, iolist_to_binary(PwdParts)}. 42 | 43 | basic_authenticate(undefined, _) -> fail; 44 | basic_authenticate({User, Pwd}, #bauth{users=Users}) -> 45 | case lists:keyfind(User, 1, Users) of 46 | {_, Pwd} -> {pass, User}; 47 | _ -> fail 48 | end. 49 | 50 | handle_basic_authenticate(fail, BAuth, Env) -> 51 | handle_basic_authenticate_fail(BAuth, Env); 52 | handle_basic_authenticate({pass, User}, BAuth, Env) -> 53 | handle_basic_authenticate_pass(User, BAuth, Env). 54 | 55 | handle_basic_authenticate_fail(#bauth{not_auth_app=NotAuth}, Env) -> 56 | NotAuth(Env). 57 | 58 | handle_basic_authenticate_pass(User, #bauth{next_app=NextApp}, Env) -> 59 | psycho:call_app(NextApp, [{remote_user, User}|Env]). 60 | -------------------------------------------------------------------------------- /src/psycho_datetime.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_datetime). 2 | 3 | -export([rfc1123/0, 4 | rfc1123/1, 5 | iso8601/1, 6 | iso8601/2]). 7 | 8 | rfc1123() -> 9 | rfc1123(calendar:universal_time()). 10 | 11 | rfc1123({{YYYY, MM, DD}, {Hour, Min, Sec}}) -> 12 | DayNumber = calendar:day_of_the_week({YYYY, MM, DD}), 13 | io_lib:format( 14 | "~s, ~2.2.0w ~3.s ~4.4.0w ~2.2.0w:~2.2.0w:~2.2.0w GMT", 15 | [day(DayNumber), DD, month(MM), YYYY, Hour, Min, Sec]). 16 | 17 | 18 | day(1) -> "Mon"; 19 | day(2) -> "Tue"; 20 | day(3) -> "Wed"; 21 | day(4) -> "Thu"; 22 | day(5) -> "Fri"; 23 | day(6) -> "Sat"; 24 | day(7) -> "Sun". 25 | 26 | month(1) -> "Jan"; 27 | month(2) -> "Feb"; 28 | month(3) -> "Mar"; 29 | month(4) -> "Apr"; 30 | month(5) -> "May"; 31 | month(6) -> "Jun"; 32 | month(7) -> "Jul"; 33 | month(8) -> "Aug"; 34 | month(9) -> "Sep"; 35 | month(10) -> "Oct"; 36 | month(11) -> "Nov"; 37 | month(12) -> "Dec". 38 | 39 | iso8601({Mega, Sec, Micro}) -> 40 | DateTime = calendar:now_to_datetime({Mega, Sec, Micro}), 41 | iso8601(DateTime, Micro); 42 | iso8601({{Year, Month, Day}, {Hour, Min, Sec}}) -> 43 | iolist_to_binary( 44 | io_lib:format( 45 | "~4.10.0B-~2.10.0B-~2.10.0B " 46 | "~2.10.0B:~2.10.0B:~2.10.0B", 47 | [Year, Month, Day, Hour, Min, Sec])); 48 | iso8601({Mega, Sec}) -> 49 | DateTime = calendar:now_to_datetime({Mega, Sec, 0}), 50 | iso8601(DateTime); 51 | iso8601(TimestampMs) when is_integer(TimestampMs) -> 52 | iso8601(timestamp_to_now(TimestampMs)); 53 | iso8601(undefined) -> <<"">>. 54 | 55 | iso8601({{Year, Month, Day}, {Hour, Min, Sec}}, Micro) -> 56 | iolist_to_binary( 57 | io_lib:format( 58 | "~4.10.0B-~2.10.0B-~2.10.0BT" 59 | "~2.10.0B:~2.10.0B:~2.10.0B.~3.10.0B", 60 | [Year, Month, Day, Hour, Min, Sec, trunc(Micro / 1000)])). 61 | 62 | timestamp_to_now(EpochMs) -> 63 | Mega = EpochMs div 1000000000, 64 | Sec = (EpochMs - Mega * 1000000000) div 1000, 65 | Micro = (EpochMs - (Mega * 1000000000) - (Sec * 1000)) * 1000, 66 | {Mega, Sec, Micro}. 67 | -------------------------------------------------------------------------------- /src/psycho_debug.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_debug). 2 | 3 | -export([trace_module/1, 4 | trace_module/2, 5 | trace_function/2, 6 | trace_function/3, 7 | trace_messages/1, 8 | trace_messages/2, 9 | stop_tracing/0]). 10 | 11 | -define(OPTIONS_SCHEMA, 12 | [{file, [{default, undefined}, {type, string}]}, 13 | {pattern, [{default, undefined}]}, 14 | {no_return, [{default, false}, {type, boolean}]}, 15 | {send_only, [{default, false}, {type, boolean}]}, 16 | {receive_only, [{default, false}, {type, boolean}]}, 17 | {process_events, [{default, false}, {type, boolean}]}]). 18 | 19 | %%%=================================================================== 20 | %%% API 21 | %%%=================================================================== 22 | 23 | trace_module(Module) -> 24 | trace_module(Module, []). 25 | 26 | trace_module(Module, Options) -> 27 | Opts = psycho_opt:validate(Options, ?OPTIONS_SCHEMA), 28 | start_tracer(Opts), 29 | dbg_tpl(Module, Opts), 30 | dbg_calls(). 31 | 32 | trace_function(Module, Function) -> 33 | trace_function(Module, Function, []). 34 | 35 | trace_function(Module, Function, Options) -> 36 | Opts = psycho_opt:validate(Options, ?OPTIONS_SCHEMA), 37 | start_tracer(Opts), 38 | dbg_tpl(Module, Function, Opts), 39 | dbg_calls(). 40 | 41 | trace_messages(Process) -> 42 | trace_messages(Process, []). 43 | 44 | trace_messages(Process, Options) -> 45 | Opts = psycho_opt:validate(Options, ?OPTIONS_SCHEMA), 46 | start_tracer(Opts), 47 | dbg_messages(Process, Opts). 48 | 49 | stop_tracing() -> 50 | dbg:stop_clear(). 51 | 52 | %%%=================================================================== 53 | %%% dbg wrappers 54 | %%%=================================================================== 55 | 56 | dbg_tpl(Module, Opts) -> 57 | handle_dbg_tpl(dbg:tpl(Module, match_spec(Opts))). 58 | 59 | dbg_tpl(Module, {Function, Arity}, Opts) -> 60 | handle_dbg_tpl(dbg:tpl(Module, Function, Arity, match_spec(Opts))); 61 | dbg_tpl(Module, Function, Opts) -> 62 | handle_dbg_tpl(dbg:tpl(Module, Function, match_spec(Opts))). 63 | 64 | handle_dbg_tpl({ok, _}) -> ok; 65 | handle_dbg_tpl({error, Err}) -> error(Err). 66 | 67 | dbg_calls() -> 68 | handle_dbg_p(dbg:p(all, c)). 69 | 70 | handle_dbg_p({ok, _}) -> ok; 71 | handle_dbg_p({error, Err}) -> error(Err). 72 | 73 | dbg_messages(Process, Opts) -> 74 | handle_dbg_p(dbg:p(Process, trace_flags(Opts))). 75 | 76 | trace_flags(Opts) -> 77 | message_flags(Opts, process_event_flags(Opts, [])). 78 | 79 | message_flags(Opts, Acc) -> 80 | case psycho_opt:value(send_only, Opts) of 81 | true -> [s|Acc]; 82 | false -> 83 | case psycho_opt:value(receive_only, Opts) of 84 | true -> [r|Acc]; 85 | false -> [m|Acc] 86 | end 87 | end. 88 | 89 | process_event_flags(Opts, Acc) -> 90 | case psycho_opt:value(process_events, Opts) of 91 | true -> [p|Acc]; 92 | false -> Acc 93 | end. 94 | 95 | %%%=================================================================== 96 | %%% tracer 97 | %%%=================================================================== 98 | 99 | start_tracer(Opts) -> 100 | start_dbg(), 101 | handle_tracer(dbg:tracer(process, tracer(Opts))). 102 | 103 | tracer(Opts) -> 104 | Pattern = pattern_match_spec(psycho_opt:value(pattern, Opts)), 105 | Out = trace_device(psycho_opt:value(file, Opts)), 106 | {fun(Msg, []) -> maybe_trace(Msg, Pattern, Out), [] end, []}. 107 | 108 | pattern_match_spec(undefined) -> undefined; 109 | pattern_match_spec(Pattern) -> 110 | ets:match_spec_compile([{Pattern, [], ['$_']}]). 111 | 112 | trace_device(undefined) -> standard_io; 113 | trace_device(File) when is_list(File) -> 114 | handle_file_open(file:open(File, [append])). 115 | 116 | handle_file_open({ok, Out}) -> Out; 117 | handle_file_open({error, Err}) -> error({trace_file, Err}). 118 | 119 | handle_tracer({ok, _Pid}) -> ok; 120 | handle_tracer({error, already_started}) -> ok. 121 | 122 | start_dbg() -> 123 | handle_dbg_start(catch(dbg:start())). 124 | 125 | handle_dbg_start({ok, _Pid}) -> ok; 126 | handle_dbg_start({'EXIT', {{case_clause, Pid}, _}}) 127 | when is_pid(Pid) -> ok. 128 | 129 | maybe_trace(Msg, undefined, Out) -> 130 | trace(Msg, Out); 131 | maybe_trace({_, _, return_from, _, _}=Msg, _Pattern, Out) -> 132 | trace(Msg, Out); 133 | maybe_trace(Msg, Pattern, Out) -> 134 | handle_pattern_match(apply_pattern(Pattern, Msg), Msg, Out). 135 | 136 | apply_pattern(Pattern, Msg) -> 137 | ets:match_spec_run([msg_content(Msg)], Pattern). 138 | 139 | msg_content({trace, _, call, {_, _, Args}}) -> Args; 140 | msg_content({trace, _, return_from, _, Val}) -> Val; 141 | msg_content({trace, _, send, Msg, _}) -> Msg; 142 | msg_content({trace, _, 'receive', Msg}) -> Msg; 143 | msg_content(Other) -> Other. 144 | 145 | handle_pattern_match([_], Msg, Out) -> trace(Msg, Out); 146 | handle_pattern_match([], _Msg, _Out) -> not_traced. 147 | 148 | trace(Msg, Out) -> 149 | {Format, Data} = format_msg(Msg), 150 | io:format(Out, Format, Data). 151 | 152 | format_msg({trace, Pid, call, {M, F, A}}) -> 153 | {"~n=TRACE CALL==== ~s ===~n~p -> ~s:~s/~p~n~p~n", 154 | [timestamp(), Pid, M, F, length(A), A]}; 155 | format_msg({trace, Pid, return_from, {M, F, Arity}, Val}) -> 156 | {"~n=TRACE RETURN==== ~s ===~n~p <- ~s:~s/~p~n~p~n", 157 | [timestamp(), Pid, M, F, Arity, Val]}; 158 | format_msg({trace, Pid, send, Msg, Dest}) -> 159 | {"~n=TRACE SEND==== ~s ===~n~p -> ~p~n~p~n", 160 | [timestamp(), Pid, Dest, Msg]}; 161 | format_msg({trace, Pid, 'receive', Msg}) -> 162 | {"~n=TRACE RECEIVE==== ~s ===~n~p~n~p~n", [timestamp(), Pid, Msg]}; 163 | format_msg(Other) -> 164 | HR = hr(), 165 | {"~s~nTRACE:~n~s~n ~p~n~n", [HR, HR, Other]}. 166 | 167 | timestamp() -> 168 | {{Y, M, D}, {H, Min, S}} = erlang:localtime(), 169 | io_lib:format("~p-~s-~p::~p:~p:~p", [D, month(M), Y, H, Min, S]). 170 | 171 | month(1) -> "Jan"; 172 | month(2) -> "Feb"; 173 | month(3) -> "Mar"; 174 | month(4) -> "Apr"; 175 | month(5) -> "May"; 176 | month(6) -> "Jun"; 177 | month(7) -> "Jul"; 178 | month(8) -> "Aug"; 179 | month(9) -> "Sep"; 180 | month(10) -> "Oct"; 181 | month(11) -> "Nov"; 182 | month(12) -> "Dec". 183 | 184 | hr() -> 185 | case io:columns() of 186 | {ok, N} -> binary:copy(<<"-">>, N - 2); 187 | {error, enotsup} -> binary:copy(<<"-">>, 78) 188 | end. 189 | 190 | %%%=================================================================== 191 | %%% Match spec support 192 | %%%=================================================================== 193 | 194 | match_spec(Opts) -> 195 | case psycho_opt:value(no_return, Opts) of 196 | true -> 197 | [{'_', [], []}]; 198 | false -> 199 | [{'_', [], [{return_trace}]}] 200 | end. 201 | -------------------------------------------------------------------------------- /src/psycho_devmode.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_devmode). 2 | 3 | -export([start/0]). 4 | 5 | start() -> 6 | application:start(sasl), 7 | psycho_reloader:start(). 8 | -------------------------------------------------------------------------------- /src/psycho_erlydtl.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_erlydtl). 2 | 3 | -export([compile_priv_dir/3, compile_priv_dir/4, render/2, render/3]). 4 | 5 | compile_priv_dir(AppMod, TemplateMod, Options) -> 6 | erlydtl:compile_dir(priv_dir(AppMod), TemplateMod, Options). 7 | 8 | compile_priv_dir(AppMod, Subdir, TemplateMod, Options) -> 9 | erlydtl:compile_dir(priv_dir(AppMod, Subdir), TemplateMod, Options). 10 | 11 | priv_dir(Mod) -> 12 | filename:join(app_dir(Mod), "priv"). 13 | 14 | priv_dir(Mod, Subdir) -> 15 | filename:join(priv_dir(Mod, Subdir)). 16 | 17 | app_dir(Mod) -> 18 | handle_mod_beam_app_dir(code:which(Mod), Mod). 19 | 20 | handle_mod_beam_app_dir(non_existing, Mod) -> 21 | error({non_existing_module, Mod}); 22 | handle_mod_beam_app_dir(BeamPath, _Mod) -> 23 | EbinDir = filename:dirname(BeamPath), 24 | filename:dirname(EbinDir). 25 | 26 | render(Template, Vars) -> 27 | TemplateMod = template_mod(Template), 28 | handle_template_compile( 29 | erlydtl:compile(Template, TemplateMod), TemplateMod, Vars). 30 | 31 | render(AppMod, Template, Vars) -> 32 | render(filename:join(app_dir(AppMod), Template), Vars). 33 | 34 | template_mod(Template) -> 35 | Hash = erlang:phash2(Template, 100000), 36 | list_to_atom("dtl_" ++ integer_to_list(Hash)). 37 | 38 | handle_template_compile(ok, Mod, Vars) -> 39 | handle_template_render(Mod:render(Vars), Mod, Vars); 40 | handle_template_compile({error, Err}, _Mod, _Vars) -> 41 | error({erlydtl_compile, Err}). 42 | 43 | handle_template_render({ok, Bin}, _Mod, _Vars) -> Bin; 44 | handle_template_render({error, Err}, Mod, Vars) -> 45 | error({erlydtl_render, Err, Mod, Vars}). 46 | -------------------------------------------------------------------------------- /src/psycho_handler.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_handler). 2 | 3 | -behavior(proc). 4 | 5 | -export([start_link/2]). 6 | 7 | -export([init/1, handle_msg/3]). 8 | 9 | -include("http_status.hrl"). 10 | 11 | -record(state, {sock, tag_http, tag_close, tag_error, app, 12 | client_ver, env, req_headers, req_content_len, recv_len, 13 | resp_status, resp_headers, resp_header_names, resp_body, 14 | resp_chunked, close}). 15 | 16 | -define(dtoc(D), D + 48). 17 | -define(period, 46). 18 | -define(server, "psycho"). 19 | -define(CRLF, "\r\n"). 20 | 21 | -define(is_string(X), is_list(X) orelse is_binary(X)). 22 | -define(is_resp_body_binary(S), is_binary(S#state.resp_body)). 23 | 24 | -define(IDLE_TIMEOUT, 300000). 25 | -define(DEFAULT_RECV_LEN, 32768). 26 | 27 | %%%=================================================================== 28 | %%% Start / init 29 | %%%=================================================================== 30 | 31 | start_link(Sock, App) -> 32 | proc:start_link(?MODULE, [Sock, App]). 33 | 34 | init([Sock, App]) -> 35 | {ok, init_state(Sock, App)}. 36 | 37 | init_state(Sock, App) -> 38 | init_socket(Sock), 39 | {Http, Close, Error} = psycho_socket:tags(Sock), 40 | Env = init_env(psycho_socket:peername(Sock)), 41 | #state{sock=Sock, 42 | tag_http=Http, 43 | tag_close=Close, 44 | tag_error=Error, 45 | app=App, 46 | client_ver=undefined, 47 | env=Env, 48 | req_headers=[], 49 | req_content_len=undefined, 50 | recv_len=0, 51 | resp_status=undefined, 52 | resp_headers=[], 53 | close=undefined}. 54 | 55 | init_socket(Sock) -> 56 | ok = psycho_socket:setopts(Sock, [{nodelay, true}]). 57 | 58 | init_env({ok, {Ip, Port}}) -> 59 | [{peer_ip, Ip}, 60 | {peer_port, Port}]; 61 | init_env(_) -> 62 | []. 63 | 64 | %%%=================================================================== 65 | %%% Process messages / HTTP request state handling 66 | %%%=================================================================== 67 | 68 | handle_msg({Http, _, {http_request, Method, Path, Ver}}, _From, 69 | #state{tag_http=Http} = State) -> 70 | set_socket_active_once(State), 71 | {noreply, set_request(Method, Path, Ver, State)}; 72 | handle_msg({Http, _, {http_header, _, Name, _, Value}}, _From, 73 | #state{tag_http=Http} = State) -> 74 | set_socket_active_once(State), 75 | {noreply, add_req_header(Name, Value, State)}; 76 | handle_msg({Http, _, http_eoh}, _From, #state{tag_http=Http} = State) -> 77 | set_socket_raw_passive(State), 78 | dispatch_to_app(finalize_request(State)); 79 | handle_msg({Http, _, {http_error, Error}}, _From, #state{tag_http=Http}) -> 80 | {stop, {http_error, Error}}; 81 | handle_msg({Close, _}, _From, #state{tag_close=Close}) -> 82 | {stop, normal}; 83 | handle_msg({Error, _, Reason}, _From, #state{tag_error=Error}) -> 84 | {stop, {tcp_error, Reason}}. 85 | 86 | set_socket_active_once(#state{sock=S}) -> 87 | ok = psycho_socket:setopts(S, [{active, once}]). 88 | 89 | %%%=================================================================== 90 | %%% Request init / pre app dispatch 91 | %%%=================================================================== 92 | 93 | set_request(Method, Path, Ver, #state{env = Env} = State) -> 94 | State#state{ 95 | env=update_env(Method, Path, Ver, Env), 96 | client_ver=Ver}. 97 | 98 | update_env(Method, Path, Ver, Env) -> 99 | [{request_method, request_method(Method)}, 100 | {request_path, request_path(Path)}, 101 | {request_protocol, request_protocol(Ver)} | Env]. 102 | 103 | request_method(M) when is_atom(M) -> atom_to_list(M); 104 | request_method(M) -> M. 105 | 106 | request_path({abs_path, Path}) -> Path. 107 | 108 | request_protocol({M, N}) -> 109 | lists:flatten(["HTTP/", ?dtoc(M), ?period, ?dtoc(N)]). 110 | 111 | add_req_header('Content-Type', Value, State) -> 112 | Special = {content_type, Value}, 113 | add_header({"Content-Type", Value}, add_env(Special, State)); 114 | add_req_header('Content-Length', Value, State) -> 115 | Special = {content_length, content_length(Value)}, 116 | add_header({"Content-Length", Value}, add_env(Special, State)); 117 | add_req_header(Name, Value, State) -> 118 | add_header({header_name(Name), Value}, State). 119 | 120 | add_env(Val, #state{env=Env}=S) -> 121 | S#state{env=[Val|Env]}. 122 | 123 | header_name(N) when is_atom(N) -> atom_to_list(N); 124 | header_name(N) -> N. 125 | 126 | add_header(H, #state{req_headers=Hs}=S) -> 127 | S#state{req_headers=[H|Hs]}. 128 | 129 | content_length(L) -> list_to_integer(L). 130 | 131 | set_socket_raw_passive(#state{sock=Sock}) -> 132 | ok = psycho_socket:setopts(Sock, [{packet, raw}, {active, false}]). 133 | 134 | finalize_request(State) -> 135 | apply_state_transforms( 136 | [fun set_persistent_connection/1, 137 | fun set_req_content_len/1, 138 | fun finalize_env/1], 139 | State). 140 | 141 | apply_state_transforms([T|Rest], State) -> 142 | apply_state_transforms(Rest, T(State)); 143 | apply_state_transforms([], State) -> State. 144 | 145 | set_persistent_connection(#state{client_ver=Ver}=S) -> 146 | Connection = req_header("Connection", S), 147 | set_persistent_connection(Ver, Connection, S). 148 | 149 | req_header(Name, #state{req_headers=Headers}) -> 150 | case lists:keyfind(Name, 1, Headers) of 151 | {_, Val} -> Val; 152 | false -> undefined 153 | end. 154 | 155 | set_persistent_connection({1, 1}, "close", S) -> set_close(true, S); 156 | set_persistent_connection({1, 1}, _, S) -> set_close(false, S); 157 | set_persistent_connection({1, 0}, "Keep-Alive", S) -> set_close(false, S); 158 | set_persistent_connection({1, 0}, _, S) -> set_close(true, S). 159 | 160 | set_close(Close, S) -> 161 | S#state{close=Close}. 162 | 163 | set_req_content_len(S) -> 164 | S#state{req_content_len=env_val(content_length, S)}. 165 | 166 | env_val(Name, #state{env=Env}) -> 167 | psycho:env_val(Name, Env). 168 | 169 | finalize_env(#state{env=Env, req_headers=Headers}=S) -> 170 | S#state{env=[{http_headers, Headers}|Env]}. 171 | 172 | %%%=================================================================== 173 | %%% App dispatch 174 | %%%=================================================================== 175 | 176 | dispatch_to_app(#state{app=App, env=Env}=State) -> 177 | handle_app_result(catch psycho:call_app(App, Env), State). 178 | 179 | handle_app_result({{I, _}=Status, Headers, Body}, State) when is_integer(I) -> 180 | respond({Status, Headers, Body}, State); 181 | handle_app_result({{I, _}=Status, Headers}, State) when is_integer(I) -> 182 | respond({Status, Headers, []}, State); 183 | handle_app_result({recv_body, App, Env}, State) -> 184 | handle_app_recv_body(App, [], setenv(Env, State)); 185 | handle_app_result({recv_body, App, Env, Options}, State) -> 186 | handle_app_recv_body(App, Options, setenv(Env, State)); 187 | handle_app_result({recv_form_data, App, Env}, State) -> 188 | handle_app_recv_form_data(App, [], setenv(Env, State)); 189 | handle_app_result({recv_form_data, App, Env, Options}, State) -> 190 | handle_app_recv_form_data(App, Options, setenv(Env, State)); 191 | handle_app_result({'EXIT', Err}, State) -> 192 | respond(internal_error(), State), 193 | error({app_error, Err}); 194 | handle_app_result(Other, State) -> 195 | respond(internal_error(), State), 196 | error({bad_return_value, Other}). 197 | 198 | setenv(Env, S) -> S#state{env=Env}. 199 | 200 | %%%=================================================================== 201 | %%% recv_body support 202 | %%%=================================================================== 203 | 204 | handle_app_recv_body(App, Options, State) -> 205 | Length = proplists:get_value(recv_length, Options, ?DEFAULT_RECV_LEN), 206 | Timeout = proplists:get_value(recv_timeout, Options, ?IDLE_TIMEOUT), 207 | maybe_send_continue(State), 208 | Received = safe_recv(Length, Timeout, State), 209 | handle_recv_body(Received, App, State). 210 | 211 | handle_recv_body({ok, Body}, App, #state{env=Env}=State) -> 212 | AppResult = (catch psycho:call_app_with_data(App, Env, Body)), 213 | error_on_recv_after_eof(is_eof(Body), AppResult, App), 214 | handle_app_result(AppResult, increment_recv_len(Body, State)); 215 | handle_recv_body({error, Error}, _App, _State) -> 216 | {stop, {recv_error, Error}}. 217 | 218 | %%%=================================================================== 219 | %%% recv_form_data support 220 | %%%=================================================================== 221 | 222 | handle_app_recv_form_data(App, Options, State) -> 223 | ContentType = env_val(content_type, State), 224 | handle_app_recv_form_data(ContentType, App, Options, State). 225 | 226 | -define(URLENCODED, "application/x-www-form-urlencoded"). 227 | -define(MULTIPART, "multipart/form-data;"). 228 | 229 | handle_app_recv_form_data(?URLENCODED, App, Options, State) -> 230 | handle_app_recv_urlencoded_data(App, Options, State); 231 | handle_app_recv_form_data(?MULTIPART ++ TypeParams, App, Options, State) -> 232 | handle_app_recv_multipart(TypeParams, App, Options, State); 233 | handle_app_recv_form_data(ContentType, App, Options, State) -> 234 | handle_app_recv_unknown(ContentType, App, Options, State). 235 | 236 | %%%=================================================================== 237 | %%% urlencoded form data 238 | %%%=================================================================== 239 | 240 | handle_app_recv_urlencoded_data(App, Options, State) -> 241 | Timeout = proplists:get_value(recv_timeout, Options, ?IDLE_TIMEOUT), 242 | maybe_send_continue(State), 243 | Received = recv_remaining(Timeout, State), 244 | handle_app_recv_urlencoded_data_(Received, App, State). 245 | 246 | handle_app_recv_urlencoded_data_({ok, Encoded}, App, #state{env=Env}=State) -> 247 | Data = decode_urlencoded_form_data(Encoded), 248 | AppResult = (catch psycho:call_app_with_data(App, Env, {ok, Data})), 249 | error_on_recv_after_eof(AppResult, App), 250 | handle_app_result(AppResult, increment_recv_len(Encoded, State)); 251 | handle_app_recv_urlencoded_data_({error, Error}, _App, _State) -> 252 | {stop, {recv_error, Error}}. 253 | 254 | decode_urlencoded_form_data(Data) -> 255 | psycho_util:parse_query_string(Data). 256 | 257 | %%%=================================================================== 258 | %%% multipart form data 259 | %%%=================================================================== 260 | 261 | handle_app_recv_multipart(TypeParams, App, Options, State) -> 262 | maybe_send_continue(State), 263 | start_recv_multipart(TypeParams, App, Options, State). 264 | 265 | start_recv_multipart(TypeParams, App, Options, State) -> 266 | Length = proplists:get_value(recv_length, Options, ?DEFAULT_RECV_LEN), 267 | Timeout = proplists:get_value(recv_timeout, Options, ?IDLE_TIMEOUT), 268 | PartHandler = proplists:get_value(part_handler, Options), 269 | MP = new_multipart(TypeParams, PartHandler, State), 270 | Recv = safe_recv_fun(Length, Timeout), 271 | handle_app_recv_multipart(Recv(State), Recv, MP, App, State). 272 | 273 | new_multipart(TypeParams, PartHandler, #state{env=Env}=State) -> 274 | Boundary = boundary_param(TypeParams, State), 275 | psycho_multipart:new(Boundary, PartHandler, Env). 276 | 277 | -define(BOUNDARY_PATTERN, <<"[; +]boundary=(.*?)(;|$)">>). 278 | 279 | boundary_param(Str, State) -> 280 | handle_boundary_re( 281 | re:run(Str, ?BOUNDARY_PATTERN, [{capture, [1], binary}]), 282 | State). 283 | 284 | handle_boundary_re({match, [Boundary]}, _State) -> Boundary; 285 | handle_boundary_re(nomatch, State) -> 286 | respond(internal_error("Invalid multipart content type\n"), State). 287 | 288 | safe_recv_fun(Length, Timeout) -> 289 | fun(State) -> safe_recv(Length, Timeout, State) end. 290 | 291 | handle_app_recv_multipart({ok, <<>>}, _Recv, MP, App, State) -> 292 | handle_app_recv_multipart_finished(MP, App, State); 293 | handle_app_recv_multipart({ok, Data}, Recv, MP, App, State) -> 294 | handle_app_recv_multipart_data(Data, Recv, MP, App, State); 295 | handle_app_recv_multipart({error, Error}, _Recv, _MP, _App, _State) -> 296 | {stop, {recv_error, Error}}. 297 | 298 | handle_app_recv_multipart_finished(MP, App, State) -> 299 | Data = psycho_multipart:form_data(MP), 300 | Env = psycho_multipart:user_data(MP), 301 | AppResult = (catch psycho:call_app_with_data(App, Env, {ok, Data})), 302 | error_on_recv_after_eof(AppResult, App), 303 | handle_app_result(AppResult, State). 304 | 305 | handle_app_recv_multipart_data(Data, Recv, MP, App, State) -> 306 | handle_updated_multipart( 307 | psycho_multipart:data(Data, MP), 308 | Recv, App, increment_recv_len(Data, State)). 309 | 310 | handle_updated_multipart(MP, Recv, App, State) -> 311 | handle_app_recv_multipart(Recv(State), Recv, MP, App, State). 312 | 313 | %%%=================================================================== 314 | %%% unknown form data 315 | %%%=================================================================== 316 | 317 | handle_app_recv_unknown(ContentType, App, Options, State) -> 318 | Timeout = proplists:get_value(recv_timeout, Options, ?IDLE_TIMEOUT), 319 | maybe_send_continue(State), 320 | Received = recv_remaining(Timeout, State), 321 | handle_app_recv_unknown_(Received, ContentType, App, State). 322 | 323 | handle_app_recv_unknown_({ok, Body}, ContentType, App, #state{env=Env}=State) -> 324 | Err = {content_type, ContentType, Body}, 325 | AppResult = (catch psycho:call_app_with_data(App, Env, {error, Err})), 326 | error_on_recv_after_eof(AppResult, App), 327 | handle_app_result(AppResult, increment_recv_len(Body, State)); 328 | handle_app_recv_unknown_({error, Error}, _Type, _App, _State) -> 329 | {stop, {recv_error, Error}}. 330 | 331 | %%%=================================================================== 332 | %%% recv related general/shared functions 333 | %%%=================================================================== 334 | 335 | maybe_send_continue(#state{client_ver={1, 0}}) -> ok; 336 | maybe_send_continue(State) -> 337 | case expect_continue(State) of 338 | true -> send_continue(State); 339 | false -> ok 340 | end. 341 | 342 | expect_continue(State) -> 343 | req_header("Expect", State) == "100-continue". 344 | 345 | send_continue(#state{sock=Sock}) -> 346 | Line = ["HTTP/1.1 100 Continue", ?CRLF, ?CRLF], 347 | ok = psycho_socket:send(Sock, Line). 348 | 349 | safe_recv(Length, Timeout, State) -> 350 | recv(safe_recv_len(Length, State), Timeout, State). 351 | 352 | safe_recv_len(_Requested, #state{req_content_len=undefined}) -> 353 | 0; 354 | safe_recv_len(Requested, #state{req_content_len=Total, recv_len=Received}) -> 355 | min(Requested, Total - Received). 356 | 357 | recv(Length, Timeout, #state{sock=Sock}) when Length > 0 -> 358 | psycho_socket:recv(Sock, Length, Timeout); 359 | recv(_Length, _Timeout, _State) -> 360 | {ok, <<>>}. 361 | 362 | recv_remaining(Timeout, State) -> 363 | recv(remaining_len(State), Timeout, State). 364 | 365 | remaining_len(#state{req_content_len=undefined}) -> 0; 366 | remaining_len(#state{req_content_len=Total, recv_len=Received}) -> 367 | Total - Received. 368 | 369 | internal_error() -> 370 | internal_error("Server error\n"). 371 | 372 | internal_error(Msg) -> 373 | {?status_internal_server_error, [{"Content-Type", "text/plain"}], Msg}. 374 | 375 | is_eof(<<>>) -> true; 376 | is_eof(_) -> false. 377 | 378 | error_on_recv_after_eof(Result, App) -> 379 | error_on_recv_after_eof(true, Result, App). 380 | 381 | error_on_recv_after_eof(true, Result, App) -> 382 | error_on_recv(Result, App); 383 | error_on_recv_after_eof(false, _Result, _App) -> 384 | ok. 385 | 386 | error_on_recv({recv_body, _, _}, App) -> 387 | error({recv_body_after_eof, App}); 388 | error_on_recv({recv_body, _, _, _}, App) -> 389 | error({recv_body_after_eof, App}); 390 | error_on_recv({recv_form_data, _, _}, App) -> 391 | error({recv_form_data_after_eof, App}); 392 | error_on_recv({recv_form_data, _, _, _}, App) -> 393 | error({recv_form_data_after_eof, App}); 394 | error_on_recv(_, _App) -> ok. 395 | 396 | increment_recv_len(Data, #state{recv_len=Received}=S) -> 397 | S#state{recv_len=size(Data) + Received}. 398 | 399 | %%%=================================================================== 400 | %%% Response 401 | %%%=================================================================== 402 | 403 | respond({Status, Headers, Body}, State) -> 404 | respond(finalize_response(set_response(Status, Headers, Body, State))). 405 | 406 | set_response(Status, Headers, Body, S) -> 407 | S#state{ 408 | resp_status=validate_status(Status), 409 | resp_headers=Headers, 410 | resp_header_names=header_names(Headers), 411 | resp_body=Body}. 412 | 413 | validate_status({Code, Desc}=Status) 414 | when is_integer(Code), 415 | ?is_string(Desc) -> Status; 416 | validate_status(Status) -> error({bad_status, Status}). 417 | 418 | header_names(Headers) -> 419 | [string:to_lower(Name) || {Name, _} <- Headers]. 420 | 421 | finalize_response(State) -> 422 | apply_state_transforms( 423 | [fun check_content_len/1, 424 | fun check_date/1, 425 | fun check_server/1, 426 | fun check_keep_alive/1], 427 | State). 428 | 429 | check_content_len(State) -> 430 | ContentLenStatus = resp_header_status("content-length", State), 431 | #state{client_ver=Ver} = State, 432 | check_content_len(ContentLenStatus, Ver, State). 433 | 434 | resp_header_status(Name, #state{resp_header_names=Names}) -> 435 | case lists:member(Name, Names) of 436 | true -> defined; 437 | false -> undefined 438 | end. 439 | 440 | check_content_len(defined, _, S) -> S; 441 | check_content_len(undefined, _, S) when ?is_resp_body_binary(S) 442 | -> set_resp_content_len(S); 443 | check_content_len(undefined, {1, 1}, S) -> set_resp_chunked(S); 444 | check_content_len(undefined, {1, 0}, S) -> set_close(true, S). 445 | 446 | set_resp_content_len(#state{resp_body=Body}=State) -> 447 | Len = integer_to_list(size(Body)), 448 | add_resp_header("Content-Length", Len, State). 449 | 450 | add_resp_header(Name, Value, #state{resp_headers=Hs}=S) -> 451 | S#state{resp_headers=[{Name, Value}|Hs]}. 452 | 453 | set_resp_chunked(State) -> 454 | ChunkedState = State#state{resp_chunked=true}, 455 | add_resp_header("Transfer-Encoding", "chunked", ChunkedState). 456 | 457 | check_date(State) -> 458 | check_date(resp_header_status("date", State), State). 459 | 460 | check_date(defined, State) -> State; 461 | check_date(undefined, State) -> 462 | add_resp_header("Date", psycho_datetime:rfc1123(), State). 463 | 464 | check_server(State) -> 465 | check_server(resp_header_status("server", State), State). 466 | 467 | check_server(defined, State) -> State; 468 | check_server(undefined, State) -> 469 | add_resp_header("Server", ?server, State). 470 | 471 | check_keep_alive(State) -> 472 | check_keep_alive(resp_header_status("connection", State), State). 473 | 474 | check_keep_alive(defined, State) -> State; 475 | check_keep_alive(undefined, #state{close=true}=State) -> State; 476 | check_keep_alive(undefined, #state{close=false}=State) -> 477 | add_resp_header("Connection", "keep-alive", State). 478 | 479 | respond(State) -> 480 | respond_status(State), 481 | respond_headers(State), 482 | respond_body(State), 483 | close_or_keep_alive(State). 484 | 485 | respond_status(#state{sock=Sock, resp_status={Code, Reason}}) -> 486 | Line = ["HTTP/1.1 ", integer_to_list(Code), " ", Reason, ?CRLF], 487 | ok = psycho_socket:send(Sock, Line). 488 | 489 | respond_headers(#state{resp_headers=Headers, sock=Sock}) -> 490 | respond_headers(Headers, Sock). 491 | 492 | respond_headers([{Name, Value}|Rest], Sock) -> 493 | ok = psycho_socket:send(Sock, [Name, ": ", header_value(Value), ?CRLF]), 494 | respond_headers(Rest, Sock); 495 | respond_headers([], Sock) -> 496 | ok = psycho_socket:send(Sock, ?CRLF). 497 | 498 | header_value(L) when is_list(L) -> L; 499 | header_value(B) when is_binary(B) -> B; 500 | header_value(I) when is_integer(I) -> integer_to_list(I). 501 | 502 | respond_body(#state{sock=Sock, resp_body={Fun, IterState}}) 503 | when is_function(Fun) -> 504 | send_body_iter(Sock, Fun, IterState); 505 | respond_body(#state{sock=Sock, resp_body=Body}=State) -> 506 | send_data(Sock, maybe_encode_chunk(Body, State)). 507 | 508 | send_data(Sock, Data) -> 509 | ok = psycho_socket:send(Sock, Data). 510 | 511 | maybe_encode_chunk(Data, #state{resp_chunked=true}) -> 512 | [encode_chunk(Data), last_chunk()]; 513 | maybe_encode_chunk(Chunk, _) -> Chunk. 514 | 515 | encode_chunk(Data) -> 516 | [encoded_chunk_size(Data), ?CRLF, Data, ?CRLF]. 517 | 518 | encoded_chunk_size(Chunk) -> 519 | integer_to_list(iolist_size(Chunk), 16). 520 | 521 | last_chunk() -> ["0", ?CRLF, ?CRLF]. 522 | 523 | send_body_iter(Sock, Fun, IterState) -> 524 | handle_body_iter(apply_body_iter(Fun, IterState), Sock, Fun). 525 | 526 | apply_body_iter(Fun, IterState) -> Fun(IterState). 527 | 528 | handle_body_iter({continue, Data, IterState}, Sock, Fun) -> 529 | send_data(Sock, Data), 530 | send_body_iter(Sock, Fun, IterState); 531 | handle_body_iter(stop, _Sock, _Fun) -> 532 | ok; 533 | handle_body_iter({stop, Data}, Sock, _Fun) -> 534 | send_data(Sock, Data). 535 | 536 | %%%=================================================================== 537 | %%% Finalize request 538 | %%%=================================================================== 539 | 540 | close_or_keep_alive(#state{close=true}=S) -> close(S); 541 | close_or_keep_alive(State) -> 542 | handle_unread_data(unread_data(State), State). 543 | 544 | unread_data(#state{req_content_len=undefined}) -> 545 | false; 546 | unread_data(#state{req_content_len=Len, recv_len=Recv}) -> 547 | Recv < Len. 548 | 549 | handle_unread_data(true, State) -> 550 | close(State); 551 | handle_unread_data(false, State) -> 552 | keep_alive(State). 553 | 554 | close(#state{sock=Sock}) -> 555 | ok = psycho_socket:close(Sock), 556 | {stop, normal}. 557 | 558 | keep_alive(S) -> 559 | {noreply, reset_state(S)}. 560 | 561 | reset_state(#state{sock=Sock, app=App}) -> 562 | ok = psycho_socket:setopts(Sock, [{active, once}, {packet, http}]), 563 | init_state(Sock, App). 564 | -------------------------------------------------------------------------------- /src/psycho_handler_sup.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_handler_sup). 2 | 3 | -behavior(proc). 4 | 5 | -export([start_link/0, start_handler/3]). 6 | 7 | -export([init/1, handle_msg/3]). 8 | 9 | start_link() -> 10 | proc:start_link(?MODULE, []). 11 | 12 | init([]) -> 13 | erlang:process_flag(trap_exit, true), 14 | {ok, []}. 15 | 16 | start_handler(Sup, Sock, Apps) -> 17 | Result = proc:call(Sup, {start_handler, Sock, Apps}), 18 | maybe_handoff_socket(Result, Sock), 19 | Result. 20 | 21 | maybe_handoff_socket({ok, Pid}, Sock) -> 22 | ok = psycho_socket:controlling_process(Sock, Pid), 23 | ok = psycho_socket:setopts(Sock, [{active, once}, {packet, http}]); 24 | maybe_handoff_socket(_Other, _Sock) -> 25 | ok. 26 | 27 | handle_msg({start_handler, Sock, Apps}, _From, State) -> 28 | handle_start_handler(Sock, Apps, State); 29 | handle_msg({'EXIT', _Handler, normal}, noreply, State) -> 30 | {noreply, State}; 31 | handle_msg({'EXIT', Handler, Reason}, noreply, State) -> 32 | log_error(Handler, Reason), 33 | {noreply, State}. 34 | 35 | handle_start_handler(Sock, Apps, State) -> 36 | Result = psycho_handler:start_link(Sock, Apps), 37 | {reply, Result, State}. 38 | 39 | log_error(Handler, Err) -> 40 | psycho_log:error({handler_error, Handler, Err}). 41 | -------------------------------------------------------------------------------- /src/psycho_log.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_log). 2 | 3 | -export([error/1]). 4 | 5 | error(Err) -> 6 | error_logger:error_report(Err). 7 | -------------------------------------------------------------------------------- /src/psycho_mime.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_mime). 2 | 3 | -export([init/0, 4 | init/1, 5 | load_types/1, 6 | type_from_path/1, 7 | type_from_path/2, 8 | type_from_extension/1, 9 | type_from_extension/2]). 10 | 11 | -define(TABLE, psycho_mime_types). 12 | -define(MIME_TYPES_FILE, "mime.types"). 13 | 14 | init() -> 15 | init_table(load_types(priv_mime_types())). 16 | 17 | init(Types) -> 18 | init_table(Types). 19 | 20 | load_types(Src) -> 21 | case file:consult(Src) of 22 | {ok, [Types]} -> Types; 23 | _ -> [] 24 | end. 25 | 26 | priv_mime_types() -> 27 | filename:join(psycho:priv_dir(), ?MIME_TYPES_FILE). 28 | 29 | init_table(Types) -> 30 | ensure_table(), 31 | ets:insert(?TABLE, table_objects(Types)). 32 | 33 | ensure_table() -> 34 | TableOpts = [named_table, public, set, {read_concurrency, true}], 35 | try ets:new(?TABLE, TableOpts) of 36 | ?TABLE -> ok 37 | catch 38 | error:badarg -> ok 39 | end. 40 | 41 | table_objects(Types) -> 42 | acc_table_objects(Types, []). 43 | 44 | acc_table_objects([{Type, Extensions}|Rest], Acc) -> 45 | acc_table_objects(Type, Extensions, Rest, Acc); 46 | acc_table_objects([], Acc) -> Acc. 47 | 48 | acc_table_objects(Type, [Extension|RestExts], RestTypes, Acc) -> 49 | acc_table_objects(Type, RestExts, RestTypes, [{Extension, Type}|Acc]); 50 | acc_table_objects(_Type, [], RestTypes, Acc) -> 51 | acc_table_objects(RestTypes, Acc). 52 | 53 | type_from_path(Path) -> 54 | type_from_path(Path, undefined). 55 | 56 | type_from_path(Path, Default) -> 57 | type_from_extension(extension(Path), Default). 58 | 59 | extension(Path) -> 60 | strip_period(filename:extension(Path)). 61 | 62 | strip_period([$.|Ext]) -> Ext; 63 | strip_period(Other) -> Other. 64 | 65 | type_from_extension(Extension) -> 66 | type_from_extension(Extension, undefined). 67 | 68 | type_from_extension(Extension, Default) when is_list(Extension) -> 69 | try ets:lookup(?TABLE, Extension) of 70 | [{_, Type}] -> Type; 71 | _ -> Default 72 | catch 73 | error:badarg -> error(not_initialized) 74 | end. 75 | -------------------------------------------------------------------------------- /src/psycho_multipart.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_multipart). 2 | 3 | -export([new/1, new/3, data/2, form_data/1, user_data/1]). 4 | 5 | -record(mp, {boundary_delim, parts, name, headers, last, acc, cb, cb_data}). 6 | 7 | new(Boundary) -> 8 | new(Boundary, undefined, undefined). 9 | 10 | new(Boundary, Callback, Data) when is_binary(Boundary) -> 11 | #mp{ 12 | boundary_delim=boundary_delim(Boundary), 13 | parts=[], 14 | name=undefined, 15 | headers=pending, 16 | last=undefined, 17 | acc=[], 18 | cb=Callback, 19 | cb_data=Data}. 20 | 21 | boundary_delim(Boundary) -> 22 | <<"--", Boundary/binary>>. 23 | 24 | data(Data, MP) -> 25 | handle_headers_state(headers_state(MP), Data, MP). 26 | 27 | headers_state(#mp{last=undefined, headers=pending}) -> not_started; 28 | headers_state(#mp{headers=pending}) -> pending; 29 | headers_state(_) -> finalized. 30 | 31 | handle_headers_state(not_started, Data, MP) -> 32 | try_boundary(Data, MP); 33 | handle_headers_state(pending, Data, MP) -> 34 | try_headers(Data, MP); 35 | handle_headers_state(finalized, Data, MP) -> 36 | try_boundary(Data, MP). 37 | 38 | try_boundary(Data, MP) -> 39 | Window = search_window(Data, MP), 40 | handle_boundary_split(split_boundary(Window, MP), Data, MP). 41 | 42 | search_window(Data, #mp{last=undefined}) -> Data; 43 | search_window(Data, #mp{last=Last}) -> <>. 44 | 45 | split_boundary(Data, #mp{boundary_delim=Delim}) -> 46 | binary:split(Data, Delim). 47 | 48 | handle_boundary_split([Window], Data, MP) -> 49 | handle_window(Window, Data, MP); 50 | handle_boundary_split([<<>>, After], _Data, MP) -> 51 | try_headers(After, After, MP); 52 | handle_boundary_split([Before, After], _Data, MP) -> 53 | try_headers(After, After, finalize_part(handle_last_data(Before, MP))). 54 | 55 | handle_window(Window, Data, #mp{headers=pending}=MP) -> 56 | try_headers(Window, Data, MP); 57 | handle_window(_Window, Data, MP) -> 58 | handle_body_data(Data, MP). 59 | 60 | try_headers(Data, MP) -> 61 | try_headers(search_window(Data, MP), Data, MP). 62 | 63 | try_headers(Window, Data, MP) -> 64 | handle_headers_split(split_on_headers_delim(Window), Data, MP). 65 | 66 | -define(HEADERS_DELIM, <<"\r\n\r\n">>). 67 | 68 | split_on_headers_delim(Window) -> 69 | binary:split(Window, ?HEADERS_DELIM). 70 | 71 | handle_headers_split([_Window], Data, MP) -> 72 | push_data(Data, MP); 73 | handle_headers_split([Before, After], _Data, MP) -> 74 | start_body(After, finalize_headers(replace_last(Before, MP))). 75 | 76 | replace_last(Last, MP) -> MP#mp{last=Last}. 77 | 78 | finalize_headers(MP) -> 79 | Headers = parse_headers(raw_headers(MP)), 80 | Name = form_data_name(Headers), 81 | handle_finalized_headers(Name, Headers, MP). 82 | 83 | raw_headers(#mp{last=Last, acc=Acc}) -> 84 | iolist_to_binary(lists:reverse([Last|Acc])). 85 | 86 | parse_headers(<<"\r\n", Raw/binary>>) -> 87 | [parse_header(Part) || Part <- split_headers(Raw)]; 88 | parse_headers(_) -> []. 89 | 90 | split_headers(Raw) -> 91 | binary:split(Raw, <<"\r\n">>, [global]). 92 | 93 | parse_header(Raw) -> 94 | case binary:split(Raw, <<":">>) of 95 | [Name, RawVal] -> {binary_to_list(Name), header_val(RawVal)}; 96 | [Name] -> {binary_to_list(Name), <<>>} 97 | end. 98 | 99 | header_val(<<" ", Val/binary>>) -> binary_to_list(Val); 100 | header_val(Val) -> binary_to_list(Val). 101 | 102 | -define(FORM_DATA_NAME_RE, <<"form-data; *name=\"(.*?)\"">>). 103 | 104 | form_data_name(Headers) -> 105 | Disp = proplists:get_value("Content-Disposition", Headers, ""), 106 | handle_form_data_name_re( 107 | re:run(Disp, ?FORM_DATA_NAME_RE, [{capture, [1], list}])). 108 | 109 | handle_form_data_name_re({match, [Name]}) -> Name; 110 | handle_form_data_name_re(nomatch) -> <<>>. 111 | 112 | handle_finalized_headers(Name, Headers, #mp{cb=undefined}=MP) -> 113 | MP#mp{name=Name, headers=Headers}; 114 | handle_finalized_headers(Name, Headers, MP) -> 115 | handle_part_cb(part_cb(Name, Headers, MP), Name, Headers, MP). 116 | 117 | part_cb(Name, Headers, #mp{cb=Callback, cb_data=CbData}) -> 118 | Callback({part, Name, Headers}, CbData). 119 | 120 | handle_part_cb({continue, CbData}, Name, Headers, MP) -> 121 | MP#mp{name=Name, headers=Headers, cb_data=CbData}; 122 | handle_part_cb({continue, {Name, Headers}, CbData}, _, _, MP) -> 123 | MP#mp{name=Name, headers=Headers, cb_data=CbData}; 124 | handle_part_cb({drop, CbData}, Name, _, MP) -> 125 | MP#mp{name=Name, headers=dropping, cb_data=CbData}. 126 | 127 | start_body(Data, MP) -> 128 | try_boundary(Data, MP#mp{last=undefined, acc=[]}). 129 | 130 | handle_last_data(Data, #mp{cb=undefined}=MP) -> 131 | replace_last(strip_trailing_crlf(Data), MP); 132 | handle_last_data(_Data, #mp{headers=dropping}=MP) -> 133 | MP; 134 | handle_last_data(Data, MP) -> 135 | Stripped = strip_trailing_crlf(Data), 136 | handle_last_data_cb(data_cb(Stripped, MP), Stripped, MP). 137 | 138 | data_cb(Data, #mp{name=Name, cb=Callback, cb_data=CbData}) -> 139 | Callback({data, Name, Data}, CbData). 140 | 141 | strip_trailing_crlf(Bin) -> 142 | N = size(Bin) - 2, 143 | case Bin of 144 | <> -> Stripped; 145 | _ -> Bin 146 | end. 147 | 148 | handle_last_data_cb({continue, CbData}, Data, MP) -> 149 | replace_last(Data, MP#mp{cb_data=CbData}); 150 | handle_last_data_cb({continue, OtherData, CbData}, _Data, MP) -> 151 | replace_last(OtherData, MP#mp{cb_data=CbData}); 152 | handle_last_data_cb({drop, CbData}, _Data, MP) -> 153 | MP#mp{cb_data=CbData}. 154 | 155 | finalize_part(#mp{headers=dropping}=MP) -> 156 | reset_part_attrs(MP); 157 | finalize_part(MP) -> 158 | handle_finalized_part(new_part(MP), MP). 159 | 160 | reset_part_attrs(MP) -> 161 | MP#mp{name=undefined, headers=pending, last=undefined, acc=[]}. 162 | 163 | new_part(#mp{name=Name, headers=Headers, last=undefined, acc=[]}) -> 164 | {Name, {Headers, <<>>}}; 165 | new_part(#mp{name=Name, headers=Headers, last=Last, acc=Acc}) -> 166 | Body = iolist_to_binary(lists:reverse([Last|Acc])), 167 | {Name, {Headers, Body}}. 168 | 169 | handle_finalized_part(Part, #mp{cb=undefined}=MP) -> 170 | reset_part_attrs(add_part(Part, MP)); 171 | handle_finalized_part(Part, MP) -> 172 | handle_eof_cb(eof_cb(MP), Part, MP). 173 | 174 | eof_cb(#mp{cb=Callback, cb_data=CbData, name=Name}) -> 175 | Callback({data, Name, eof}, CbData). 176 | 177 | add_part(Part, #mp{parts=Parts}=MP) -> 178 | MP#mp{parts=[Part|Parts]}. 179 | 180 | handle_eof_cb({continue, CbData}, Part, MP) -> 181 | reset_part_attrs(add_part(Part, MP#mp{cb_data=CbData})); 182 | handle_eof_cb({drop, CbData}, _Part, MP) -> 183 | reset_part_attrs(MP#mp{cb_data=CbData}). 184 | 185 | handle_body_data(Data, #mp{cb=undefined}=MP) -> 186 | push_data(Data, MP); 187 | handle_body_data(Data, #mp{headers=dropping}=MP) -> 188 | replace_last(Data, MP); 189 | handle_body_data(Data, #mp{last=undefined}=MP) -> 190 | push_data(Data, MP); 191 | handle_body_data(Data, #mp{last=Last}=MP) -> 192 | handle_data_cb(data_cb(Last, MP), Data, MP). 193 | 194 | handle_data_cb({continue, CbData}, Data, MP) -> 195 | push_data(Data, MP#mp{cb_data=CbData}); 196 | handle_data_cb({continue, NewLast, CbData}, Data, MP) -> 197 | push_data(Data, MP#mp{last=NewLast, cb_data=CbData}); 198 | handle_data_cb({drop, CbData}, Data, MP) -> 199 | replace_last(Data, MP#mp{cb_data=CbData}). 200 | 201 | push_data(Data, #mp{last=undefined}=MP) -> 202 | MP#mp{last=Data}; 203 | push_data(Data, #mp{last=Last, acc=Acc}=MP) -> 204 | MP#mp{last=Data, acc=[Last|Acc]}. 205 | 206 | form_data(#mp{parts=Parts}) -> 207 | lists:reverse(Parts). 208 | 209 | user_data(#mp{cb_data=Data}) -> 210 | Data. 211 | -------------------------------------------------------------------------------- /src/psycho_opt.erl: -------------------------------------------------------------------------------- 1 | %% =================================================================== 2 | %% @author Garrett Smith 3 | %% @copyright 2011-2012 Garrett Smith 4 | %% 5 | %% @doc e2 option validation utility. 6 | %% 7 | %% For example in how this facility can be used to validate options 8 | %% lists, see [https://github.com/gar1t/e2/blob/master/test/e2_opt_tests.erl 9 | %% test/e2_opt_tests.erl]. 10 | %% @end 11 | %% =================================================================== 12 | 13 | -module(psycho_opt). 14 | 15 | -export([validate/2, validate/3, value/2, value/3]). 16 | 17 | -define(NO_DEFAULT, '$e2_opt_nodefault'). 18 | 19 | -record(schema, {implicit, constraints}). 20 | -record(constraint, 21 | {values, 22 | type, 23 | min, 24 | max, 25 | pattern, 26 | validate, 27 | implicit=false, 28 | optional=false, 29 | default=?NO_DEFAULT}). 30 | 31 | -define(is_type(T), (T == int orelse 32 | T == float orelse 33 | T == string orelse 34 | T == number orelse 35 | T == atom orelse 36 | T == list orelse 37 | T == boolean orelse 38 | T == binary orelse 39 | T == iolist orelse 40 | T == function)). 41 | 42 | %%%=================================================================== 43 | %%% API 44 | %%%=================================================================== 45 | 46 | validate(Options, Schema) -> 47 | validate(Options, compile_schema(Schema), dict:new()). 48 | 49 | validate([], #schema{}=Schema, Opts0) -> 50 | apply_missing(Schema, Opts0); 51 | validate([Opt|Rest], #schema{}=Schema, Opts0) -> 52 | validate(Rest, Schema, apply_opt(Opt, Schema, Opts0)); 53 | validate(MoreOptions, Schema, Opts0) -> 54 | validate(MoreOptions, compile_schema(Schema), Opts0). 55 | 56 | value(Name, Opts) -> 57 | dict:fetch(Name, Opts). 58 | 59 | value(Name, Opts, Default) -> 60 | case dict:find(Name, Opts) of 61 | {ok, Value} -> Value; 62 | error -> Default 63 | end. 64 | 65 | compile_schema(Schema) -> 66 | Constraints = [compile_constraint(C) || C <- Schema], 67 | #schema{implicit=index_implicit(Constraints), constraints=Constraints}. 68 | 69 | %%%=================================================================== 70 | %%% Internal functions 71 | %%%=================================================================== 72 | 73 | compile_constraint(Name) when is_atom(Name) -> 74 | {Name, #constraint{}}; 75 | compile_constraint({Name, Opts}) -> 76 | {Name, apply_constraint_options(Opts, #constraint{})}. 77 | 78 | index_implicit(Constraints) -> 79 | index_implicit(Constraints, dict:new()). 80 | 81 | index_implicit([], Imp) -> Imp; 82 | index_implicit([{_, #constraint{implicit=false}}|Rest], Imp) -> 83 | index_implicit(Rest, Imp); 84 | index_implicit([{Name, #constraint{implicit=true, values=undefined}}|_], _) -> 85 | error({values_required, Name}); 86 | index_implicit([{Name, #constraint{implicit=true, values=Vals}}|Rest], Imp) -> 87 | index_implicit(Rest, index_implicit_vals(Name, Vals, Imp)). 88 | 89 | index_implicit_vals(_, [], Imp) -> Imp; 90 | index_implicit_vals(Name, [Val|Rest], Imp) -> 91 | case dict:find(Val, Imp) of 92 | {ok, _} -> error({duplicate_implicit_value, Val}); 93 | error -> index_implicit_vals(Name, Rest, dict:store(Val, Name, Imp)) 94 | end. 95 | 96 | -define(constraint_val(Field, Val, C), C#constraint{Field=Val}). 97 | 98 | apply_constraint_options([], C) -> C; 99 | apply_constraint_options([{values, Values}|Rest], C) when is_list(Values) -> 100 | apply_constraint_options(Rest, ?constraint_val(values, Values, C)); 101 | apply_constraint_options([{type, Type}|Rest], C) when ?is_type(Type) -> 102 | apply_constraint_options(Rest, ?constraint_val(type, Type, C)); 103 | apply_constraint_options([Type|Rest], C) when ?is_type(Type) -> 104 | apply_constraint_options(Rest, ?constraint_val(type, Type, C)); 105 | apply_constraint_options([{min, Min}|Rest], C) -> 106 | apply_constraint_options(Rest, ?constraint_val(min, Min, C)); 107 | apply_constraint_options([{max, Max}|Rest], C) -> 108 | apply_constraint_options(Rest, ?constraint_val(max, Max, C)); 109 | apply_constraint_options([{pattern, Pattern}|Rest], C) -> 110 | apply_constraint_options( 111 | Rest, ?constraint_val(pattern, compile_pattern(Pattern), C)); 112 | apply_constraint_options([{validate, F}|Rest], C) when is_function(F) -> 113 | apply_constraint_options( 114 | Rest, ?constraint_val(validate, check_validate(F), C)); 115 | apply_constraint_options([optional|Rest], C) -> 116 | apply_constraint_options(Rest, ?constraint_val(optional, true, C)); 117 | apply_constraint_options([{optional, B}|Rest], C) when is_boolean(B) -> 118 | apply_constraint_options(Rest, ?constraint_val(optional, B, C)); 119 | apply_constraint_options([{default, Default}|Rest], C) -> 120 | apply_constraint_options(Rest, ?constraint_val(default, Default, C)); 121 | apply_constraint_options([implicit|Rest], C) -> 122 | apply_constraint_options(Rest, ?constraint_val(implicit, true, C)); 123 | apply_constraint_options([{Name, _}|_], _) -> 124 | error({badarg, Name}); 125 | apply_constraint_options([Other|_], _) -> 126 | error({badarg, Other}). 127 | 128 | compile_pattern(Pattern) -> 129 | case re:compile(Pattern) of 130 | {ok, Re} -> Re; 131 | {error, _} -> error({badarg, pattern}) 132 | end. 133 | 134 | check_validate(F) -> 135 | case erlang:fun_info(F, arity) of 136 | {arity, 1} -> F; 137 | {arity, _} -> error({badarg, validate}) 138 | end. 139 | 140 | apply_opt(Opt, Schema, Opts) -> 141 | {Name, Value} = validate_opt(Opt, Schema), 142 | case dict:find(Name, Opts) of 143 | {ok, _} -> error({duplicate, Name}); 144 | error -> dict:store(Name, Value, Opts) 145 | end. 146 | 147 | validate_opt({Name, Value}, Schema) -> 148 | case find_constraint(Name, Schema) of 149 | {ok, Constraint} -> 150 | case check_value(Value, Constraint) of 151 | ok -> {Name, Value}; 152 | error -> error({badarg, Name}) 153 | end; 154 | error -> error({badarg, Name}) 155 | end; 156 | validate_opt(Option, Schema) -> 157 | case implicit_option(Option, Schema) of 158 | {ok, Name} -> {Name, Option}; 159 | error -> 160 | validate_opt({Option, true}, Schema) 161 | end. 162 | 163 | find_constraint(Name, #schema{constraints=Constraints}) -> 164 | case lists:keyfind(Name, 1, Constraints) of 165 | {Name, Constraint} -> {ok, Constraint}; 166 | false -> error 167 | end. 168 | 169 | implicit_option(Value, #schema{implicit=Implicit}) -> 170 | case dict:find(Value, Implicit) of 171 | {ok, Name} -> {ok, Name}; 172 | error -> error 173 | end. 174 | 175 | check_value(Val, Constraint) -> 176 | apply_checks(Val, Constraint, 177 | [fun check_enum/2, 178 | fun check_type/2, 179 | fun check_range/2, 180 | fun check_pattern/2, 181 | fun apply_validate/2]). 182 | 183 | apply_checks(_Val, _Constraint, []) -> ok; 184 | apply_checks(Val, Constraint, [Check|Rest]) -> 185 | case Check(Val, Constraint) of 186 | ok -> apply_checks(Val, Constraint, Rest); 187 | error -> error 188 | end. 189 | 190 | check_enum(_Val, #constraint{values=undefined}) -> ok; 191 | check_enum(Val, #constraint{values=Values}) -> 192 | case lists:member(Val, Values) of 193 | true -> ok; 194 | false -> error 195 | end. 196 | 197 | -define(is_iolist(T), 198 | try erlang:iolist_size(Val) of 199 | _ -> true 200 | catch 201 | error:badarg -> false 202 | end). 203 | 204 | check_type(_Val, #constraint{type=undefined}) -> ok; 205 | check_type(Val, #constraint{type=int}) when is_integer(Val) -> ok; 206 | check_type(Val, #constraint{type=float}) when is_float(Val) -> ok; 207 | check_type(Val, #constraint{type=number}) when is_number(Val) -> ok; 208 | check_type(Val, #constraint{type=string}) -> 209 | case ?is_iolist(Val) of 210 | true -> ok; 211 | false -> error 212 | end; 213 | check_type(Val, #constraint{type=boolean}) when is_boolean(Val) -> ok; 214 | check_type(Val, #constraint{type=list}) when is_list(Val) -> ok; 215 | check_type(Val, #constraint{type=atom}) when is_atom(Val) -> ok; 216 | check_type(Val, #constraint{type=binary}) when is_binary(Val) -> ok; 217 | check_type(Val, #constraint{type=function}) when is_function(Val) -> ok; 218 | check_type(Val, #constraint{type=iolist}) -> 219 | try iolist_size(Val) of 220 | _ -> ok 221 | catch 222 | error:badarg -> error 223 | end; 224 | check_type(_, _) -> error. 225 | 226 | check_range(_Val, #constraint{min=undefined, max=undefined}) -> ok; 227 | check_range(Val, #constraint{min=undefined, max=Max}) when Val =< Max -> ok; 228 | check_range(Val, #constraint{min=Min, max=undefined}) when Val >= Min -> ok; 229 | check_range(Val, #constraint{min=Min, max=Max}) when Val =< Max, 230 | Val >= Min-> ok; 231 | check_range(_, _) -> error. 232 | 233 | check_pattern(_Val, #constraint{pattern=undefined}) -> ok; 234 | check_pattern(Val, #constraint{pattern=Regex}) -> 235 | case re:run(Val, Regex, [{capture, none}]) of 236 | match -> ok; 237 | nomatch -> error 238 | end. 239 | 240 | apply_validate(_Val, #constraint{validate=undefined}) -> ok; 241 | apply_validate(Val, #constraint{validate=Validate}) -> 242 | case Validate(Val) of 243 | ok -> ok; 244 | error -> error; 245 | Other -> error({validate_result, Other}) 246 | end. 247 | 248 | apply_missing(#schema{constraints=Constraints}, Opts0) -> 249 | lists:foldl(fun apply_default/2, Opts0, Constraints). 250 | 251 | apply_default({Name, #constraint{default=Default, optional=Optional}}, Opts) -> 252 | case dict:find(Name, Opts) of 253 | {ok, _} -> Opts; 254 | error -> 255 | case Default of 256 | ?NO_DEFAULT -> 257 | case Optional of 258 | true -> Opts; 259 | false -> error({required, Name}) 260 | end; 261 | _ -> dict:store(Name, Default, Opts) 262 | end 263 | end. 264 | -------------------------------------------------------------------------------- /src/psycho_reloader.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_reloader). 2 | 3 | -export([start/0]). 4 | 5 | -export([init/1]). 6 | 7 | -include_lib("kernel/include/file.hrl"). 8 | 9 | -define(SLEEP_INTERVAL, 1000). 10 | 11 | start() -> 12 | proc_lib:start(?MODULE, init, [self()]). 13 | 14 | init(Parent) -> 15 | proc_lib:init_ack(Parent, {ok, self()}), 16 | loop(init_beam_timestamps()). 17 | 18 | init_beam_timestamps() -> 19 | dict:from_list(module_timestamps(reloadable_modules())). 20 | 21 | reloadable_modules() -> 22 | filter_non_sticky(filter_real_paths(code:all_loaded())). 23 | 24 | filter_real_paths(Mods) -> 25 | lists:filter(filter_real_path_fun(), Mods). 26 | 27 | filter_real_path_fun() -> 28 | fun({_Mod, Path}) -> is_list(Path) end. 29 | 30 | filter_non_sticky(Mods) -> 31 | lists:filter(filter_non_sticky_fun(), Mods). 32 | 33 | filter_non_sticky_fun() -> 34 | fun({Mod, _Path}) -> not code:is_sticky(Mod) end. 35 | 36 | module_timestamps(Loaded) -> 37 | [{{Mod, Path}, beam_timestamp(Path)} || {Mod, Path} <- Loaded]. 38 | 39 | beam_timestamp(Path) -> 40 | case file:read_file_info(Path) of 41 | {ok, #file_info{mtime=Time}} -> Time; 42 | {error, _Err} -> undefined 43 | end. 44 | 45 | loop(PrevTimes) -> 46 | sleep(), 47 | CurTimes = init_beam_timestamps(), 48 | check_modules(PrevTimes, CurTimes), 49 | loop(CurTimes). 50 | 51 | sleep() -> timer:sleep(?SLEEP_INTERVAL). 52 | 53 | check_modules(PrevTimes, CurTimes) -> 54 | reload_modules(changed_modules(PrevTimes, CurTimes)). 55 | 56 | changed_modules(PrevTimes, CurTimes) -> 57 | dict:fold(changed_module_folder(CurTimes), [], PrevTimes). 58 | 59 | changed_module_folder(CurTimes) -> 60 | fun(ModInfo, PrevTime, Changed) -> 61 | add_if_changed(ModInfo, PrevTime, CurTimes, Changed) end. 62 | 63 | add_if_changed(Loaded, PrevTime, CurTimes, Changed) -> 64 | case PrevTime /= beam_timestamp(Loaded, CurTimes) of 65 | true -> add_changed_module(Loaded, Changed); 66 | false -> Changed 67 | end. 68 | 69 | beam_timestamp(Loaded, Times) -> 70 | case dict:find(Loaded, Times) of 71 | {ok, Time} -> Time; 72 | error -> undefined 73 | end. 74 | 75 | add_changed_module({Mod, _Path}, Changed) -> 76 | [Mod|Changed]. 77 | 78 | reload_modules([Mod|Rest]) -> 79 | io:format("Reloading ~p... ", [Mod]), 80 | code:purge(Mod), 81 | handle_code_load_file(code:load_file(Mod)), 82 | reload_modules(Rest); 83 | reload_modules([]) -> ok. 84 | 85 | handle_code_load_file({module, _}) -> 86 | io:format("ok~n"); 87 | handle_code_load_file({error, nofile}) -> 88 | ok; 89 | handle_code_load_file({error, Err}) -> 90 | io:format("error: ~p~n", [Err]). 91 | -------------------------------------------------------------------------------- /src/psycho_route.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_route). 2 | 3 | -export([create_app/1, create_app/2, route/2, route/3, 4 | dispatch_app/1, dispatch_app/2, 5 | default_not_found_app/0]). 6 | 7 | %%%=================================================================== 8 | %%% Route app 9 | %%%=================================================================== 10 | 11 | create_app(Routes) -> 12 | create_app(Routes, []). 13 | 14 | create_app(Routes, Options) -> 15 | fun(Env) -> route(Routes, Env, Options) end. 16 | 17 | route(Routes, Env) -> 18 | route(Routes, Env, []). 19 | 20 | route(Routes, Env0, Options) -> 21 | {{Path, _, _}, Env} = psycho_util:ensure_parsed_request_path(Env0), 22 | Method = psycho:env_val(request_method, Env), 23 | dispatch(Routes, Method, Path, Env, Options). 24 | 25 | dispatch([{Route, App}|Rest], Method, Path, Env, Options) -> 26 | maybe_dispatch( 27 | path_matches(Route, Path), 28 | App, Rest, Method, Path, Env, Options); 29 | dispatch([{RouteMethod, Route, App}|Rest], Method, Path, Env, Options) -> 30 | maybe_dispatch( 31 | RouteMethod =:= Method andalso path_matches(Route, Path), 32 | App, Rest, Method, Path, Env, Options); 33 | dispatch([Invalid|_], _Method, _Path, _Env, _Options) -> 34 | error({invald_route, Invalid}); 35 | dispatch([], _Method, _Path, Env, Options) -> 36 | psycho:call_app(not_found_handler(Options), Env). 37 | 38 | path_matches({starts_with, Prefix}, Path) -> starts_with(Prefix, Path); 39 | path_matches({matches, Regex}, Path) -> regex_matches(Regex, Path); 40 | path_matches({exact, Path}, Path) -> true; 41 | path_matches({any, Conditions}, Path) -> any_matches(Conditions, Path); 42 | path_matches('_', _) -> true; 43 | path_matches(Path, Path) -> true; 44 | path_matches(_, _) -> false. 45 | 46 | starts_with([Char|RestPrefix], [Char|RestStr]) -> 47 | starts_with(RestPrefix, RestStr); 48 | starts_with([], _Str) -> true; 49 | starts_with(_, _) -> false. 50 | 51 | regex_matches(Regex, Str) -> 52 | match == re:run(Str, Regex, [{capture, none}]). 53 | 54 | any_matches([Condition|Rest], Path) -> 55 | case path_matches(Condition, Path) of 56 | true -> true; 57 | false -> any_matches(Rest, Path) 58 | end; 59 | any_matches([], _Path) -> false. 60 | 61 | maybe_dispatch(true, App, _Rest, _Method, _Path, Env, _Options) -> 62 | psycho:call_app(App, Env); 63 | maybe_dispatch(false, _App, Rest, Method, Path, Env, Options) -> 64 | dispatch(Rest, Method, Path, Env, Options). 65 | 66 | not_found_handler(Options) -> 67 | proplists:get_value(not_found_handler, Options, fun default_not_found/1). 68 | 69 | %%%=================================================================== 70 | %%% Default page handlers 71 | %%%=================================================================== 72 | 73 | default_not_found_app() -> 74 | fun(Env) -> default_not_found(Env) end. 75 | 76 | default_not_found(_Env) -> 77 | {{404, "Not Found"}, [{"Content-Type", "text/plain"}], "Not Found"}. 78 | 79 | %%%=================================================================== 80 | %%% Dispatch app 81 | %%%=================================================================== 82 | 83 | dispatch_app(Spec) -> 84 | dispatch_app(Spec, []). 85 | 86 | dispatch_app(Spec, Options) -> 87 | fun(Env) -> dispatch(app_for_path(Spec, Options, Env), Options) end. 88 | 89 | app_for_path({module, Prefix, Suffix}, _Options, Env) -> 90 | mod_for_path(Env, Prefix, Suffix). 91 | 92 | mod_for_path(Env0, Prefix, Suffix) -> 93 | {{Path, _, _}, Env} = psycho_util:ensure_parsed_request_path(Env0), 94 | MaybeMod = try_mod_for_parts([Prefix, path_to_mod_part(Path), Suffix]), 95 | {MaybeMod, Env}. 96 | 97 | path_to_mod_part("/" ++ Path) -> 98 | path_to_mod_part(Path); 99 | path_to_mod_part("") -> 100 | "index"; 101 | path_to_mod_part(Path) -> 102 | replace(Path, $/, $_, []). 103 | 104 | replace([Replace|Rest], Replace, With, Acc) -> 105 | replace(Rest, Replace, With, [With|Acc]); 106 | replace([C|Rest], Replace, With, Acc) -> 107 | replace(Rest, Replace, With, [C|Acc]); 108 | replace([], _Replace, _With, Acc) -> 109 | lists:reverse(Acc). 110 | 111 | try_mod_for_parts(Parts) -> 112 | Flattened = lists:flatten(Parts), 113 | try list_to_existing_atom(Flattened) of 114 | A -> {ok, A} 115 | catch 116 | error:badarg -> error 117 | end. 118 | 119 | dispatch({{ok, App}, Env}, _Options) -> 120 | psycho:call_app(App, Env); 121 | dispatch({error, Env}, Options) -> 122 | psycho:call_app(not_found_handler(Options), Env). 123 | -------------------------------------------------------------------------------- /src/psycho_server.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_server). 2 | 3 | -behavior(proc). 4 | 5 | -export([start/2, start/3, start_link/2, start_link/3]). 6 | 7 | -export([init/1, handle_msg/3]). 8 | 9 | -record(state, {handler_sup, lsock, app, mod, accept_timeout}). 10 | 11 | -define(DEFAULT_BACKLOG, 128). 12 | -define(DEFAULT_RECBUF, 8192). 13 | -define(DEFAULT_ACCEPT_TIMEOUT_FOR_CB, 500). 14 | 15 | %%%=================================================================== 16 | %%% Start / init 17 | %%%=================================================================== 18 | 19 | start(Binding, App) -> 20 | start(Binding, App, []). 21 | 22 | start(Binding, App, Options) -> 23 | proc:start(?MODULE, [Binding, App, Options]). 24 | 25 | start_link(Binding, App) -> 26 | start_link(Binding, App, []). 27 | 28 | start_link(Binding, App, Options) -> 29 | proc:start_link(?MODULE, [Binding, App, Options]). 30 | 31 | init([Binding, App, Options]) -> 32 | HandlerSup = start_handler_sup(), 33 | Mod = maybe_cb_init(Options), 34 | LSock = listen(Binding, Options), 35 | State = #state{ 36 | handler_sup=HandlerSup, 37 | lsock=LSock, 38 | app=App, 39 | mod=Mod, 40 | accept_timeout=accept_timeout_opt(Mod, Options)}, 41 | {ok, State, {first_msg, accept}}. 42 | 43 | maybe_cb_init(Options) -> 44 | case proplists:get_value(proc_callback, Options) of 45 | undefined -> undefined; 46 | {Mod, InitArg} -> 47 | Mod:init(InitArg), 48 | Mod 49 | end. 50 | 51 | start_handler_sup() -> 52 | {ok, Sup} = psycho_handler_sup:start_link(), 53 | Sup. 54 | 55 | listen(Binding, Options) -> 56 | handle_listen(psycho_socket:listen(Binding, Options)). 57 | 58 | handle_listen({ok, LSock}) -> LSock; 59 | handle_listen({error, Err}) -> error({listen, Err}). 60 | 61 | accept_timeout_opt(undefined, _Opts) -> 62 | infinity; 63 | accept_timeout_opt(_CbMod, Opts) -> 64 | proplists:get_value( 65 | accept_timeout, Opts, ?DEFAULT_ACCEPT_TIMEOUT_FOR_CB). 66 | 67 | %%%=================================================================== 68 | %%% Message dispatch 69 | %%%=================================================================== 70 | 71 | handle_msg(accept, noreply, State) -> 72 | handle_accept(accept(State), State), 73 | {next_msg, accept, State}; 74 | handle_msg(_Msg, _From, #state{mod=undefined}=State) -> 75 | {noreply, State}; 76 | handle_msg(Msg, From, #state{app=App, mod=Mod}=State) -> 77 | handle_msg_cb(Mod:handle_msg(Msg, From, App), State). 78 | 79 | handle_msg_cb({noreply, App}, State) -> 80 | {noreply, State#state{app=App}}; 81 | handle_msg_cb({reply, Reply, App}, State) -> 82 | {reply, Reply, State#state{app=App}}; 83 | handle_msg_cb({stop, Reason}, State) -> 84 | {stop, Reason, State}. 85 | 86 | accept(#state{lsock=LSock, accept_timeout=Timeout}) -> 87 | psycho_socket:accept(LSock, Timeout). 88 | 89 | handle_accept({ok, Sock}, State) -> 90 | dispatch_request(Sock, State); 91 | handle_accept({error, timeout}, _State) -> 92 | ok. 93 | 94 | dispatch_request(Sock, #state{handler_sup=Sup, app=App}) -> 95 | handle_start_handler(psycho_handler_sup:start_handler(Sup, Sock, App)). 96 | 97 | handle_start_handler({ok, _Pid}) -> ok; 98 | handle_start_handler(Other) -> psycho_log:error(Other). 99 | -------------------------------------------------------------------------------- /src/psycho_socket.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_socket). 2 | 3 | -export([listen/2, 4 | tags/1, 5 | accept/1, 6 | accept/2, 7 | controlling_process/2, 8 | setopts/2, 9 | peername/1, 10 | send/2, 11 | recv/2, 12 | recv/3, 13 | close/1]). 14 | 15 | 16 | -define(DEFAULT_BACKLOG, 128). 17 | -define(DEFAULT_RECBUF, 8192). 18 | -define(SSL_DETECT_KEYS, [key, keyfile, cert, certfile]). 19 | -define(SSL_OPTION_KEYS, [verify, verify_fun, fail_if_no_peer_cert, depth, 20 | cert, certfile, key, keyfile, password, cacerts, 21 | cacertfile, dh, dhfile, ciphers, user_lookup_fun, 22 | reuse_sessions, reuse_session, 23 | client_preferred_next_protocols, log_alert, 24 | server_name_indication, sni_hosts, sni_fun]). 25 | 26 | -record(psycho_socket, { 27 | type = undefined, 28 | socket = undefined 29 | }). 30 | 31 | 32 | listen(Binding, Options) -> 33 | Port = binding_port(Binding), 34 | ListenOpts = listen_options(Binding, Options), 35 | EnableSSL = enable_ssl(Options), 36 | listen_socket(Port, ListenOpts, EnableSSL). 37 | 38 | 39 | tags(#psycho_socket{type =ssl }) -> 40 | {ssl, ssl_closed, ssl_error}; 41 | tags(#psycho_socket{type = tcp }) -> 42 | {http, tcp_closed, tcp_error}. 43 | 44 | 45 | accept(Socket) -> 46 | accept(Socket, infinity). 47 | 48 | accept(#psycho_socket{socket = Sock, type=ssl}, Timeout) -> 49 | handle_ssl_transport(ssl:transport_accept(Sock, Timeout), Timeout); 50 | accept(#psycho_socket{socket = Sock, type=tcp}, Timeout) -> 51 | socket_or_error(gen_tcp:accept(Sock, Timeout), false). 52 | 53 | 54 | controlling_process(#psycho_socket{socket = Sock, type=ssl}, Pid) -> 55 | ssl:controlling_process(Sock, Pid); 56 | controlling_process(#psycho_socket{socket = Sock, type=tcp}, Pid) -> 57 | gen_tcp:controlling_process(Sock, Pid). 58 | 59 | 60 | setopts(#psycho_socket{socket = Sock, type=ssl}, Options) -> 61 | ssl:setopts(Sock, Options); 62 | setopts(#psycho_socket{socket = Sock, type=tcp}, Options) -> 63 | inet:setopts(Sock, Options). 64 | 65 | 66 | peername(#psycho_socket{socket = Sock, type=ssl}) -> 67 | ssl:peername(Sock); 68 | peername(#psycho_socket{socket = Sock, type=tcp}) -> 69 | inet:peername(Sock). 70 | 71 | 72 | send(#psycho_socket{socket = Sock, type=ssl}, Data) -> 73 | ssl:send(Sock, Data); 74 | send(#psycho_socket{socket = Sock, type=tcp}, Data) -> 75 | gen_tcp:send(Sock, Data). 76 | 77 | 78 | recv(Socket, Length) -> 79 | recv(Socket, Length, infinity). 80 | 81 | recv(#psycho_socket{socket = Sock, type=ssl}, Length, Timeout) -> 82 | ssl:recv(Sock, Length, Timeout); 83 | recv(#psycho_socket{socket = Sock, type=tcp}, Length, Timeout) -> 84 | gen_tcp:recv(Sock, Length, Timeout). 85 | 86 | 87 | close(#psycho_socket{socket = Sock, type=tcp}) -> 88 | gen_tcp:close(Sock). 89 | 90 | 91 | listen_socket(Port, ListenOpts, true) -> 92 | socket_or_error(ssl:listen(Port, ListenOpts), true); 93 | listen_socket(Port, ListenOpts, false) -> 94 | socket_or_error(gen_tcp:listen(Port, ListenOpts), false). 95 | 96 | 97 | binding_port(Port) when is_integer(Port) -> Port; 98 | binding_port({_Addr, Port}) when is_integer(Port) -> Port; 99 | binding_port(Other) -> error({invalid_binding, Other}). 100 | 101 | 102 | listen_options(Binding, Opts) -> 103 | Options = ssl_binding_options(Binding, Opts), 104 | lists:merge([binary, 105 | {active, false}, 106 | {reuseaddr, true}, 107 | {backlog, backlog_opt(Opts)}, 108 | {recbuf, recbuf_opt(Opts)}], Options). 109 | 110 | 111 | binding_opt({Addr, _Port}) -> 112 | Addr; 113 | binding_opt(_) -> 114 | undefined. 115 | 116 | 117 | backlog_opt(Opts) -> 118 | proplists:get_value(backlog, Opts, ?DEFAULT_BACKLOG). 119 | 120 | 121 | recbuf_opt(Opts) -> 122 | proplists:get_value(recbuf, Opts, ?DEFAULT_RECBUF). 123 | 124 | 125 | enable_ssl(Opts) -> 126 | OptionKeys = proplists:get_keys(Opts), 127 | ContainsSSL = 128 | fun(Key, Result) -> 129 | Result orelse lists:member(Key, ?SSL_DETECT_KEYS) 130 | end, 131 | lists:foldl(ContainsSSL, false, OptionKeys). 132 | 133 | 134 | ssl_binding_options(Binding, Options) -> 135 | add_binding(ssl_options(Options), binding_opt(Binding)). 136 | 137 | 138 | ssl_options(Options) -> 139 | Filter = fun({Key, _Value}) -> 140 | lists:member(Key, ?SSL_OPTION_KEYS); 141 | (_) -> 142 | false 143 | end, 144 | lists:filter(Filter, Options). 145 | 146 | 147 | add_binding(Opts, undefined) -> 148 | Opts; 149 | add_binding(Opts, Addr) -> 150 | [{ip, Addr} | Opts ]. 151 | 152 | 153 | handle_ssl_transport({ok, NewSocket}, Timeout) -> 154 | handle_ssl_handshake(ssl:ssl_accept(NewSocket, Timeout), NewSocket); 155 | handle_ssl_transport(Error, _Timeout) -> 156 | Error. 157 | 158 | 159 | handle_ssl_handshake(ok, TransportSocket) -> 160 | socket_or_error({ok, TransportSocket}, true); 161 | handle_ssl_handshake({ok, SslSocket}, _) -> 162 | socket_or_error({ok, SslSocket}, true); 163 | handle_ssl_handshake(Error, _) -> 164 | Error. 165 | 166 | 167 | socket_or_error({ok, Sock}, false) -> 168 | {ok, #psycho_socket{ type = tcp, socket = Sock}}; 169 | socket_or_error({ok, Sock}, true) -> 170 | {ok, #psycho_socket{ type = ssl, socket = Sock}}; 171 | socket_or_error(Error, _) -> 172 | Error. 173 | -------------------------------------------------------------------------------- /src/psycho_static.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_static). 2 | 3 | -export([create_app/1, create_app/2, 4 | serve_file/1, serve_file/2, serve_file/3]). 5 | 6 | -include_lib("kernel/include/file.hrl"). 7 | -include("http_status.hrl"). 8 | 9 | -define(chunk_read_size, 409600). 10 | -define(DEFAULT_CONTENT_TYPE, "text/plain"). 11 | -define(DEFAULT_FILE, "index.html"). 12 | 13 | -record(read_state, {path, file}). 14 | 15 | %% TODO: Options to include: 16 | %% 17 | %% - Content type proplist 18 | %% - Default file for a dir 19 | %% 20 | create_app(Dir) -> 21 | fun(Env) -> ?MODULE:serve_file(Dir, Env) end. 22 | 23 | create_app(Dir, "/" ++ StripPrefix) -> 24 | create_app(Dir, StripPrefix); 25 | create_app(Dir, StripPrefix) -> 26 | fun(Env) -> ?MODULE:serve_file(Dir, Env, StripPrefix) end. 27 | 28 | serve_file(Dir, Env) -> 29 | serve_file(requested_path(Dir, Env)). 30 | 31 | serve_file(Dir, Env, StripPrefix) -> 32 | serve_file(requested_path(Dir, Env, StripPrefix)). 33 | 34 | requested_path(Dir, Env) -> 35 | filename:join(Dir, relative_request_path(Env)). 36 | 37 | requested_path(Dir, Env, StripPrefix) -> 38 | case strip_prefix(StripPrefix, relative_request_path(Env)) of 39 | {ok, Stripped} -> filename:join(Dir, Stripped); 40 | error -> not_found() 41 | end. 42 | 43 | relative_request_path(Env) -> 44 | strip_leading_slashes(request_path(Env)). 45 | 46 | request_path(Env) -> 47 | {Path, _, _} = psycho:parsed_request_path(Env), 48 | Path. 49 | 50 | strip_leading_slashes([$/|Rest]) -> strip_leading_slashes(Rest); 51 | strip_leading_slashes([$\\|Rest]) -> strip_leading_slashes(Rest); 52 | strip_leading_slashes(RelativePath) -> RelativePath. 53 | 54 | strip_prefix(Prefix, Path) -> 55 | case lists:split(length(Prefix), Path) of 56 | {Prefix, Stripped} -> {ok, Stripped}; 57 | _ -> error 58 | end. 59 | 60 | serve_file(File) -> 61 | {Info, Path} = resolved_file_info(File), 62 | LastModified = last_modified(Info), 63 | ContentType = content_type(Path), 64 | Size = file_size(Info), 65 | RawHeaders = 66 | [{"Last-Modified", LastModified}, 67 | {"Content-Type", ContentType}, 68 | {"Content-Length", Size}], 69 | Headers = remove_undefined(RawHeaders), 70 | Body = body_iterable(Path, open_file(Path)), 71 | {{200, "OK"}, Headers, Body}. 72 | 73 | resolved_file_info(Path) -> 74 | handle_file_or_dir_info(file_info(Path), Path). 75 | 76 | file_info(Path) -> 77 | file:read_file_info(Path, [{time, universal}]). 78 | 79 | handle_file_or_dir_info({ok, #file_info{type=directory}}, DirPath) -> 80 | FilePath = apply_default_file(DirPath), 81 | handle_file_info(file_info(FilePath), FilePath); 82 | handle_file_or_dir_info(Info, Path) -> 83 | handle_file_info(Info, Path). 84 | 85 | apply_default_file(Dir) -> 86 | filename:join(Dir, ?DEFAULT_FILE). 87 | 88 | handle_file_info({ok, #file_info{type=regular}=Info}, Path) -> 89 | {Info, Path}; 90 | handle_file_info({ok, _}, _Path) -> 91 | not_found(); 92 | handle_file_info({error, _}, _Path) -> 93 | not_found(). 94 | 95 | last_modified(#file_info{mtime=MTime}) -> 96 | psycho_util:http_date(MTime). 97 | 98 | content_type(Path) -> 99 | psycho_mime:type_from_path(Path, ?DEFAULT_CONTENT_TYPE). 100 | 101 | file_size(#file_info{size=Size}) -> integer_to_list(Size). 102 | 103 | remove_undefined(L) -> 104 | [{Name, Val} || {Name, Val} <- L, Val /= undefined]. 105 | 106 | open_file(Path) -> 107 | case file:open(Path, [read, raw, binary]) of 108 | {ok, File} -> File; 109 | {error, Err} -> internal_error({read_file, Path, Err}) 110 | end. 111 | 112 | body_iterable(Path, File) -> 113 | {fun read_file_chunk/1, init_read_state(Path, File)}. 114 | 115 | init_read_state(Path, File) -> #read_state{path=Path, file=File}. 116 | 117 | read_file_chunk(#read_state{file=File}=State) -> 118 | handle_read_file(file:read(File, ?chunk_read_size), State). 119 | 120 | handle_read_file({ok, Data}, State) -> 121 | {continue, Data, State}; 122 | handle_read_file(eof, #read_state{path=Path, file=File}) -> 123 | close_file(Path, File), 124 | stop; 125 | handle_read_file({error, Err}, #read_state{path=Path, file=File}) -> 126 | psycho_log:error({read_file, Path, Err}), 127 | close_file(Path, File), 128 | stop. 129 | 130 | close_file(Path, File) -> 131 | case file:close(File) of 132 | ok -> ok; 133 | {error, Err} -> 134 | psycho_log:error({close_file, Path, Err}) 135 | end. 136 | 137 | not_found() -> 138 | throw({?status_not_found, 139 | [{"Content-Type", "text/plain"}], 140 | "Not found"}). 141 | 142 | internal_error(Err) -> 143 | psycho_log:error(Err), 144 | throw({?status_internal_server_error, 145 | [{"Content-Type", "text/plain"}], 146 | "Internal Error"}). 147 | -------------------------------------------------------------------------------- /src/psycho_static2.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_static2). 2 | 3 | -export([create_app/1, create_app/2, serve_file/3]). 4 | 5 | -export([handle_request/3]). 6 | 7 | -include_lib("kernel/include/file.hrl"). 8 | -include("http_status.hrl"). 9 | 10 | -define(chunk_read_size, 409600). 11 | -define(DEFAULT_CONTENT_TYPE, "text/plain"). 12 | -define(DEFAULT_FILE, "index.html"). 13 | 14 | -record(read_state, {path, file}). 15 | 16 | %% =================================================================== 17 | %% App 18 | %% =================================================================== 19 | 20 | create_app(Dir) -> 21 | create_app(Dir, []). 22 | 23 | create_app(Dir, Opts) -> 24 | fun(Env) -> ?MODULE:handle_request(Env, Dir, Opts) end. 25 | 26 | handle_request(Env, Dir, Opts) -> 27 | Path = request_path(Env, Dir, Opts), 28 | serve_file_(resolve_path(Path, Opts), Env, file_opts(Opts)). 29 | 30 | request_path(Env, Dir, Opts) -> 31 | RelPath = relative_request_path(Env), 32 | filename:join(Dir, maybe_strip_path(RelPath, Opts)). 33 | 34 | relative_request_path(Env) -> 35 | {Path, _, _} = psycho:parsed_request_path(Env), 36 | strip_leading_slashes(Path). 37 | 38 | strip_leading_slashes([$/|Rest]) -> strip_leading_slashes(Rest); 39 | strip_leading_slashes([$\\|Rest]) -> strip_leading_slashes(Rest); 40 | strip_leading_slashes(RelativePath) -> RelativePath. 41 | 42 | maybe_strip_path(Path, Opts) -> 43 | case lists:keyfind(strip_prefix, 1, Opts) of 44 | {_, Prefix} -> strip_prefix(Prefix, Path, Opts); 45 | false -> Path 46 | end. 47 | 48 | strip_prefix(Prefix, Path, Opts) -> 49 | case lists:split(length(Prefix), Path) of 50 | {Prefix, Stripped} -> Stripped; 51 | _ -> not_found(Path, Opts) 52 | end. 53 | 54 | resolve_path(Path, Opts) -> 55 | resolve_path(Path, Opts, 0). 56 | 57 | resolve_path(Path, Opts, Calls) when Calls =< 1 -> 58 | case file_info(Path) of 59 | {ok, #file_info{type=regular}=Info} -> 60 | {Path, Info}; 61 | {ok, #file_info{type=directory}} -> 62 | resolve_dir(Path, Opts, Calls); 63 | _ -> 64 | not_found(Path, Opts) 65 | end; 66 | resolve_path(Path, Opts, _N) -> 67 | internal_error({too_many_calls, Path, Opts}). 68 | 69 | file_info(Path) -> 70 | file:read_file_info(Path, [{time, universal}]). 71 | 72 | resolve_dir(Dir, Opts, Calls) -> 73 | Default = proplists:get_value(default_file, Opts, ?DEFAULT_FILE), 74 | NewPath = filename:join(Dir, Default), 75 | resolve_path(NewPath, Opts, Calls + 1). 76 | 77 | file_opts(Opts) -> 78 | case proplists:get_value(default_cache_control, Opts) of 79 | undefined -> []; 80 | Val -> [{cache_control, Val}] 81 | end. 82 | 83 | %% =================================================================== 84 | %% Serve file 85 | %% =================================================================== 86 | 87 | serve_file(Path, Env, Opts) -> 88 | case file_info(Path) of 89 | {ok, #file_info{type=regular}=Info} -> 90 | serve_file_({Path, Info}, Env, Opts); 91 | _ -> 92 | not_found(Path, Opts) 93 | end. 94 | 95 | serve_file_({Path, Info}, Env, Opts) -> 96 | ETag = etag(Info, Opts), 97 | IfNoneMatch = psycho:env_header("If-None-Match", Env), 98 | file_or_not_modified(IfNoneMatch, ETag, Path, Info, Opts). 99 | 100 | file_or_not_modified(ETag, ETag, _Path, _Info, _Opts) -> 101 | not_modified(ETag); 102 | file_or_not_modified(_, ETag, Path, Info, Opts) -> 103 | ok_file(ETag, Path, Info, Opts). 104 | 105 | not_modified(ETag) -> 106 | {{304, "Not Modified"}, [{"ETag", ETag}]}. 107 | 108 | ok_file(ETag, Path, Info, Opts) -> 109 | Headers = 110 | headers( 111 | [{"Last-Modified", fun() -> last_modified(Info) end}, 112 | {"Content-Type", fun() -> content_type(Path, Opts) end}, 113 | {"Content-Length", fun() -> content_length(Info) end}, 114 | {"Cache-Control", fun() -> cache_control(Opts) end}, 115 | {"ETag", fun() -> ETag end}, 116 | {"Content-Encoding", fun() -> content_encoding(Opts) end}]), 117 | Body = body_iterable(Path, open_file(Path)), 118 | {{200, "OK"}, Headers, Body}. 119 | 120 | %% ------------------------------------------------------------------- 121 | %% Header support 122 | %% ------------------------------------------------------------------- 123 | 124 | headers(Specs) -> 125 | lists:foldl(fun apply_header/2, [], Specs). 126 | 127 | apply_header({Name, F}, Acc) -> 128 | case F() of 129 | undefined -> Acc; 130 | I when is_integer(I) -> [{Name, integer_to_list(I)}|Acc]; 131 | Val -> [{Name, Val}|Acc] 132 | end. 133 | 134 | last_modified(#file_info{mtime=MTime}) -> 135 | psycho_util:http_date(MTime). 136 | 137 | content_type(Path, Opts) -> 138 | case proplists:get_value(content_type, Opts) of 139 | undefined -> psycho_mime:type_from_path(Path, ?DEFAULT_CONTENT_TYPE); 140 | Val -> Val 141 | end. 142 | 143 | content_length(#file_info{size=Size}) -> Size. 144 | 145 | etag(Info, Opts) -> 146 | case proplists:get_value(etag, Opts) of 147 | undefined -> default_etag(Info); 148 | Val -> Val 149 | end. 150 | 151 | default_etag(#file_info{mtime=MTime}) -> 152 | integer_to_list(erlang:phash2(MTime, 4294967296)). 153 | 154 | cache_control(Opts) -> 155 | proplists:get_value(cache_control, Opts). 156 | 157 | content_encoding(Opts) -> 158 | proplists:get_value(content_encoding, Opts). 159 | 160 | %% ------------------------------------------------------------------- 161 | %% File I/O 162 | %% ------------------------------------------------------------------- 163 | 164 | open_file(Path) -> 165 | case file:open(Path, [read, raw, binary]) of 166 | {ok, File} -> File; 167 | {error, Err} -> internal_error({read_file, Path, Err}) 168 | end. 169 | 170 | body_iterable(Path, File) -> 171 | {fun read_file_chunk/1, init_read_state(Path, File)}. 172 | 173 | init_read_state(Path, File) -> #read_state{path=Path, file=File}. 174 | 175 | read_file_chunk(#read_state{file=File}=State) -> 176 | handle_read_file(file:read(File, ?chunk_read_size), State). 177 | 178 | handle_read_file({ok, Data}, State) -> 179 | {continue, Data, State}; 180 | handle_read_file(eof, #read_state{path=Path, file=File}) -> 181 | close_file(Path, File), 182 | stop; 183 | handle_read_file({error, Err}, #read_state{path=Path, file=File}) -> 184 | psycho_log:error({read_file, Path, Err}), 185 | close_file(Path, File), 186 | stop. 187 | 188 | close_file(Path, File) -> 189 | case file:close(File) of 190 | ok -> ok; 191 | {error, Err} -> 192 | psycho_log:error({close_file, Path, Err}) 193 | end. 194 | 195 | %% =================================================================== 196 | %% Response helpers 197 | %% =================================================================== 198 | 199 | not_found(Path, Opts) -> 200 | case proplists:get_value(not_found_handler, Opts) of 201 | undefined -> default_not_found(); 202 | Handler -> Handler(Path) 203 | end. 204 | 205 | default_not_found() -> 206 | throw( 207 | {?status_not_found, 208 | [{"Content-Type", "text/plain"}], 209 | "Not found"}). 210 | 211 | internal_error(Err) -> 212 | psycho_log:error(Err), 213 | throw( 214 | {?status_internal_server_error, 215 | [{"Content-Type", "text/plain"}], 216 | "Internal Error"}). 217 | -------------------------------------------------------------------------------- /src/psycho_tests.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_tests). 2 | 3 | -export([run/0]). 4 | 5 | run() -> 6 | try 7 | test_parse_request_path(), 8 | test_ensure_parsed_request_path(), 9 | test_routes(), 10 | test_crypto(), 11 | test_validate(), 12 | test_multipart_simplest(), 13 | test_multipart_splits(), 14 | test_multipart_multiple(), 15 | test_multipart_filtering(), 16 | test_dispatch_on(), 17 | test_encode_decode_url(), 18 | test_chain_apps() 19 | catch 20 | _:Err -> 21 | io:format("ERROR~n~p~n~p~n", [Err, erlang:get_stacktrace()]) 22 | end. 23 | 24 | %% =================================================================== 25 | %% parse request path 26 | %% =================================================================== 27 | 28 | test_parse_request_path() -> 29 | io:format("parse_request_path: "), 30 | P = fun(S) -> psycho_util:parse_request_path(S) end, 31 | 32 | %% Empty case 33 | {"", "", []} = P(""), 34 | 35 | %% Simple paths 36 | {"/foo", "", []} = P("/foo"), 37 | {"/foo/bar", "", []} = P("/foo/bar"), 38 | 39 | %% Path with simple query strings 40 | {"/foo", "bar=123", [{"bar", "123"}]} = 41 | P("/foo?bar=123"), 42 | {"/foo", "bar=123&baz=456", [{"bar", "123"}, {"baz", "456"}]} = 43 | P("/foo?bar=123&baz=456"), 44 | 45 | %% Query strings with names only 46 | {"/foo", "bar=123&baz", [{"bar", "123"}, {"baz", ""}]} = 47 | P("/foo?bar=123&baz"), 48 | {"/foo", "baz&bar=123", [{"baz", ""}, {"bar", "123"}]} = 49 | P("/foo?baz&bar=123"), 50 | 51 | %% Query strings with multiple values 52 | {"/foo", "bar=123&bar=456", [{"bar", "123"}, {"bar", "456"}]} = 53 | P("/foo?bar=123&bar=456"), 54 | 55 | io:format("OK~n"). 56 | 57 | %% =================================================================== 58 | %% ensure parsed request path 59 | %% =================================================================== 60 | 61 | test_ensure_parsed_request_path() -> 62 | io:format("ensure_parsed_request_path: "), 63 | E = fun(Env) -> psycho_util:ensure_parsed_request_path(Env) end, 64 | 65 | Path = "/foo?bar=123&bar=456", 66 | Parsed = psycho_util:parse_request_path(Path), 67 | Env0 = [{request_path, Path}], 68 | 69 | {Parsed, Env1} = E(Env0), 70 | Env1 = [{parsed_request_path, Parsed}|Env0], 71 | {Parsed, Env1} = E(Env1), 72 | 73 | io:format("OK~n"). 74 | 75 | %% =================================================================== 76 | %% routes 77 | %% =================================================================== 78 | 79 | test_routes() -> 80 | io:format("routes: "), 81 | R = fun(Routes, Env) -> psycho_route:route(Routes, Env) end, 82 | R2 = fun(Routes, Env, Opts) -> psycho_route:route(Routes, Env, Opts) end, 83 | App = fun(Result) -> fun(_Env) -> Result end end, 84 | Env = fun(Path) -> [{request_path, Path}] end, 85 | Env2 = fun(Method, Path) -> [{request_method, Method}, 86 | {request_path, Path}] 87 | end, 88 | 89 | NotFoundHandlerOpts = [{not_found_handler, App(not_found)}], 90 | 91 | Routes = 92 | [{"/", App(root)}, 93 | {"/foo", App(foo)}, 94 | {{exact, "/bar"}, App(bar)}, 95 | {{starts_with, "/bar"}, App(starts_with_bar)}, 96 | {{matches, "^/baz/(bam|BAM)$"}, App(baz_bam)}, 97 | {{matches, "^/baz/"}, App(baz_other)}, 98 | {"POST", "/bam", App(bam_post)}, 99 | {"PUT", {starts_with, "/bam/"}, App(bam_other_put)}], 100 | 101 | %% Test routes to app proxies 102 | root = R(Routes, Env("/")), 103 | foo = R(Routes, Env("/foo")), 104 | bar = R(Routes, Env("/bar")), 105 | starts_with_bar = R(Routes, Env("/bar/baz")), 106 | baz_bam = R(Routes, Env("/baz/bam")), 107 | baz_bam = R(Routes, Env("/baz/BAM")), 108 | baz_other = R(Routes, Env("/baz/bAm")), 109 | baz_other = R(Routes, Env("/baz/bam/foo")), 110 | not_found = R2(Routes, Env("baz/bam"), NotFoundHandlerOpts), 111 | bam_post = R(Routes, Env2("POST", "/bam")), 112 | bam_other_put = R(Routes, Env2("PUT", "/bam/foo")), 113 | not_found = R2(Routes, Env("/not_handled"), NotFoundHandlerOpts), 114 | 115 | %% Default not found handler 116 | {{404, "Not Found"}, _, _} = R(Routes, Env("/not_handled")), 117 | 118 | io:format("OK~n"). 119 | 120 | %% =================================================================== 121 | %% crypto 122 | %% =================================================================== 123 | 124 | test_crypto() -> 125 | io:format("crypto: "), 126 | 127 | E = fun(Data, Key) -> psycho_util:encrypt(Data, Key) end, 128 | D = fun(Data, Key) -> psycho_util:decrypt(Data, Key) end, 129 | 130 | Data1 = <<"hello">>, 131 | Data2 = <<"there">>, 132 | Key1 = <<"sesame">>, 133 | Key2 = <<"letmein">>, 134 | 135 | {ok, Data1} = D(E(Data1, Key1), Key1), 136 | {ok, Data1} = D(E(Data1, Key2), Key2), 137 | {ok, Data2} = D(E(Data2, Key1), Key1), 138 | {ok, Data2} = D(E(Data2, Key2), Key2), 139 | 140 | error = D(E(Data1, Key1), Key2), 141 | error = D(E(Data1, Key2), Key1), 142 | error = D(E(Data2, Key1), Key2), 143 | error = D(E(Data2, Key2), Key1), 144 | 145 | io:format("OK~n"). 146 | 147 | %% =================================================================== 148 | %% validate 149 | %% =================================================================== 150 | 151 | test_validate() -> 152 | io:format("validate: "), 153 | 154 | V = fun(Data, Schema) -> psycho_util:validate(Data, Schema) end, 155 | 156 | %% Empty / base case 157 | 158 | {ok, []} = V([], []), 159 | 160 | %% required 161 | 162 | {error, {"foo", required}} = V([], [{"foo", [required]}]), 163 | {ok, _} = V([{"foo", "FOO"}], [{"foo", [required]}]), 164 | 165 | %% must_equal, literal 166 | 167 | {error, {"foo", {must_equal, "FOO"}}} = 168 | V([], [{"foo", [{must_equal, "FOO"}]}]), 169 | {ok, _} = V([{"foo", "FOO"}], [{"foo", [{must_equal, "FOO"}]}]), 170 | {ok, _} = V([{"foo", "FOO"}], [{"foo", ["FOO"]}]), 171 | 172 | %% must_equal, reference to another field value 173 | 174 | {ok, _} = V([], [{"foo", [{must_equal, {field, "bar"}}]}]), 175 | {error, {"foo", {must_equal, {field,"bar"}}}} = 176 | V([{"foo", "FOO"}], [{"foo", [{must_equal, {field, "bar"}}]}]), 177 | {error, {"foo", {must_equal, {field,"bar"}}}} = 178 | V([{"foo", "FOO"}, {"bar", "BAR"}], 179 | [{"foo", [{must_equal, {field, "bar"}}]}]), 180 | {ok, _} = 181 | V([{"foo", "FOO"}, {"bar", "FOO"}], 182 | [{"foo", [{must_equal, {field, "bar"}}]}]), 183 | 184 | %% min_length 185 | 186 | {error, {"foo", {min_length, 4}}} = V([], [{"foo", [{min_length, 4}]}]), 187 | {error, {"foo", {min_length, 4}}} = 188 | V([{"foo", "FOO"}], [{"foo", [{min_length, 4}]}]), 189 | {ok, _} = V([{"foo", "FOO"}], [{"foo", [{min_length, 3}]}]), 190 | 191 | %% Any 192 | {error, {"foo", {any, ["456","789"]}}} = 193 | V([{"foo", "123"}], 194 | [{"foo", [{any, ["456", "789"]}]}]), 195 | {ok, [{"foo", "456"}]} = 196 | V([{"foo", "456"}], 197 | [{"foo", [{any, ["456", "789"]}]}]), 198 | 199 | %% Conversions 200 | 201 | {ok, [{"foo", 123}]} = V([{"foo", "123"}], [{"foo", [integer]}]), 202 | {ok, [{"foo", 123.0}]} = V([{"foo", "123"}], [{"foo", [float]}]), 203 | {ok, [{"foo", 123.456}]} = V([{"foo", "123.456"}], [{"foo", [float]}]), 204 | {ok, [{"foo", 123}]} = V([{"foo", "123"}], [{"foo", [number]}]), 205 | {ok, [{"foo", 123.456}]} = V([{"foo", "123.456"}], [{"foo", [number]}]), 206 | 207 | {error, {"foo", integer}} = V([{"foo", "xxx"}], [{"foo", [integer]}]), 208 | {error, {"foo", float}} = V([{"foo", "xxx"}], [{"foo", [float]}]), 209 | {error, {"foo", number}} = V([{"foo", "xxx"}], [{"foo", [number]}]), 210 | 211 | {ok, [{"foo", <<"FOO">>}]} = V([{"foo", "FOO"}], [{"foo", [binary]}]), 212 | 213 | % Optional 214 | 215 | {ok, []} = V([{"foo", "FOO"}], []), 216 | 217 | io:format("OK~n"). 218 | 219 | %% =================================================================== 220 | %% multipart (simplest) 221 | %% =================================================================== 222 | 223 | test_multipart_simplest() -> 224 | io:format("multipart_simplest: "), 225 | 226 | %% This is the simplest test case of a multipart processing: 227 | %% 228 | %% - A single part (content generated from curl) 229 | %% - No filtering via callbacks 230 | %% - Assert the resulting form data 231 | %% 232 | 233 | Boundary = <<"------------------------65d0128e83f480f8">>, 234 | Data = 235 | [<<"--------------------------65d0128e83f480f8\r\n" 236 | "Content-Disposition: form-data; name=\"msg\";" 237 | " filename=\"msg.txt\"\r\n" 238 | "Content-Type: text/plain\r\n" 239 | "\r\n" 240 | "Hi there.\n" 241 | "\r\n" 242 | "--------------------------65d0128e83f480f8--\r\n">>], 243 | 244 | All = apply_data(Data, psycho_multipart:new(Boundary)), 245 | 246 | [{"msg", 247 | {[{"Content-Disposition", 248 | "form-data; name=\"msg\"; filename=\"msg.txt\""}, 249 | {"Content-Type","text/plain"}], 250 | <<"Hi there.\n">>}}] = psycho_multipart:form_data(All), 251 | 252 | io:format("OK~n"). 253 | 254 | %% =================================================================== 255 | %% multipart (splits) 256 | %% =================================================================== 257 | 258 | test_multipart_splits() -> 259 | io:format("multipart_splits: "), 260 | 261 | %% This test is uses the same data as simplest above but splits it 262 | %% across various divisions to show that the processing is not 263 | %% sensitive to how data is fed during processing. 264 | 265 | Boundary = <<"------------------------65d0128e83f480f8">>, 266 | MP = psycho_multipart:new(Boundary), 267 | 268 | Data = 269 | [<<"--------------------------65d0128e83f480f8\r\n" 270 | "Content-Disposition: form-data; name=\"msg\";" 271 | " filename=\"msg.txt\"\r\n" 272 | "Content-Type: text/plain\r\n" 273 | "\r\n" 274 | "Hi there.\n" 275 | "\r\n" 276 | "--------------------------65d0128e83f480f8--\r\n">>], 277 | 278 | All = apply_data(Data, MP), 279 | 280 | [{"msg", 281 | {[{"Content-Disposition", 282 | "form-data; name=\"msg\"; filename=\"msg.txt\""}, 283 | {"Content-Type","text/plain"}], 284 | <<"Hi there.\n">>}}] = psycho_multipart:form_data(All), 285 | 286 | %% Split in the middle of the headers 287 | 288 | Split1 = 289 | [<<"--------------------------65d0128e83f480f8\r\n" 290 | "Content-Disposition: form-data; name=\"msg\";" 291 | " filename=\"msg.t">>, 292 | <<"xt\"\r\n" 293 | "Content-Type: text/plain\r\n" 294 | "\r\n" 295 | "Hi there.\n" 296 | "\r\n" 297 | "--------------------------65d0128e83f480f8--\r\n">>], 298 | 299 | All = apply_data(Split1, MP), 300 | 301 | %% Split in the middle of the header delimiter 302 | 303 | Split2 = 304 | [<<"--------------------------65d0128e83f480f8\r\n" 305 | "Content-Disposition: form-data; name=\"msg\";" 306 | " filename=\"msg.txt\"\r\n" 307 | "Content-Type: text/plain\r\n">>, 308 | <<"\r\n" 309 | "Hi there.\n" 310 | "\r\n" 311 | "--------------------------65d0128e83f480f8--\r\n">>], 312 | 313 | All = apply_data(Split2, MP), 314 | 315 | %% Split in the middle of the body 316 | 317 | Split3 = 318 | [<<"--------------------------65d0128e83f480f8\r\n" 319 | "Content-Disposition: form-data; name=\"msg\";" 320 | " filename=\"msg.txt\"\r\n" 321 | "Content-Type: text/plain\r\n" 322 | "\r\n" 323 | "Hi th">>, 324 | <<"ere.\n" 325 | "\r\n" 326 | "--------------------------65d0128e83f480f8--\r\n">>], 327 | 328 | All = apply_data(Split3, MP), 329 | 330 | %% Split both headers and body 331 | 332 | Split4 = 333 | [<<"--------------------------65d0128e83f480f8\r\n" 334 | "Content-Disposition: form-data; name=\"msg\";" 335 | " filename=\"msg.">>, 336 | <<"txt\"\r\n" 337 | "Content-Type: text/plain\r\n">>, 338 | <<"\r\n" 339 | "Hi there.\n">>, 340 | <<"\r\n" 341 | "--------------------------65d0128e83f480f8--\r\n">>], 342 | 343 | All = apply_data(Split4, MP), 344 | 345 | io:format("OK~n"). 346 | 347 | %% =================================================================== 348 | %% multipart (multiple) 349 | %% =================================================================== 350 | 351 | test_multipart_multiple() -> 352 | io:format("multipart_multiple: "), 353 | 354 | Boundary = <<"----WebKitFormBoundaryDr6DS6tqR3sKzPnI">>, 355 | MP = psycho_multipart:new(Boundary), 356 | 357 | Split = 358 | [<<"------WebKitFormBoundaryDr6DS6tqR3sKzPnI\r\nConten">>, 359 | <<"t-Disposition: form-data; name=\"name\"\r\n\r\nBob\r\n">>, 360 | <<"------WebKitFormBoundaryDr6DS6tqR3sKzPnI\r\nConten">>, 361 | <<"t-Disposition: form-data; name=\"awesome\"\r\n\r\non\r\n">>, 362 | <<"------WebKitFormBoundaryDr6DS6tqR3sKzPnI\r\nConten">>, 363 | <<"t-Disposition: form-data; name=\"file1\"; filename">>, 364 | <<"=\"file1\"\r\nContent-Type: application/octet-stream">>, 365 | <<"\r\n\r\nThis is\nfile 1.\n\r\n------WebKitFormBoundaryDr">>, 366 | <<"6DS6tqR3sKzPnI\r\nContent-Disposition: form-data; ">>, 367 | <<"name=\"file2\"; filename=\"file2\"\r\nContent-Type: ap">>, 368 | <<"plication/octet-stream\r\n\r\nThis\nis\nfile 2.\n\r\n----">>, 369 | <<"--WebKitFormBoundaryDr6DS6tqR3sKzPnI--\r\n">>], 370 | 371 | Data = [iolist_to_binary(Split)], 372 | 373 | All = apply_data(Split, MP), 374 | All = apply_data(Data, MP), 375 | 376 | [{"name", 377 | {[{"Content-Disposition","form-data; name=\"name\""}], 378 | <<"Bob">>}}, 379 | {"awesome", 380 | {[{"Content-Disposition","form-data; name=\"awesome\""}], 381 | <<"on">>}}, 382 | {"file1", 383 | {[{"Content-Disposition","form-data; name=\"file1\"; " 384 | "filename=\"file1\""}, 385 | {"Content-Type","application/octet-stream"}], 386 | <<"This is\nfile 1.\n">>}}, 387 | {"file2", 388 | {[{"Content-Disposition","form-data; name=\"file2\"; " 389 | "filename=\"file2\""}, 390 | {"Content-Type","application/octet-stream"}], 391 | <<"This\nis\nfile 2.\n">>}}] = psycho_multipart:form_data(All), 392 | 393 | io:format("OK~n"). 394 | 395 | %% =================================================================== 396 | %% multipart (filtering) 397 | %% =================================================================== 398 | 399 | test_multipart_filtering() -> 400 | io:format("multipart_filtering: "), 401 | 402 | Boundary = <<"----WebKitFormBoundaryDr6DS6tqR3sKzPnI">>, 403 | Data = 404 | [<<"------WebKitFormBoundaryDr6DS6tqR3sKzPnI\r\nConten" 405 | "t-Disposition: form-data; name=\"name\"\r\n\r\nBob\r\n" 406 | "------WebKitFormBoundaryDr6DS6tqR3sKzPnI\r\nConten" 407 | "t-Disposition: form-data; name=\"awesome\"\r\n\r\non\r\n" 408 | "------WebKitFormBoundaryDr6DS6tqR3sKzPnI\r\nConten" 409 | "t-Disposition: form-data; name=\"file1\"; filename" 410 | "=\"file1\"\r\nContent-Type: application/octet-stream" 411 | "\r\n\r\nThis is\nfile 1.\n\r\n------WebKitFormBoundaryDr" 412 | "6DS6tqR3sKzPnI\r\nContent-Disposition: form-data; " 413 | "name=\"file2\"; filename=\"file2\"\r\nContent-Type: ap" 414 | "plication/octet-stream\r\n\r\nThis">>, 415 | %% Splits here simulates how a large file might be sent 416 | <<"\nis\nfile ">>, 417 | <<"2.\n\r\n----">>, 418 | <<"--WebKitFormBoundaryDr6DS6tqR3sKzPnI--\r\n">>], 419 | 420 | %% Handle all parts (default behavior with no callback) 421 | 422 | All = apply_data(Data, psycho_multipart:new(Boundary)), 423 | 424 | [{"name", 425 | {[{"Content-Disposition","form-data; name=\"name\""}], 426 | <<"Bob">>}}, 427 | {"awesome", 428 | {[{"Content-Disposition","form-data; name=\"awesome\""}], 429 | <<"on">>}}, 430 | {"file1", 431 | {[{"Content-Disposition","form-data; name=\"file1\"; " 432 | "filename=\"file1\""}, 433 | {"Content-Type","application/octet-stream"}], 434 | <<"This is\nfile 1.\n">>}}, 435 | {"file2", 436 | {[{"Content-Disposition","form-data; name=\"file2\"; " 437 | "filename=\"file2\""}, 438 | {"Content-Type","application/octet-stream"}], 439 | <<"This\nis\nfile 2.\n">>}}] = psycho_multipart:form_data(All), 440 | 441 | %% Use callback to drop the two files 442 | 443 | DropFilesHandler = drop_handler(["file1", "file2"]), 444 | DropFilesMP = psycho_multipart:new(Boundary, DropFilesHandler, []), 445 | DropFiles = apply_data(Data, DropFilesMP), 446 | 447 | [{"name", 448 | {[{"Content-Disposition","form-data; name=\"name\""}], 449 | <<"Bob">>}}, 450 | {"awesome", 451 | {[{"Content-Disposition","form-data; name=\"awesome\""}], 452 | <<"on">>}}] = psycho_multipart:form_data(DropFiles), 453 | 454 | Events = fun(MP) -> lists:reverse(psycho_multipart:user_data(MP)) end, 455 | 456 | [{keep,"name"}, 457 | {data,"name",<<"Bob">>}, 458 | {data,"name",eof}, 459 | {keep,"awesome"}, 460 | {data,"awesome",<<"on">>}, 461 | {data,"awesome",eof}, 462 | {drop,"file1"}, 463 | {drop,"file2"}] = Events(DropFiles), 464 | 465 | %% Use a callback to keep only the two files 466 | 467 | KeepFilesHandler = drop_handler(["name", "awesome"]), 468 | KeepFilesMP = psycho_multipart:new(Boundary, KeepFilesHandler, []), 469 | KeepFiles = apply_data(Data, KeepFilesMP), 470 | 471 | [{"file1", 472 | {[{"Content-Disposition","form-data; name=\"file1\"; " 473 | "filename=\"file1\""}, 474 | {"Content-Type","application/octet-stream"}], 475 | <<"This is\nfile 1.\n">>}}, 476 | {"file2", 477 | {[{"Content-Disposition","form-data; name=\"file2\"; " 478 | "filename=\"file2\""}, 479 | {"Content-Type","application/octet-stream"}], 480 | <<"This\nis\nfile 2.\n">>}}] = psycho_multipart:form_data(KeepFiles), 481 | 482 | [{drop,"name"}, 483 | {drop,"awesome"}, 484 | {keep,"file1"}, 485 | {data,"file1",<<"This is\nfile 1.\n">>}, 486 | {data,"file1",eof}, 487 | {keep,"file2"}, 488 | {data,"file2",<<"This">>}, 489 | {data,"file2",<<"\nis\nfile ">>}, 490 | {data,"file2",<<"2.\n">>}, 491 | {data,"file2",eof}] = Events(KeepFiles), 492 | 493 | %% Use a part handler to modify a part 494 | 495 | RenameHandler = rename_handler("awesome", "lame"), 496 | RenameMP = psycho_multipart:new(Boundary, RenameHandler, []), 497 | Rename = apply_data(Data, RenameMP), 498 | 499 | [{"lame", 500 | {[{"Content-Disposition","form-data; name=\"lame\""}], 501 | <<"on">>}}] = psycho_multipart:form_data(Rename), 502 | 503 | [{drop,"name"}, 504 | {rename,"awesome","lame"}, 505 | {data,"lame",<<"on">>}, 506 | {data,"lame",eof}, 507 | {drop,"file1"}, 508 | {drop,"file2"}] = Events(Rename), 509 | 510 | io:format("OK~n"). 511 | 512 | drop_handler(Drop) -> 513 | fun(Part, Acc) -> handle_drop_part(Part, Acc, Drop) end. 514 | 515 | handle_drop_part({part, Name, _Headers}, Acc, Drop) -> 516 | maybe_drop_part(lists:member(Name, Drop), Name, Acc); 517 | handle_drop_part(Part, Acc, _Drop) -> 518 | {continue, [Part|Acc]}. 519 | 520 | maybe_drop_part(true, Name, Acc) -> 521 | {drop, [{drop, Name}|Acc]}; 522 | maybe_drop_part(false, Name, Acc) -> 523 | {continue, [{keep, Name}|Acc]}. 524 | 525 | rename_handler(From, To) -> 526 | fun(Part, Acc) -> handle_rename(Part, Acc, From, To) end. 527 | 528 | handle_rename({part, Name, _Headers}, Acc, From, To) -> 529 | maybe_rename_part(Name == From, Acc, Name, To); 530 | handle_rename(Part, Acc, _From, _To) -> 531 | {continue, [Part|Acc]}. 532 | 533 | maybe_rename_part(true, Acc, From, To) -> 534 | Headers = [{"Content-Disposition", "form-data; name=\"" ++ To ++ "\""}], 535 | {continue, {To, Headers}, [{rename, From, To}|Acc]}; 536 | maybe_rename_part(false, Acc, Name, _) -> 537 | {drop, [{drop, Name}|Acc]}. 538 | 539 | apply_data([Data|Rest], MP) -> 540 | apply_data(Rest, psycho_multipart:data(Data, MP)); 541 | apply_data([], MP) -> MP. 542 | 543 | %% =================================================================== 544 | %% dispatch on 545 | %% =================================================================== 546 | 547 | test_dispatch_on() -> 548 | io:format("dispatch_on: "), 549 | Env = 550 | [{request_method, "GET"}, 551 | {request_path, "/foo/bar?a=1&b=2"}, 552 | {http_headers, 553 | [{"Cookie", "A=1; B=2"}]}], 554 | D = fun(Parts) -> psycho_util:dispatch_on(Parts, Env) end, 555 | 556 | [Env] = D([env]), 557 | ["GET"] = D([method]), 558 | ["/foo/bar"] = D([path]), 559 | [["foo", "bar"]] = D([split_path]), 560 | [[{"a","1"}, {"b","2"}]] = D([parsed_query_string]), 561 | [{"/foo/bar", 562 | "a=1&b=2", 563 | [{"a","1"}, {"b","2"}]}] = D([parsed_path]), 564 | ["a=1&b=2"] = D([query_string]), 565 | [[{"A","1"}, {"B","2"}]] = D([parsed_cookie]), 566 | ["Literal"] = D(["Literal"]), 567 | ["GET", 568 | "/foo/bar", 569 | [{"a","1"}, {"b","2"}]] = D([method, path, parsed_query_string]), 570 | 571 | io:format("OK~n"). 572 | 573 | %% =================================================================== 574 | %% encode/decode url 575 | %% =================================================================== 576 | 577 | test_encode_decode_url() -> 578 | io:format("encode_decode_url: "), 579 | 580 | Encode = fun(Base, Params) -> 581 | binary_to_list( 582 | iolist_to_binary( 583 | psycho_util:encode_url(Base, Params))) 584 | end, 585 | Decode = fun psycho_util:decode_url_part/1, 586 | 587 | "/foo?" = Encode("/foo", []), 588 | "/foo?a=A" = Encode("/foo", [{"a", "A"}]), 589 | "/foo?a%26b=c%7Cd" = Encode("/foo", [{"a&b", "c|d"}]), 590 | "/foo?a%26b=c%7Cd&e%3Df&g%2Fh" 591 | = Encode("/foo", [{"a&b", "c|d"}, "e=f", "g/h"]), 592 | 593 | "a" = Decode("a"), 594 | "a&b" = Decode("a%26b"), 595 | "c|d" = Decode("c%7Cd"), 596 | "e=f" = Decode("e%3Df"), 597 | "g/h" = Decode("g%2Fh"), 598 | 599 | io:format("OK~n"). 600 | 601 | %% =================================================================== 602 | %% chain apps 603 | %% =================================================================== 604 | 605 | test_chain_apps() -> 606 | io:format("chain_apps: "), 607 | 608 | App = psycho_util:chain_apps( 609 | base_app(), 610 | [middleware(m1), middleware(m2), middleware(m3)]), 611 | 612 | [base, m1, m2, m3] = App([]), 613 | 614 | io:format("OK~n"). 615 | 616 | base_app() -> 617 | fun(Env) -> [base|Env] end. 618 | 619 | middleware_app(Tag, Upstream) -> 620 | fun(Env) -> Upstream([Tag|Env]) end. 621 | 622 | middleware(Tag) -> 623 | fun(Upstream) -> middleware_app(Tag, Upstream) end. 624 | -------------------------------------------------------------------------------- /src/psycho_util.erl: -------------------------------------------------------------------------------- 1 | -module(psycho_util). 2 | 3 | -export([http_date/1, 4 | ensure_parsed_request_path/1, 5 | parse_request_path/1, 6 | parse_query_string/1, 7 | split_path/1, 8 | ensure_parsed_cookie/1, 9 | parse_cookie/1, 10 | cookie_header/2, cookie_header/3, 11 | encrypt/2, decrypt/2, 12 | validate/2, format_validate_error/1, 13 | parse_content_disposition/1, 14 | content_disposition/2, 15 | app_dir/1, priv_dir/1, priv_dir/2, 16 | dispatch_on/2, dispatch_app/2, 17 | chain_apps/2, 18 | encode_url/2, decode_url_part/1]). 19 | 20 | -import(psycho, [env_val/2, env_header/3]). 21 | 22 | %%%=================================================================== 23 | %%% Date functions 24 | %%%=================================================================== 25 | 26 | http_date(UTC) -> 27 | {{Year, Month, Day}, {Hour, Min, Sec}} = UTC, 28 | DayNum = calendar:day_of_the_week({Year, Month, Day}), 29 | io_lib:format( 30 | "~s, ~2.2.0w ~3.s ~4.4.0w ~2.2.0w:~2.2.0w:~2.2.0w GMT", 31 | [day_name(DayNum), Day, month_name(Month), Year, Hour, Min, Sec]). 32 | 33 | day_name(1) -> "Mon"; 34 | day_name(2) -> "Tue"; 35 | day_name(3) -> "Wed"; 36 | day_name(4) -> "Thu"; 37 | day_name(5) -> "Fri"; 38 | day_name(6) -> "Sat"; 39 | day_name(7) -> "Sun". 40 | 41 | month_name(1) -> "Jan"; 42 | month_name(2) -> "Feb"; 43 | month_name(3) -> "Mar"; 44 | month_name(4) -> "Apr"; 45 | month_name(5) -> "May"; 46 | month_name(6) -> "Jun"; 47 | month_name(7) -> "Jul"; 48 | month_name(8) -> "Aug"; 49 | month_name(9) -> "Sep"; 50 | month_name(10) -> "Oct"; 51 | month_name(11) -> "Nov"; 52 | month_name(12) -> "Dec". 53 | 54 | %%%=================================================================== 55 | %%% Request path 56 | %%%=================================================================== 57 | 58 | ensure_parsed_request_path(Env) -> 59 | handle_parsed_request_path(env_val(parsed_request_path, Env), Env). 60 | 61 | handle_parsed_request_path(undefined, Env) -> 62 | Parsed = parse_request_path(env_val(request_path, Env)), 63 | {Parsed, [{parsed_request_path, Parsed}|Env]}; 64 | handle_parsed_request_path(Parsed, Env) -> 65 | {Parsed, Env}. 66 | 67 | parse_request_path(Path) -> 68 | {ParsedPath, QueryString} = split_once(Path, $?, ""), 69 | {ParsedPath, QueryString, parse_query_string(QueryString)}. 70 | 71 | split_once([Delim|Rest], Delim, Acc) -> 72 | {lists:reverse(Acc), Rest}; 73 | split_once([Char|Rest], Delim, Acc) -> 74 | split_once(Rest, Delim, [Char|Acc]); 75 | split_once([], _Delim, Acc) -> 76 | {lists:reverse(Acc), ""}. 77 | 78 | split_path([$/|RestPath]) -> 79 | split_path(RestPath); 80 | split_path(Path) -> 81 | split_all(Path, $/, "", []). 82 | 83 | %%%=================================================================== 84 | %%% Query string 85 | %%%=================================================================== 86 | 87 | parse_query_string(QS) when is_list(QS) -> 88 | [qs_nameval(Part) || Part <- split_qs(QS)]; 89 | parse_query_string(QS) when is_binary(QS) -> 90 | parse_query_string(binary_to_list(QS)). 91 | 92 | split_qs("") -> []; 93 | split_qs(QS) -> 94 | split_all(QS, $&, "", []). 95 | 96 | split_all([Delim|Rest], Delim, Cur, Acc) -> 97 | split_all(Rest, Delim, "", [lists:reverse(Cur)|Acc]); 98 | split_all([Char|Rest], Delim, Cur, Acc) -> 99 | split_all(Rest, Delim, [Char|Cur], Acc); 100 | split_all([], _Delim, Last, Acc) -> 101 | lists:reverse([lists:reverse(Last)|Acc]). 102 | 103 | qs_nameval(Str) -> 104 | {Name, Value} = split_once(Str, $=, ""), 105 | {unescape_qs_val(Name), unescape_qs_val(Value)}. 106 | 107 | unescape_qs_val(Str) -> 108 | unescape_qs_val(Str, []). 109 | 110 | unescape_qs_val([$%, H1, H2|Rest], Acc) -> 111 | Ch = list_to_integer([H1, H2], 16), 112 | unescape_qs_val(Rest, [Ch|Acc]); 113 | unescape_qs_val([$+|Rest], Acc) -> 114 | unescape_qs_val(Rest, [$ |Acc]); 115 | unescape_qs_val([Ch|Rest], Acc) -> 116 | unescape_qs_val(Rest, [Ch|Acc]); 117 | unescape_qs_val([], Acc) -> 118 | lists:reverse(Acc). 119 | 120 | %%%=================================================================== 121 | %%% Cookies 122 | %%%=================================================================== 123 | 124 | ensure_parsed_cookie(Env) -> 125 | handle_parsed_cookie(env_val(parsed_cookie, Env), Env). 126 | 127 | handle_parsed_cookie(undefined, Env) -> 128 | Parsed = parse_cookie(env_header("Cookie", Env, "")), 129 | {Parsed, [{parsed_cookie, Parsed}|Env]}; 130 | handle_parsed_cookie(Parsed, Env) -> 131 | {Parsed, Env}. 132 | 133 | parse_cookie(C) when is_list(C) -> 134 | [cookie_nameval(Part) || Part <- split_cookie(C)]; 135 | parse_cookie(C) when is_binary(C) -> 136 | parse_cookie(binary_to_list(C)). 137 | 138 | split_cookie(C) -> 139 | split_all(C, $;, "", []). 140 | 141 | cookie_nameval(Str) -> 142 | {Name, Value} = split_once(Str, $=, ""), 143 | {strip_spaces(Name), strip_spaces(Value)}. 144 | 145 | strip_spaces(S) -> string:strip(S, both, $ ). 146 | 147 | cookie_header(Name, Value) -> 148 | cookie_header(Name, Value, []). 149 | 150 | cookie_header(Name, Value, Options) -> 151 | Base = [Name, "=", Value, "; Version=1"], 152 | {"Set-Cookie", append_cookie_options(Options, Base)}. 153 | 154 | append_cookie_options([], Val) -> Val; 155 | append_cookie_options([{domain, Domain}|Rest], Val) -> 156 | append_cookie_options(Rest, [Val, "; Domain=", Domain]); 157 | append_cookie_options([{path, Path}|Rest], Val) -> 158 | append_cookie_options(Rest, [Val, "; Path=", Path]); 159 | append_cookie_options([{max_age, Secs}|Rest], Val) when is_integer(Secs) -> 160 | {Expires, MaxAge} = cookie_max_age(Secs), 161 | NewVal = [Val, "; Expires=", Expires, "; Max-Age=", MaxAge], 162 | append_cookie_options(Rest, NewVal); 163 | append_cookie_options([{secure, true}|Rest], Val) -> 164 | append_cookie_options(Rest, [Val, "; Secure"]); 165 | append_cookie_options([{secure, false}|Rest], Val) -> 166 | append_cookie_options(Rest, Val); 167 | append_cookie_options([{http_only, true}|Rest], Val) -> 168 | append_cookie_options(Rest, [Val, "; HttpOnly"]); 169 | append_cookie_options([{http_only, false}|Rest], Val) -> 170 | append_cookie_options(Rest, Val); 171 | append_cookie_options([Other|_], _Val) -> 172 | error({invalid_cookie_option, Other}). 173 | 174 | 175 | cookie_max_age(0) -> 176 | {"Thu, 01 Jan 1970 00:00:01 GMT", "0"}; 177 | cookie_max_age(MaxAgeSecs) -> 178 | UTC = calendar:universal_time(), 179 | Secs = calendar:datetime_to_gregorian_seconds(UTC), 180 | ExpireDate = calendar:gregorian_seconds_to_datetime(Secs + MaxAgeSecs), 181 | MaxAge = io_lib:format("~b", [MaxAgeSecs]), 182 | Expires = psycho_datetime:rfc1123(ExpireDate), 183 | {Expires, MaxAge}. 184 | 185 | 186 | %%%=================================================================== 187 | %%% Crypto 188 | %%%=================================================================== 189 | 190 | -define(NULL_IV_128, <<0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0>>). 191 | 192 | decrypt(Data, Key) -> 193 | PaddedKey = pad(Key, 16), 194 | unpad(crypto:block_decrypt(aes_cbc128, PaddedKey, ?NULL_IV_128, Data)). 195 | 196 | encrypt(Data, Key) -> 197 | PaddedData = pad(Data, 16), 198 | PaddedKey = pad(Key, 16), 199 | crypto:block_encrypt(aes_cbc128, PaddedKey, ?NULL_IV_128, PaddedData). 200 | 201 | pad(Bin, BlockSize) -> 202 | PadCount = BlockSize - (size(Bin) rem BlockSize), 203 | Pad = binary:copy(<>, PadCount), 204 | <>. 205 | 206 | unpad(Bin) -> 207 | try binary:part(Bin, 0, size(Bin) - binary:last(Bin)) of 208 | Unpadded -> {ok, Unpadded} 209 | catch 210 | error:badarg -> error 211 | end. 212 | 213 | %%%=================================================================== 214 | %%% Form validation 215 | %%%=================================================================== 216 | 217 | %% TODO: This validation scheme is incomplete. Add more checks (e.g. 218 | %% max_length, pattern, etc.) and possibly more conversion. 219 | 220 | validate(Data, Schema) -> 221 | validate(Data, Schema, []). 222 | 223 | validate(DataIn, [{Field, Checks}|Rest], DataOut) -> 224 | Value = proplists:get_value(Field, DataIn), 225 | handle_apply_checks( 226 | apply_checks(Value, Checks, Field, DataIn), 227 | DataIn, Rest, DataOut); 228 | validate(_DataIn, [], DataOut) -> 229 | {ok, DataOut}. 230 | 231 | apply_checks(Value, [Check|Rest], Field, Data) -> 232 | handle_apply_check( 233 | apply_check(Check, Value, Data), 234 | Check, Value, Rest, Field, Data); 235 | apply_checks(Value, [], Field, _Data) -> 236 | {ok, {Field, Value}}. 237 | 238 | apply_check(required, Value, _Data) -> 239 | Value /= undefined; 240 | apply_check({must_equal, {field, Field}}, Value, Data) -> 241 | Value == proplists:get_value(Field, Data); 242 | apply_check({must_equal, Target}, Value, _Data) -> 243 | Value == Target; 244 | apply_check(TargetStr, Value, _Data) when is_list(TargetStr) -> 245 | Value == TargetStr; 246 | apply_check(binary, Value, _Data) -> 247 | {true, list_to_binary(Value)}; 248 | apply_check(integer, Value, _Data) -> 249 | try_integer(Value); 250 | apply_check(float, Value, _Data) -> 251 | try_float(Value); 252 | apply_check(number, Value, _Data) -> 253 | try_number(Value); 254 | apply_check({min_length, MinLength}, Value, _Data) -> 255 | Value /= undefined andalso iolist_size(Value) >= MinLength; 256 | apply_check({any, Checks}, Value, Data) -> 257 | try_any(Checks, Value, Data); 258 | apply_check(Check, _Value, _Data) -> 259 | error({invalid_check, Check}). 260 | 261 | try_integer(undefined) -> 262 | {true, undefined}; 263 | try_integer(Value) -> 264 | try list_to_integer(Value) of 265 | I -> {true, I} 266 | catch 267 | _:_ -> false 268 | end. 269 | 270 | try_float(undefined) -> 271 | {true, undefined}; 272 | try_float(Value) -> 273 | try list_to_float(Value) of 274 | F -> {true, F} 275 | catch 276 | _:_ -> 277 | try list_to_integer(Value) of 278 | I -> {true, float(I)} 279 | catch 280 | _:_ -> false 281 | end 282 | end. 283 | 284 | try_number(undefined) -> 285 | {true, undefined}; 286 | try_number(Value) -> 287 | try list_to_integer(Value) of 288 | I -> {true, I} 289 | catch 290 | _:_ -> 291 | try list_to_float(Value) of 292 | F -> {true, F} 293 | catch 294 | _:_ -> false 295 | end 296 | end. 297 | 298 | try_any([Check|Rest], ValueIn, Data) -> 299 | case apply_check(Check, ValueIn, Data) of 300 | true -> true; 301 | {true, ValueOut} -> {true, ValueOut}; 302 | false -> try_any(Rest, ValueIn, Data) 303 | end; 304 | try_any([], _Value, _Data) -> 305 | false. 306 | 307 | handle_apply_check(true, _Check, Value, Rest, Field, Data) -> 308 | apply_checks(Value, Rest, Field, Data); 309 | handle_apply_check({true, NewValue}, _Check, _Value, Rest, Field, Data) -> 310 | apply_checks(NewValue, Rest, Field, Data); 311 | handle_apply_check(false, Check, _Value, _Rest, Field, _Data) -> 312 | {error, {Field, Check}}. 313 | 314 | handle_apply_checks({ok, Validated}, DataIn, Rest, DataOut) -> 315 | validate(DataIn, Rest, maybe_add_validated(Validated, DataOut)); 316 | handle_apply_checks({error, Err}, _DataIn, _Rest, _DataOut) -> 317 | {error, Err}. 318 | 319 | maybe_add_validated({_, undefined}, Data) -> Data; 320 | maybe_add_validated(Validated, Data) -> [Validated|Data]. 321 | 322 | %% TODO: this is misconceived hard-coded EN -- where should this be? 323 | format_validate_error({Field, required}) -> 324 | io_lib:format("~s is required", [Field]); 325 | format_validate_error({Field, {must_equal, {field, Target}}}) -> 326 | io_lib:format("~s must match ~s", [Field, Target]); 327 | format_validate_error({Field, {must_equal, Target}}) -> 328 | io_lib:format("~s must be ~s", [Field, Target]); 329 | format_validate_error({Field, Target}) when is_list(Target) -> 330 | io_lib:format("~s must be ~s", [Field, Target]); 331 | format_validate_error({Field, {min_length, MinLength}}) -> 332 | io_lib:format( 333 | "~s must be at least ~b characters long", 334 | [Field, MinLength]); 335 | format_validate_error({Field, NumType}) 336 | when NumType == integer; 337 | NumType == float; 338 | NumType == number -> 339 | io_lib:format("~s must be a valid ~s", [Field, NumType]); 340 | format_validate_error({Field, {any, Checks}}) -> 341 | FormattedChecks = [format_validate_error({Field, C}) || C <- Checks], 342 | string:join(FormattedChecks, " or "); 343 | format_validate_error(Other) -> 344 | io_lib:format("~p", [Other]). 345 | 346 | %%%=================================================================== 347 | %%% Parsing content-disposition multipart part header 348 | %%%=================================================================== 349 | 350 | parse_content_disposition(S) -> 351 | handle_split_content_disp(re:split(S, "; *", [{return, list}])). 352 | 353 | handle_split_content_disp(["form-data"|Vars]) -> 354 | acc_content_disp_namevals(Vars, []). 355 | 356 | acc_content_disp_namevals([S|Rest], Acc) -> 357 | handle_disp_nameval_match(disp_nameval_match(S), Rest, Acc); 358 | acc_content_disp_namevals([], Acc) -> 359 | Acc. 360 | 361 | disp_nameval_match(S) -> 362 | re:run(S, "(.*?)=\"(.*?)\"", [{capture, all_but_first, list}]). 363 | 364 | handle_disp_nameval_match({match, [Name, Val]}, Rest, Acc) -> 365 | acc_content_disp_namevals(Rest, [{Name, Val}|Acc]); 366 | handle_disp_nameval_match(nomatch, Rest, Acc) -> 367 | acc_content_disp_namevals(Rest, Acc). 368 | 369 | content_disposition(Name, PartHeaders) -> 370 | Unparsed = proplists:get_value("Content-Disposition", PartHeaders), 371 | Parsed = parse_content_disposition(Unparsed), 372 | proplists:get_value(Name, Parsed). 373 | 374 | %%%=================================================================== 375 | %%% Module directory functions 376 | %%%=================================================================== 377 | 378 | app_dir(Mod) -> 379 | BeamDir = filename:dirname(code:which(Mod)), 380 | filename:dirname(BeamDir). 381 | 382 | priv_dir(Mod) -> 383 | filename:join(app_dir(Mod), "priv"). 384 | 385 | priv_dir(Mod, Subdir) -> 386 | filename:join(priv_dir(Mod), Subdir). 387 | 388 | %%%=================================================================== 389 | %%% Dispatch helpers 390 | %%%=================================================================== 391 | 392 | dispatch_on(Parts, Env) -> 393 | {_, AccResolved} = lists:foldl(fun dispatch_part_acc/2, {Env, []}, Parts), 394 | lists:reverse(AccResolved). 395 | 396 | dispatch_part_acc(env, {Env, Acc}) -> 397 | {Env, [Env|Acc]}; 398 | dispatch_part_acc(method, {Env, Acc}) -> 399 | {Env, [psycho:env_val(request_method, Env)|Acc]}; 400 | dispatch_part_acc(path, {Env0, Acc}) -> 401 | {{Path, _, _}, Env} = ensure_parsed_request_path(Env0), 402 | {Env, [Path|Acc]}; 403 | dispatch_part_acc(split_path, {Env0, Acc}) -> 404 | {{Path, _, _}, Env} = ensure_parsed_request_path(Env0), 405 | {Env, [psycho_util:split_path(Path)|Acc]}; 406 | dispatch_part_acc(parsed_query_string, {Env0, Acc}) -> 407 | {{_, _, ParsedQS}, Env} = ensure_parsed_request_path(Env0), 408 | {Env, [ParsedQS|Acc]}; 409 | dispatch_part_acc(parsed_path, {Env0, Acc}) -> 410 | {ParsedPath, Env} = ensure_parsed_request_path(Env0), 411 | {Env, [ParsedPath|Acc]}; 412 | dispatch_part_acc(query_string, {Env0, Acc}) -> 413 | {{_, QS, _}, Env} = ensure_parsed_request_path(Env0), 414 | {Env, [QS|Acc]}; 415 | dispatch_part_acc(parsed_cookie, {Env0, Acc}) -> 416 | {ParsedCookie, Env} = ensure_parsed_cookie(Env0), 417 | {Env, [ParsedCookie|Acc]}; 418 | dispatch_part_acc(Literal, {Env, Acc}) -> 419 | {Env, [Literal|Acc]}. 420 | 421 | dispatch_app({M, F}, On) -> 422 | fun(Env) -> apply(M, F, dispatch_on(On, Env)) end; 423 | dispatch_app(M, On) when is_atom(M) -> 424 | fun(Env) -> apply(M, app, dispatch_on(On, Env)) end; 425 | dispatch_app(Fun, On) when is_function(Fun) -> 426 | fun(Env) -> apply(Fun, dispatch_on(On, Env)) end. 427 | 428 | %%%=================================================================== 429 | %%% Chain apps 430 | %%%=================================================================== 431 | 432 | chain_apps(Base, Middleware) -> 433 | lists:foldl(fun create_middleware_app/2, Base, Middleware). 434 | 435 | create_middleware_app(Create, Upstream) -> Create(Upstream). 436 | 437 | %%%=================================================================== 438 | %%% Encode URL 439 | %%%=================================================================== 440 | 441 | encode_url(Base, Params) -> 442 | [Base, $?|encode_url_params(Params, [])]. 443 | 444 | encode_url_params([Param|Rest], Acc) -> 445 | encode_url_params(Rest, apply_encoded_param(Param, Acc)); 446 | encode_url_params([], Acc) -> 447 | lists:reverse(Acc). 448 | 449 | apply_encoded_param(Param, []) -> 450 | [encode_param(Param)]; 451 | apply_encoded_param(Param, Acc) -> 452 | [encode_param(Param), $&|Acc]. 453 | 454 | encode_param({Name, Val}) -> 455 | [uri_part_encode(Name), $=, uri_part_encode(Val)]; 456 | encode_param(Val) -> 457 | uri_part_encode(Val). 458 | 459 | uri_part_encode(L) when is_list(L) -> 460 | http_uri:encode(L); 461 | uri_part_encode(B) when is_binary(B) -> 462 | http_uri:encode(binary_to_list(B)). 463 | 464 | %%%=================================================================== 465 | %%% Decode 466 | %%%=================================================================== 467 | 468 | decode_url_part(Part) -> http_uri:decode(Part). 469 | -------------------------------------------------------------------------------- /src/sample_echo.erl: -------------------------------------------------------------------------------- 1 | -module(sample_echo). 2 | 3 | -export([app/1]). 4 | 5 | app(Env) -> 6 | {{200, "OK"}, [{"Content-Type", "text/plain"}], echo_env(Env)}. 7 | 8 | echo_env(Env) -> 9 | ["ENVIRONMENT:\n", 10 | io_lib:format("~p", [Env]), "\n" 11 | ]. 12 | -------------------------------------------------------------------------------- /src/sample_hello.erl: -------------------------------------------------------------------------------- 1 | -module(sample_hello). 2 | 3 | -export([start_server/1, app/1]). 4 | 5 | -include("http_status.hrl"). 6 | 7 | start_server(Port) -> 8 | psycho_server:start(Port, ?MODULE). 9 | 10 | app(_Env) -> 11 | {?status_ok, [{"Content-Type", "text/plain"}], "Hello!"}. 12 | -------------------------------------------------------------------------------- /src/sample_middleware.erl: -------------------------------------------------------------------------------- 1 | -module(sample_middleware). 2 | 3 | -export([header_footer/3, 4 | basic_auth/4, 5 | cookie_auth/2, user_cookie/1, clear_user_cookie/0]). 6 | 7 | %%%=================================================================== 8 | %%% Header / Footer 9 | %%%=================================================================== 10 | 11 | header_footer(Header, Footer, App) -> 12 | fun(Env) -> header_footer_app(Header, Footer, App, Env) end. 13 | 14 | header_footer_app(Header, Footer, App, Env) -> 15 | handle_header_footer_app(psycho:call_app(App, Env), Header, Footer). 16 | 17 | handle_header_footer_app({{200, "OK"}, Headers, Body}, Header, Footer) -> 18 | {{200, "OK"}, Headers, [Header, Body, Footer]}; 19 | handle_header_footer_app(AppResp, _Header, _Footer) -> 20 | AppResp. 21 | 22 | %%%=================================================================== 23 | %%% Basic Authentication 24 | %%%=================================================================== 25 | 26 | basic_auth(Realm, User, Password, App) -> 27 | UserBin = iolist_to_binary(User), 28 | PasswordBin = iolist_to_binary(Password), 29 | fun(Env) -> basic_auth_app(Realm, UserBin, PasswordBin, App, Env) end. 30 | 31 | basic_auth_app(Realm, User, Password, App, Env) -> 32 | Creds = basic_credentials(Env), 33 | CheckResult = check_basic_creds(Creds, User, Password), 34 | handle_basic_auth_request(CheckResult, App, Env, Realm). 35 | 36 | basic_credentials(Env) -> 37 | basic_creds_from_auth_header(psycho:env_header("Authorization", Env)). 38 | 39 | basic_creds_from_auth_header("Basic " ++ Base64Encoded) -> 40 | parse_decoded_auth_header(base64:decode(Base64Encoded)); 41 | basic_creds_from_auth_header(undefined) -> undefined. 42 | 43 | parse_decoded_auth_header(Header) -> 44 | [User|PwdParts] = binary:split(Header, <<":">>), 45 | {User, iolist_to_binary(PwdParts)}. 46 | 47 | check_basic_creds({User, Password}, User, Password) -> {pass, User}; 48 | check_basic_creds(_Creds, _User, _Password) -> fail. 49 | 50 | handle_basic_auth_request({pass, User}, App, Env, _Realm) -> 51 | handle_basic_authorized(User, App, Env); 52 | handle_basic_auth_request(fail, _App, _Env, Realm) -> 53 | handle_basic_unauthorized(Realm). 54 | 55 | handle_basic_authorized(User, App, Env) -> 56 | psycho:call_app(App, add_user_to_env(User, Env)). 57 | 58 | add_user_to_env(User, Env) -> 59 | [{remote_user, User}|Env]. 60 | 61 | handle_basic_unauthorized(Realm) -> 62 | Headers = 63 | [{"WWW-Authenticate", ["Basic realm=", Realm]}, 64 | {"Content-Type", "text/plain"}], 65 | Msg = "Not Authorized", 66 | {{401, "Not Authorized"}, Headers, Msg}. 67 | 68 | %%%=================================================================== 69 | %%% Cookie Authentication 70 | %%%=================================================================== 71 | 72 | -define(USER_KEY, <<1,2,3,4,5,6,7,8>>). 73 | 74 | cookie_auth(Form, App) -> 75 | fun(Env) -> cookie_auth_app(Form, App, Env) end. 76 | 77 | cookie_auth_app(Form, App, Env) -> 78 | handle_cookie_user(cookie_user(Env), Form, App). 79 | 80 | cookie_user(Env0) -> 81 | {Cookie, Env} = psycho_util:ensure_parsed_cookie(Env0), 82 | User = try_decrypt_user_cookie(user_cookie_val(Cookie)), 83 | {User, Env}. 84 | 85 | user_cookie_val(Cookie) -> 86 | proplists:get_value("user", Cookie). 87 | 88 | try_decrypt_user_cookie(undefined) -> undefined; 89 | try_decrypt_user_cookie(Encoded) when is_list(Encoded) -> 90 | try_decrypt_user_cookie(try_base64_decode(Encoded)); 91 | try_decrypt_user_cookie(UserBin) when is_binary(UserBin) -> 92 | handle_user_decrypt(psycho_util:decrypt(UserBin, ?USER_KEY)). 93 | 94 | try_base64_decode(Encoded) -> 95 | handle_base64_decode(catch base64:decode(Encoded)). 96 | 97 | handle_base64_decode({'EXIT', _}) -> undefined; 98 | handle_base64_decode(Decoded) -> Decoded. 99 | 100 | handle_user_decrypt({ok, UserBin}) -> binary_to_list(UserBin); 101 | handle_user_decrypt(error) -> undefined. 102 | 103 | handle_cookie_user({undefined, Env}, Form, _App) -> 104 | Form(Env); 105 | handle_cookie_user({User, Env}, _Form, App) -> 106 | App([{remote_user, User}|Env]). 107 | 108 | user_cookie(User) -> 109 | psycho_util:cookie_header("user", encrypt_user(User)). 110 | 111 | encrypt_user(User) -> 112 | UserBin = list_to_binary(User), 113 | Encrypted = psycho_util:encrypt(UserBin, ?USER_KEY), 114 | base64:encode(Encrypted). 115 | 116 | clear_user_cookie() -> 117 | {"Set-Cookie", "user=;"}. 118 | -------------------------------------------------------------------------------- /src/sample_proc.erl: -------------------------------------------------------------------------------- 1 | -module(sample_proc). 2 | 3 | -behavior(proc). 4 | 5 | -export([start_link/0, ping/1, ping_async/1]). 6 | 7 | -export([handle_msg/3]). 8 | 9 | start_link() -> 10 | proc:start_link(?MODULE, []). 11 | 12 | ping(Proc) -> 13 | proc:call(Proc, ping). 14 | 15 | ping_async(Proc) -> 16 | proc:call(Proc, ping_async). 17 | 18 | handle_msg(ping, _From, State) -> 19 | {reply, pong, State}; 20 | handle_msg(ping_async, From, State) -> 21 | proc_lib:spawn_link(reply_async_fun(From, pong)), 22 | {noreply, State}. 23 | 24 | reply_async_fun(From, Reply) -> 25 | fun() -> proc:reply(From, Reply) end. 26 | -------------------------------------------------------------------------------- /src/sample_rest.erl: -------------------------------------------------------------------------------- 1 | %% sample_rest 2 | %% 3 | %% This module illustrates a simple approach to building RESTful apps. 4 | %% The only novelty here is the use of `psycho_util:dispatch_app` to 5 | %% split the inbound Env into parts that are useful for dispatching. 6 | %% In this case we're dispatching on method, path, and env - which 7 | %% creates a reasonably declarative mapping between requests and 8 | %% method handlers. 9 | 10 | -module(sample_rest). 11 | 12 | -export([start_server/1]). 13 | 14 | %%=================================================================== 15 | %% Start / init 16 | %%=================================================================== 17 | 18 | start_server(Port) -> 19 | psycho_server:start(Port, create_app()). 20 | 21 | create_app() -> 22 | psycho_util:dispatch_app(fun handle/3, [method, path, env]). 23 | 24 | %%=================================================================== 25 | %% Main handler 26 | %%=================================================================== 27 | 28 | handle("GET", Path, Env) -> handle_get(Path, Env); 29 | handle("PUT", Path, Env) -> handle_put(Path, Env); 30 | handle(Method, _Path, _Env) -> bad_method(Method). 31 | 32 | %%=================================================================== 33 | %% Method specific handlers 34 | %%=================================================================== 35 | 36 | handle_get(Path, _Env) -> ok(["GET ", Path]). 37 | 38 | handle_put(Path, _Env) -> ok(["PUT ", Path]). 39 | 40 | %%=================================================================== 41 | %% Response helpers 42 | %%=================================================================== 43 | 44 | ok(Text) -> 45 | {{200, "OK"}, [{"Content-Type", "text/plain"}], Text}. 46 | 47 | bad_method(Method) -> 48 | Msg = ["Bad method ", Method], 49 | {{400, "Bad Request"}, [{"Content-Type", "text/plain"}], Msg}. 50 | -------------------------------------------------------------------------------- /src/sample_static.erl: -------------------------------------------------------------------------------- 1 | -module(sample_static). 2 | 3 | -export([start_server/2]). 4 | 5 | start_server(Port, Dir) -> 6 | psycho_server:start(Port, psycho_static:create_app(Dir)). 7 | --------------------------------------------------------------------------------