├── .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 |
--------------------------------------------------------------------------------