diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..e0d2af0
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,23 @@
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+
+[CMakeLists.txt]
+indent_style = space
+indent_size = 4
+
+[*.cmake]
+indent_style = space
+indent_size = 4
+
+[*.lua]
+indent_style = space
+indent_size = 4
+
+[*.{h,c,cc}]
+indent_style = tab
+tab_width = 8
diff --git a/.gitignore b/.gitignore
index 9fa6709..0924cb6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,7 @@ install_manifest.txt
VERSION
Testing
CTestTestfile.cmake
+*.snap
+*.xlog
+VERSION.lua
+.rocks
diff --git a/.luacheckrc b/.luacheckrc
new file mode 100644
index 0000000..9320391
--- /dev/null
+++ b/.luacheckrc
@@ -0,0 +1,36 @@
+redefined = false
+include_files = {"**/*.lua", "*.rockspec", "*.luacheckrc"}
+exclude_files = {"lua_modules", ".luarocks", ".rocks", "luatest/luaunit.lua", "build"}
+new_read_globals = {
+ 'box',
+ '_TARANTOOL',
+ 'tonumber64',
+ os = {
+ fields = {
+ 'environ',
+ }
+ },
+ string = {
+ fields = {
+ 'split',
+ 'startswith',
+ },
+ },
+ table = {
+ fields = {
+ 'maxn',
+ 'copy',
+ 'new',
+ 'clear',
+ 'move',
+ 'foreach',
+ 'sort',
+ 'remove',
+ 'foreachi',
+ 'deepcopy',
+ 'getn',
+ 'concat',
+ 'insert',
+ },
+ },
+}
diff --git a/.travis.yml b/.travis.yml
index 68a8c2a..fa786ff 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,146 +2,92 @@ sudo: false
language: C
services:
- docker
+dist: xenial
cache:
- directories:
- - $HOME/.cache
+ directories:
+ - $HOME/.cache
git:
- depth: 100500
+ depth: 100500
env:
- global:
- - PRODUCT=tarantool-http
+ global:
+ - PRODUCT=tarantool-http
- matrix:
- - OS=el DIST=6
- - OS=el DIST=7
- - OS=fedora DIST=26
- - OS=fedora DIST=27
- - OS=fedora DIST=28
- - OS=fedora DIST=29
- - OS=ubuntu DIST=trusty
- - OS=ubuntu DIST=xenial
- - OS=ubuntu DIST=bionic
- - OS=ubuntu DIST=cosmic
- - OS=debian DIST=jessie
- - OS=debian DIST=stretch
+_test: &test
+ before_install:
+ - curl http://download.tarantool.org/tarantool/$TARANTOOL_VERSION/gpgkey | sudo apt-key add -
+ - echo "deb http://download.tarantool.org/tarantool/$TARANTOOL_VERSION/ubuntu/ xenial main" |
+ sudo tee /etc/apt/sources.list.d/tarantool.list
+ - sudo apt-get -y update
+ - sudo apt-get install -y tarantool tarantool-dev
+ - tarantoolctl rocks make
+ - tarantoolctl rocks install luatest 0.2.2
+ script: .rocks/bin/luatest
-script:
- - git describe --long
- - git clone https://github.com/packpack/packpack.git packpack
- - packpack/packpack
+_deploy: &deploy
+ provider: packagecloud
+ username: tarantool
+ token: ${PACKAGECLOUD_TOKEN}
+ dist: ${OS}/${DIST}
+ package_glob: build/*.{deb,rpm}
+ skip_cleanup: true
+ on:
+ branch: master
+ condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
-before_deploy:
- - ls -l build/
+_packpack: &packpack
+ stage: deploy
+ script:
+ - git describe --long
+ - git clone https://github.com/packpack/packpack.git packpack
+ - packpack/packpack
+ - ls -l build/
+ deploy:
+ # Deploy packages to PackageCloud
+ - <<: *deploy
+ repository: "1_7"
+ - <<: *deploy
+ repository: "1_9"
+ - <<: *deploy
+ repository: "1_10"
+ - <<: *deploy
+ repository: "2x"
+ - <<: *deploy
+ repository: "2_2"
-deploy:
- # Deploy packages to PackageCloud from master branch
- - provider: packagecloud
- username: tarantool
- repository: "1_7"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- branch: master
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "1_9"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- branch: master
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "1_10"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- branch: master
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "2x"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- branch: master
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "2_2"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- branch: master
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- # Deploy packages to PackageCloud from tags
- # see:
- # * https://github.com/tarantool/tarantool/issues/3745
- # * https://github.com/travis-ci/travis-ci/issues/7780#issuecomment-302389370
- - provider: packagecloud
- username: tarantool
- repository: "1_7"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- tags: true
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "1_9"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- tags: true
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "1_10"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- tags: true
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "2x"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- tags: true
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
- - provider: packagecloud
- username: tarantool
- repository: "2_2"
- token: ${PACKAGECLOUD_TOKEN}
- dist: ${OS}/${DIST}
- package_glob: build/*.{deb,rpm}
- skip_cleanup: true
- on:
- tags: true
- condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}"
+jobs:
+ include:
+ - <<: *test
+ env: TARANTOOL_VERSION=1.10
+ - <<: *test
+ env: TARANTOOL_VERSION=2x
+
+ - <<: *packpack
+ env: OS=el DIST=6
+ - <<: *packpack
+ env: OS=el DIST=7
+ - <<: *packpack
+ env: OS=fedora DIST=26
+ - <<: *packpack
+ env: OS=fedora DIST=27
+ - <<: *packpack
+ env: OS=fedora DIST=28
+ - <<: *packpack
+ env: OS=fedora DIST=29
+ - <<: *packpack
+ env: OS=ubuntu DIST=trusty
+ - <<: *packpack
+ env: OS=ubuntu DIST=xenial
+ - <<: *packpack
+ env: OS=ubuntu DIST=bionic
+ - <<: *packpack
+ env: OS=ubuntu DIST=cosmic
+ - <<: *packpack
+ env: OS=debian DIST=jessie
+ - <<: *packpack
+ env: OS=debian DIST=stretch
notifications:
email:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d25fa5..d85114c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
-- Added options `log_requests` and `log_errors` to `route()` method for customizing request log output and error log output respectively.
+### Added
+- Major rewrite since version 1.x
+- Ability to be used with internal http server and an nginx upstream module
+ (without modifying the backend code)
+- Standardized request object (similar to WSGI)
+- A new router with route priorities inspired by Mojolicious
+- Middleware support (for e.g. for centrally handling authorization)
## [1.0.3] - 2018-06-29
### Added
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9457d19..67fa1c4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -21,7 +21,7 @@ enable_testing()
set (LUA_PATH "LUA_PATH=${PROJECT_SOURCE_DIR}/?.lua\\;${PROJECT_SOURCE_DIR}/?/init.lua\\;\\;")
set (LUA_SOURCE_DIR "LUA_SOURCE_DIR=${PROJECT_SOURCE_DIR}")
-add_test(http ${CMAKE_SOURCE_DIR}/test/http.test.lua)
+add_test(http ${CMAKE_SOURCE_DIR}/.rocks/bin/luatest)
set_tests_properties(http PROPERTIES ENVIRONMENT "${LUA_PATH};${LUA_SOURCE_DIR}")
@@ -29,3 +29,32 @@ set_tests_properties(http PROPERTIES ENVIRONMENT "${LUA_PATH};${LUA_SOURCE_DIR}"
add_custom_target(check
WORKING_DIRECTORY ${PROJECT_BUILD_DIR}
COMMAND ctest -V)
+
+## VERSION ####################################################################
+###############################################################################
+
+execute_process(
+ COMMAND git describe --tags --always
+ WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ OUTPUT_VARIABLE GIT_DESCRIBE
+ ERROR_QUIET
+)
+
+if (NOT GIT_DESCRIBE)
+ set(GIT_DESCRIBE "unknown")
+endif()
+
+configure_file (
+ "${PROJECT_SOURCE_DIR}/http/VERSION.lua.in"
+ "${CMAKE_CURRENT_BINARY_DIR}/VERSION.lua"
+)
+
+
+## Install ####################################################################
+###############################################################################
+
+install(
+ FILES ${CMAKE_CURRENT_BINARY_DIR}/VERSION.lua
+ DESTINATION ${TARANTOOL_INSTALL_LUADIR}/${PROJECT_NAME}/
+)
diff --git a/README.md b/README.md
index d39d5e5..ebbf6b7 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,10 @@ align="right">
[](https://travis-ci.org/tarantool/http)
+> **DISCLAIMER:** Any functionality not described here is subject to change
+> in backward incompatible fashion at any time. Don't rely on source code
+> internals.
+
> **Note:** In Tarantool 1.7.5+, a full-featured HTTP client is available aboard.
> For Tarantool 1.6.5+, both HTTP server and client are available
> [here](https://github.com/tarantool/http/tree/tarantool-1.6).
@@ -18,21 +22,17 @@ align="right">
* [Usage](#usage)
* [Creating a server](#creating-a-server)
* [Using routes](#using-routes)
-* [Contents of app\_dir](#contents-of-app_dir)
* [Route handlers](#route-handlers)
* [Fields and methods of the Request object](#fields-and-methods-of-the-request-object)
* [Fields and methods of the Response object](#fields-and-methods-of-the-response-object)
* [Examples](#examples)
* [Working with stashes](#working-with-stashes)
- * [Special stash names](#special-stash-names)
* [Working with cookies](#working-with-cookies)
-* [Rendering a template](#rendering-a-template)
-* [Template helpers](#template-helpers)
-* [Hooks](#hooks)
- * [handler(httpd, req)](#handlerhttpd-req)
- * [before\_dispatch(httpd, req)](#before_dispatchhttpd-req)
- * [after\_dispatch(cx, resp)](#after_dispatchcx-resp)
-* [See also](#see-also)
+* [Middleware](#middleware)
+ * [router:use(f, opts)](#routerusef-opts)
+ * [f](#f)
+ * [Ordering](#ordering)
+ * [Example](#example)
## Prerequisites
@@ -67,26 +67,38 @@ You can:
## Usage
-The server is an object which is configured with HTTP request
-handlers, routes (paths), templates, and a port to bind to.
+There are 4 main logical objects you can operate with:
+1. **server**, which can be nginx or built-in
+2. **router**, where you define routes and middleware
+3. **route**, a function processing HTTP requests
+4. **middleware**, a function invoked before route handler is invoked
+
+The **server** is an object which implements HTTP protocol and handles all
+lower level stuff like TCP connection.
Unless Tarantool is running under a superuser, port numbers
below 1024 may be unavailable.
The server can be started and stopped anytime. Multiple
servers can be created.
-To start a server:
+The **router** is where you define how and who will handle your requests
+configured with HTTP request handlers, routes (paths), templates,
+and a port to bind to. You need to set the router to a server, for it to be used.
+
+To start a server with a router:
-1. [Create it](#creating-a-server) with `httpd = require('http.server').new(...)`.
-2. [Configure routing](#using-routes) with `httpd:route(...)`.
-3. Start it with `httpd:start()`.
+1. [Create a server](#creating-a-server) with `server = require('http.server').new(...)`.
+2. [Create a router](#creating-a-router) with `router = require('http.router').new(...)`.
+3. Set a router to server with `server:set_router(router)`.
+4. [Configure routing](#using-routes) with `router:route(...)`.
+5. Start serving HTTP requests it with `server:start()`.
-To stop the server, use `httpd:stop()`.
+To stop the server, use `server:stop()`.
## Creating a server
```lua
-httpd = require('http.server').new(host, port[, { options } ])
+server = require('http.server').new(host, port[, { options } ])
```
`host` and `port` must contain:
@@ -95,27 +107,25 @@ httpd = require('http.server').new(host, port[, { options } ])
`options` may contain:
-* `max_header_size` (default is 4096 bytes) - a limit for
- HTTP request header size.
-* `header_timeout` (default: 100 seconds) - a timeout until
- the server stops reading HTTP headers sent by the client.
- The server closes the client connection if the client doesn't
- send its headers within the given amount of time.
-* `app_dir` (default is '.', the server working directory) -
- a path to the directory with HTML templates and controllers.
* `handler` - a Lua function to handle HTTP requests (this is
a handler to use if the module "routing" functionality is not
needed).
-* `charset` - the character set for server responses of
- type `text/html`, `text/plain` and `application/json`.
* `display_errors` - return application errors and backtraces to the client
(like PHP).
-* `log_requests` - log incoming requests. This parameter can receive:
- - function value, supporting C-style formatting: log_requests(fmt, ...), where fmt is a format string and ... is Lua Varargs, holding arguments to be replaced in fmt.
- - boolean value, where `true` choose default `log.info` and `false` disable request logs at all.
+* `log_errors` - log application errors using `log.error()`.
+* `log_requests` - log incoming requests.
+
+## Creating a router
+
+```lua
+router = require('http.router').new(options)
+server:set_router(router)
+```
- By default uses `log.info` function for requests logging.
-* `log_errors` - same as the `log_requests` option but is used for error messages logging. By default uses `log.error()` function.
+`options` may contain:
+
+* `charset` - the character set for server responses of
+ type `text/html`, `text/plain` and `application/json`.
## Using routes
@@ -142,53 +152,21 @@ Route examples:
To configure a route, use the `route()` method of the `httpd` object:
```lua
-httpd:route({ path = '/path/to' }, 'controller#action')
-httpd:route({ path = '/', template = 'Hello <%= var %>' }, handle1)
-httpd:route({ path = '/:abc/cde', file = 'users.html.el' }, handle2)
httpd:route({ path = '/objects', method = 'GET' }, handle3)
...
```
The first argument for `route()` is a Lua table with one or more keys:
-* `file` - a template file name (can be relative to.
- `{app_dir}/templates`, where `app_dir` is the path set when creating the
- server). If no template file name extension is provided, the extension is
- set to ".html.el", meaning HTML with embedded Lua.
-* `template` - template Lua variable name, in case the template
- is a Lua variable. If `template` is a function, it's called on every
- request to get template body. This is useful if template body must be
- taken from a database.
-* `path` - route path, as described earlier.
-* `name` - route name.
-* `method` - method on the route like `POST`, `GET`, `PUT`, `DELETE`
-* `log_requests` - option that overrides the server parameter of the same name but only for current route.
-* `log_errors` - option that overrides the server parameter of the same name but only for current route.
+| key | description |
+| --- | ----------- |
+| `path` | route path, as described earlier. |
+| `name` | route name. |
+| `method` | method on the route like `POST`, `GET`, `PUT`, `DELETE` |
The second argument is the route handler to be used to produce
a response to the request.
-The typical usage is to avoid passing `file` and `template` arguments,
-since they take time to evaluate, but these arguments are useful
-for writing tests or defining HTTP servers with just one "route".
-
-The handler can also be passed as a string of the form 'filename#functionname'.
-In that case, the handler body is taken from a file in the
-`{app_dir}/controllers` directory.
-
-## Contents of `app_dir`
-
-* `public` - a path to static content. Everything stored on this path
- defines a route which matches the file name, and the HTTP server serves this
- file automatically, as is. Notice that the server doesn't use `sendfile()`,
- and it reads the entire content of the file into the memory before passing
- it to the client. ??? Caching is not used, unless turned on. So this is not
- suitable for large files, use nginx instead.
-* `templates` - a path to templates.
-* `controllers` - a path to *.lua files with Lua controllers. For example,
- the controller name 'module.submodule#foo' is mapped to
- `{app_dir}/controllers/module.submodule.lua`.
-
## Route handlers
A route handler is a function which accepts one argument (**Request**) and
@@ -207,38 +185,36 @@ end
### Fields and methods of the Request object
-* `req.method` - HTTP request type (`GET`, `POST` etc).
-* `req.path` - request path.
-* `req.query` - request arguments.
-* `req.proto` - HTTP version (for example, `{ 1, 1 }` is `HTTP/1.1`).
-* `req.headers` - normalized request headers. A normalized header
- is in the lower case, all headers joined together into a single string.
-* `req.peer` - a Lua table with information about the remote peer
- (like `socket:peer()`).
-* `tostring(req)` - returns a string representation of the request.
-* `req:request_line()` - returns the request body.
-* `req:read(delimiter|chunk|{delimiter = x, chunk = x}, timeout)` - reads the
- raw request body as a stream (see `socket:read()`).
-* `req:json()` - returns a Lua table from a JSON request.
-* `req:post_param(name)` - returns a single POST request a parameter value.
- If `name` is `nil`, returns all parameters as a Lua table.
-* `req:query_param(name)` - returns a single GET request parameter value.
- If `name` is `nil`, returns a Lua table with all arguments.
-* `req:param(name)` - any request parameter, either GET or POST.
-* `req:cookie(name)` - to get a cookie in the request.
-* `req:stash(name[, value])` - get or set a variable "stashed"
- when dispatching a route.
-* `req:url_for(name, args, query)` - returns the route's exact URL.
-* `req:render({})` - create a **Response** object with a rendered template.
-* `req:redirect_to` - create a **Response** object with an HTTP redirect.
+| method | description |
+| ------ | ----------- |
+| `req:method()` | HTTP request type (`GET`, `POST` etc). |
+| `req:path()` | request path. |
+| `req:query()` | request arguments. |
+| `req:proto()` | HTTP version (for example, `{ 1, 1 }` is `HTTP/1.1`). |
+| `req:headers()` | normalized request headers. A normalized header. |
+| `req:header(name)` | value of header `name`. |
+| `req:peer()` | a Lua table with information about the remote peer (like `socket:peer()`). **NOTE**: when router is being used with nginx adapter, `req:peer()` contains information on iproto connection with nginx, not the original HTTP user-agent. |
+| `tostring(req)` | returns a string representation of the request.
+| `req:request_line()` | returns the request body.
+| `req:read(delimiter\|chunk\|{delimiter = x, chunk = x}, timeout)` | reads the raw request body as a stream (see `socket:read()`). **NOTE**: when using NGINX TSGI adapter, only `req:read(chunk)` is available. |
+| `req:post_param(name)` | returns a single POST request a parameter value. If `name` is `nil`, returns all parameters as a Lua table. |
+| `req:query_param(name)` | returns a single GET request parameter value. If `name` is `nil`, returns a Lua table with all arguments. |
+| `req:param(name)` | any request parameter, either GET or POST. |
+| `req:cookie(name)` | to get a cookie in the request. |
+| `req:stash(name[, value])` | **NOTE**: currently not supported inside middleware handlers. Get or set a variable "stashed" when dispatching a route. |
+| `req:url_for(name, args, query)` | returns the route's exact URL.
+| `req:redirect_to` | create a **Response** object with an HTTP redirect.
+| `req:next()` | in middleware invokes remaining middleware chain and route handler and returns the response |
+| `req:hijack()` | terminates HTTP connection. Open TCP connection object is returned |
### Fields and methods of the Response object
-* `resp.status` - HTTP response code.
-* `resp.headers` - a Lua table with normalized headers.
-* `resp.body` - response body (string|table|wrapped\_iterator).
-* `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'))` -
- adds `Set-Cookie` headers to `resp.headers`.
+| method | description |
+| ------ | ----------- |
+| `resp.status` | HTTP response code.
+| `resp.headers` | a Lua table with normalized headers.
+| `resp.body` | response body (string|table|wrapped\_iterator).
+| `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'))` | adds `Set-Cookie` headers to `resp.headers`.
### Examples
@@ -274,15 +250,6 @@ httpd:route(
httpd:start()
```
-### Special stash names
-
-* `controller` - the controller name.
-* `action` - the handler name in the controller.
-* `format` - the current output format (e.g. `html`, `txt`). Is
- detected automatically based on the request's `path` (for example, `/abc.js`
- sets `format` to `js`). When producing a response, `format` is used
- to serve the response's 'Content-type:'.
-
## Working with cookies
To get a cookie, use:
@@ -334,107 +301,107 @@ The table must contain the following fields:
* `+1m` - 1 month (30 days)
* `+1y` - 1 year (365 days)
-## Rendering a template
-
-Lua can be used inside a response template, for example:
-
-```html
-
-
- <%= title %>
-
-
-
- % for i = 1, 10 do
- - <%= item[i].key %>: <%= item[i].value %>
- % end
-
-
-
-```
-
-To embed Lua code into a template, use:
+## Middleware
-* `<% lua-here %>` - insert any Lua code, including multi-line.
- Can be used anywhere in the template.
-* `% lua-here` - a single-line Lua substitution. Can only be
- present at the beginning of a line (with optional preceding spaces
- and tabs, which are ignored).
+tarantool/http v2 comes with improved middleware support:
+1. middleware functions control both HTTP request arrival and HTTP response
+return in the same function. As opposed to v1 functions `before_dispatch()`, `after_dispatch()`.
+2. filters on path and method: if request doesn't match path pattern or
+method, the middleware won't be invoked for this particular request.
+3. you can modify order of middleware execution by specifying relations
+between middlewares via optional `opts.after` / `opts.before` arrays on
+middleware creation (see below).
-A few control characters may follow `%`:
+### `router:use(f, opts)`
-* `=` (e.g., `<%= value + 1 %>`) - runs the embedded Lua code
- and inserts the result into HTML. Special HTML characters,
- such as `<`, `>`, `&`, `"`, are escaped.
-* `==` (e.g., `<%== value + 10 %>`) - the same, but without
- escaping.
+#### Parameters
-A Lua statement inside the template has access to the following
-environment:
+| parameter | type | description |
+| ----------- | ------ | ---------------- |
+| `f` | response = function(req) | see explanation below |
+| `opts.path` | string | as in `route(f)` |
+| `opts.method` | string | as in `route()` |
+| `opts.preroute` | bool | when true, middleware will be invoked before routing |
+| `opts.name` | string | middleware name that is referred to when defining order between middleware.
+| `opts.before` | array of strings | middleware names that must be invoked before this middleware |
+| `opts.after` | array of strings | middleware names that must be invoked after this middleware |
+| `return-value` | bool | true, if middleware is added successfully, false otherwise |
-1. Lua variables defined in the template,
-1. stashed variables,
-1. variables standing for keys in the `render` table.
+#### f
+`f` has the same signature as route handler.
-## Template helpers
+Inside `f` use `req:next()` to call next function, which can be
+another middleware handler or a terminating route handler.
-Helpers are special functions that are available in all HTML
-templates. These functions must be defined when creating an `httpd` object.
+**NOTE**: `req:stash()` is currently not working inside middleware handlers.
-Setting or deleting a helper:
-
-```lua
--- setting a helper
-httpd:helper('time', function(self, ...) return box.time() end)
--- deleting a helper
-httpd:helper('some_name', nil)
-```
-
-Using a helper inside an HTML template:
-
-```html
-
- Current timestamp: <%= time() %>
-
-```
+Alternatively, you can return response from `f` before calling
+`req:next()` (early exit), in this case the request will not be
+dispatched to the route handler.
-A helper function can receive arguments. The first argument is
-always the current controller. The rest is whatever is
-passed to the helper from the template.
+This is convenient for example in authorization middleware functions,
+where you can exit with 403 Forbidden on authorization failure.
-## Hooks
+#### Ordering
-It is possible to define additional functions invoked at various
-stages of request processing.
+By default, if you don't specify `before`/`after` options in `router:use()`,
+the order of invokation for any request is the **definition order** (of
+course, if some middleware is filtered-out, it won't be executed)
-### `handler(httpd, req)`
+If you need more complex order of execution between middleware handlers,
+you can do so by providing local execution order:
-If `handler` is present in `httpd` options, it gets
-involved on every HTTP request, and the built-in routing
-mechanism is unused (no other hooks are called in this case).
+```lua
+local ok_b = router:use(b, {name = 'c', before = 'a'})
+local ok_a = router:use(a, {name = 'a'})
+local ok_c = router:use(c, {name = 'b', after = 'a'})
+-- order is a -> b -> c -> route-handler (where "->" is invokation via `req:next()`)
-### `before_dispatch(httpd, req)`
+local ok_d = router:use(d, {before = 'a', after = 'c'})
+assert(ok_d) -- FAIL: cannot satisfy order without breaking specified dependencies
+```
-Is invoked before a request is routed to a handler. The first
-argument of the hook is the HTTP request to be handled.
-The return value of the hook is ignored.
+Internally, in any time, a total-order of middleware is maintained.
+If upon adding new middleware such total-order becomes non-existing,
+the middleware addition is rejected.
-This hook could be used to log a request, or modify request headers.
+#### Example
+```lua
+local users = require('myproject.users')
-### `after_dispatch(cx, resp)`
+local json = require('json')
+local digest = require('digest')
-Is invoked after a handler for a route is executed.
+local function basic_auth_handler(req)
+ local auth = req:header('authorization')
+ if not auth or not auth:find('Basic ') then
+ return {
+ status = 401,
+ body = json.encode({message = 'Missing Authorization Header'})
+ }
+ end
-The arguments of the hook are the request passed into the handler,
-and the response produced by the handler.
+ local base64_credentials = auth:split(' ')[2]
+ local credentials = digest.base64_decode(base64_credentials)
+ local username = credentials:split(':')[1]
+ local password = credentials:split(':')[2]
-This hook can be used to modify the response.
-The return value of the hook is ignored.
+ local user = users.authenticate(username, password)
+ if not user then
+ return {
+ status = 401,
+ body = json.encode({message = 'Invalid Authentication Credentials'})
+ }
+ end
-## See also
+ req.user = user
- * [Tarantool project][Tarantool] on GitHub
- * [Tests][] for the `http` module
+ return req:next()
+end
-[Tarantool]: http://github.com/tarantool/tarantool
-[Tests]: https://github.com/tarantool/http/tree/master/test
+local ok = router:use(basic_auth_handler, {
+ path = '/api/v1', -- e.g. in API v2 a different
+ -- authentication mechanism is used
+ method = 'ANY',
+})
+```
diff --git a/cmake/FindTarantool.cmake b/cmake/FindTarantool.cmake
index c9a9ba3..a938158 100644
--- a/cmake/FindTarantool.cmake
+++ b/cmake/FindTarantool.cmake
@@ -9,7 +9,8 @@ macro(extract_definition name output input)
endmacro()
find_path(TARANTOOL_INCLUDE_DIR tarantool/module.h
- HINTS ENV TARANTOOL_DIR
+ HINTS ${TARANTOOL_DIR} ENV TARANTOOL_DIR
+ PATH_SUFFIXES include
)
if(TARANTOOL_INCLUDE_DIR)
@@ -26,22 +27,14 @@ include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(TARANTOOL
REQUIRED_VARS TARANTOOL_INCLUDE_DIR VERSION_VAR TARANTOOL_VERSION)
if(TARANTOOL_FOUND)
- set(TARANTOOL_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/tarantool")
- set(TARANTOOL_INSTALL_LUADIR "${CMAKE_INSTALL_DATADIR}/tarantool")
set(TARANTOOL_INCLUDE_DIRS "${TARANTOOL_INCLUDE_DIR}"
- "${TARANTOOL_INCLUDE_DIR}/tarantool/")
+ "${TARANTOOL_INCLUDE_DIR}/tarantool/"
+ CACHE PATH "Include directories for Tarantool")
+ set(TARANTOOL_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/tarantool"
+ CACHE PATH "Directory for storing Lua modules written in Lua")
+ set(TARANTOOL_INSTALL_LUADIR "${CMAKE_INSTALL_DATADIR}/tarantool"
+ CACHE PATH "Directory for storing Lua modules written in C")
- if (NOT "${CMAKE_INSTALL_PREFIX}" STREQUAL "/usr/local" AND
- NOT "${CMAKE_INSTALL_PREFIX}" STREQUAL "${_install_prefix}")
- message(WARNING "Provided CMAKE_INSTALL_PREFIX is different from "
- "CMAKE_INSTALL_PREFIX of Tarantool. You might need to set "
- "corrent package.path/package.cpath to load this module or "
- "change your build prefix:"
- "\n"
- "cmake . -DCMAKE_INSTALL_PREFIX=${_install_prefix}"
- "\n"
- )
- endif ()
if (NOT TARANTOOL_FIND_QUIETLY AND NOT FIND_TARANTOOL_DETAILS)
set(FIND_TARANTOOL_DETAILS ON CACHE INTERNAL "Details about TARANTOOL")
message(STATUS "Tarantool LUADIR is ${TARANTOOL_INSTALL_LUADIR}")
diff --git a/debian/control b/debian/control
index 6e6db23..6c87625 100644
--- a/debian/control
+++ b/debian/control
@@ -4,7 +4,8 @@ Section: database
Maintainer: Roman Tsisyk
Build-Depends: debhelper (>= 9), cdbs,
cmake (>= 2.8),
- tarantool-dev (>= 1.7.5.0)
+ tarantool-dev (>= 1.7.5.0),
+ tarantool-checks (>= 3.0.1)
Standards-Version: 3.9.6
Homepage: https://github.com/tarantool/http
Vcs-Git: git://github.com/tarantool/http.git
@@ -12,7 +13,9 @@ Vcs-Browser: https://github.com/tarantool/http
Package: tarantool-http
Architecture: i386 amd64 armhf arm64
-Depends: tarantool (>= 1.7.5.0), ${shlibs:Depends}, ${misc:Depends}
+Depends: tarantool (>= 1.7.5.0),
+ tarantool-checks (>= 3.0.1),
+ ${shlibs:Depends}, ${misc:Depends}
Pre-Depends: ${misc:Pre-Depends}
Description: HTTP server for Tarantool
This package provides a HTTP server for Tarantool.
diff --git a/debian/rules b/debian/rules
index d5825ab..821d511 100755
--- a/debian/rules
+++ b/debian/rules
@@ -2,7 +2,6 @@
DEB_CMAKE_EXTRA_FLAGS := -DCMAKE_INSTALL_LIBDIR=lib/$(DEB_HOST_MULTIARCH) \
-DCMAKE_BUILD_TYPE=RelWithDebInfo
-DEB_MAKE_CHECK_TARGET := check
include /usr/share/cdbs/1/rules/debhelper.mk
include /usr/share/cdbs/1/class/cmake.mk
diff --git a/examples/middleware.lua b/examples/middleware.lua
new file mode 100755
index 0000000..0092f62
--- /dev/null
+++ b/examples/middleware.lua
@@ -0,0 +1,81 @@
+#!/usr/bin/env tarantool
+local http_router = require('http.router')
+local http_server = require('http.server')
+local tsgi = require('http.tsgi')
+local json = require('json')
+local log = require('log')
+
+box.cfg{} -- luacheck: ignore
+
+local httpd = http_server.new('127.0.0.1', 12345, {
+ log_requests = true,
+ log_errors = true
+})
+
+local function swap_orange_and_apple(env)
+ local path_info = env['PATH_INFO']
+ log.info('swap_orange_and_apple: path_info = %s', path_info)
+ if path_info == '/fruits/orange' then
+ env['PATH_INFO'] = '/fruits/apple'
+ elseif path_info == '/fruits/apple' then
+ env['PATH_INFO'] = '/fruits/orange'
+ end
+
+ return tsgi.next(env)
+end
+
+local function add_helloworld_to_response(env)
+ local resp = tsgi.next(env)
+ if resp.body == nil then
+ return resp
+ end
+
+ local lua_body = json.decode(resp.body)
+ lua_body.message = 'hello world!'
+ resp.body = json.encode(lua_body)
+
+ return resp
+end
+
+local function apple_handler(_)
+ return {status = 200, body = json.encode({kind = 'apple'})}
+end
+
+local function orange_handler(_)
+ return {status = 200, body = json.encode({kind = 'orange'})}
+end
+
+local router = http_router.new()
+ :route({
+ method = 'GET',
+ path = '/fruits/apple',
+ },
+ apple_handler
+ )
+ :route({
+ method = 'GET',
+ path = '/fruits/orange',
+ },
+ orange_handler
+ )
+
+local ok = router:use({
+ preroute = true,
+ name = 'swap_orange_and_apple',
+ method = 'GET',
+ path = '/fruits/.*',
+ handler = swap_orange_and_apple,
+})
+assert(ok, 'no conflict on adding swap_orange_and_apple')
+
+ok = router:use({
+ name = 'hello_world',
+ method = 'GET',
+ path = '/fruits/.*',
+ handler = add_helloworld_to_response,
+})
+assert(ok, 'no conflict on adding hello_world middleware')
+
+
+httpd:set_handler(router)
+httpd:start()
diff --git a/rockspecs/http-scm-1.rockspec b/http-scm-1.rockspec
similarity index 54%
rename from rockspecs/http-scm-1.rockspec
rename to http-scm-1.rockspec
index 3713e0a..c86fb19 100644
--- a/rockspecs/http-scm-1.rockspec
+++ b/http-scm-1.rockspec
@@ -10,7 +10,8 @@ description = {
license = 'BSD',
}
dependencies = {
- 'lua >= 5.1'
+ 'lua >= 5.1',
+ 'checks >= 3.0.1',
}
external_dependencies = {
TARANTOOL = {
@@ -18,18 +19,13 @@ external_dependencies = {
}
}
build = {
- type = 'builtin',
+ type = 'cmake',
- modules = {
- ['http.lib'] = {
- sources = 'http/lib.c',
- incdirs = {
- "$(TARANTOOL_INCDIR)"
- }
- },
- ['http.server'] = 'http/server.lua',
- ['http.mime_types'] = 'http/mime_types.lua',
- ['http.codes'] = 'http/codes.lua',
+ variables = {
+ version = 'scm-1',
+ TARANTOOL_DIR = '$(TARANTOOL_DIR)',
+ TARANTOOL_INSTALL_LIBDIR = '$(LIBDIR)',
+ TARANTOOL_INSTALL_LUADIR = '$(LUADIR)',
}
}
diff --git a/http/CMakeLists.txt b/http/CMakeLists.txt
index 441aeb7..d24e1ec 100644
--- a/http/CMakeLists.txt
+++ b/http/CMakeLists.txt
@@ -12,6 +12,16 @@ set_target_properties(httpd
# Install
install(TARGETS httpd LIBRARY DESTINATION ${TARANTOOL_INSTALL_LIBDIR}/http)
-install(FILES server.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http)
-install(FILES mime_types.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http)
-install(FILES codes.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http)
+install(FILES server/init.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/server/init.lua)
+install(FILES server/tsgi_adapter.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/server/tsgi_adapter.lua)
+install(FILES nginx_server/init.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/nginx_server/init.lua)
+install(FILES router/init.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/init.lua)
+install(FILES router/fs.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/fs.lua)
+install(FILES router/matching.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/matching.lua)
+install(FILES router/middleware.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/middleware.lua)
+install(FILES router/request.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/request.lua)
+install(FILES router/response.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/response.lua)
+install(FILES tsgi.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/tsgi.lua)
+install(FILES utils.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/utils.lua)
+install(FILES mime_types.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/mime_types.lua)
+install(FILES codes.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/codes.lu)
diff --git a/http/VERSION.lua.in b/http/VERSION.lua.in
new file mode 100644
index 0000000..9baca9a
--- /dev/null
+++ b/http/VERSION.lua.in
@@ -0,0 +1,3 @@
+#!/usr/bin/env tarantool
+
+return "@GIT_DESCRIBE@"
diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua
new file mode 100644
index 0000000..28aaf8f
--- /dev/null
+++ b/http/nginx_server/init.lua
@@ -0,0 +1,204 @@
+local tsgi = require('http.tsgi')
+
+local checks = require('checks')
+local json = require('json')
+local log = require('log')
+
+local KEY_BODY = 'tsgi.http.nginx_server.body'
+
+local function convert_headername(name)
+ return 'HEADER_' .. string.upper(name)
+end
+
+local function tsgi_input_read(self, n)
+ checks('table', '?number')
+
+ local start = self._pos
+ local last
+
+ if n ~= nil then
+ last = start + n
+ self._pos = last
+ else
+ last = #self._env[KEY_BODY]
+ self._pos = last
+ end
+
+ return self._env[KEY_BODY]:sub(start, last)
+end
+
+local function tsgi_input_rewind(self)
+ self._pos = 0
+end
+
+local function make_env(server, req)
+ -- NGINX Tarantool Upstream `parse_query` option must NOT be set.
+ local uriparts = string.split(req.uri, '?')
+ local path_info, query_string = uriparts[1], uriparts[2]
+
+ local body = ''
+ if type(req.body) == 'string' then
+ body = json.decode(req.body).params
+ end
+
+ local hostport = box.session.peer(box.session.id())
+ local hostport_parts = string.split(hostport, ':')
+ local peer_host, peer_port = hostport_parts[1], tonumber(hostport_parts[2])
+
+ local env = {
+ ['tsgi.version'] = '1',
+ ['tsgi.url_scheme'] = 'http', -- no support for https
+ ['tsgi.input'] = {
+ _pos = 0, -- last unread char in body
+ read = tsgi_input_read,
+ rewind = tsgi_input_rewind,
+ },
+ ['tsgi.hijack'] = nil, -- no support for hijack with nginx
+ ['REQUEST_METHOD'] = string.upper(req.method),
+ ['SERVER_NAME'] = server.host,
+ ['SERVER_PORT'] = server.port,
+ ['PATH_INFO'] = path_info,
+ ['QUERY_STRING'] = query_string,
+ ['SERVER_PROTOCOL'] = req.proto,
+ [tsgi.KEY_PEER] = {
+ host = peer_host,
+ port = peer_port,
+ family = 'AF_INET',
+ type = 'SOCK_STREAM',
+ protocol = 'tcp',
+ },
+
+ [KEY_BODY] = body, -- http body string; used in `tsgi_input_read`
+ }
+
+ -- Pass through `env` to env['tsgi.*']:read() functions
+ env['tsgi.input']._env = env
+
+ for name, value in pairs(req.headers) do
+ env[convert_headername(name)] = value
+ end
+
+ -- SCRIPT_NAME is a virtual location of your app.
+ --
+ -- Imagine you want to serve your HTTP API under prefix /test
+ -- and later move it to /.
+ --
+ -- Instead of rewriting endpoints to your application, you do:
+ --
+ -- location /test/ {
+ -- proxy_pass http://127.0.0.1:8001/test/;
+ -- proxy_redirect http://127.0.0.1:8001/test/ http://$host/test/;
+ -- proxy_set_header SCRIPT_NAME /test;
+ -- }
+ --
+ -- Application source code is not touched.
+ env['SCRIPT_NAME'] = env['HTTP_SCRIPT_NAME'] or ''
+ env['HTTP_SCRIPT_NAME'] = nil
+
+ return env
+end
+
+local function generic_entrypoint(server, req, ...)
+ local env = make_env(server, req, ...)
+
+ local ok, resp = pcall(server.router_obj, env)
+
+ local status = resp.status or 200
+ local headers = resp.headers or {}
+ local body = resp.body or ''
+
+ if not ok then
+ status = 500
+ headers = {}
+ local trace = debug.traceback()
+
+ -- TODO: copypaste
+ -- TODO: env could be changed. we need to save a copy of it
+ log.error('unhandled error: %s\n%s\nrequest:\n%s',
+ tostring(resp), trace, tsgi.serialize_request(env))
+
+ if server.display_errors then
+ body =
+ "Unhandled error: " .. tostring(resp) .. "\n"
+ .. trace .. "\n\n"
+ .. "\n\nRequest:\n"
+ .. tsgi.serialize_request(env)
+ else
+ body = "Internal Error"
+ end
+ end
+
+ -- handle iterable body
+ local gen, param, state
+
+ if type(body) == 'function' then
+ -- Generating function
+ gen = body
+ elseif type(body) == 'table' and body.gen then
+ -- Iterator
+ gen, param, state = body.gen, body.param, body.state
+ end
+
+ if gen ~= nil then
+ body = ''
+ for _, part in gen, param, state do
+ body = body .. tostring(part)
+ end
+ end
+
+ return status, headers, body
+end
+
+local function ngxserver_set_router(self, router)
+ checks('table', 'function|table')
+
+ self.router_obj = router
+end
+
+local function ngxserver_router(self)
+ return self.router_obj
+end
+
+local function ngxserver_start(self)
+ checks('table')
+
+ rawset(_G, self.tnt_method, function(...)
+ return generic_entrypoint(self, ...)
+ end)
+end
+
+local function ngxserver_stop(self)
+ checks('table')
+
+ rawset(_G, self.tnt_method, nil)
+end
+
+local function new(opts)
+ checks({
+ host = 'string',
+ port = 'number',
+ tnt_method = 'string',
+ display_errors = '?boolean',
+ log_errors = '?boolean',
+ log_requests = '?boolean',
+ })
+
+ local self = {
+ host = opts.host,
+ port = opts.port,
+ tnt_method = opts.tnt_method,
+ display_errors = opts.display_errors or true,
+ log_errors = opts.log_errors or true,
+ log_requests = opts.log_requests or true,
+
+ set_router = ngxserver_set_router,
+ router = ngxserver_router,
+ start = ngxserver_start,
+ stop = ngxserver_stop,
+ }
+ return self
+end
+
+return {
+ new = new,
+}
diff --git a/http/router/fs.lua b/http/router/fs.lua
new file mode 100644
index 0000000..a635c20
--- /dev/null
+++ b/http/router/fs.lua
@@ -0,0 +1,258 @@
+local lib = require('http.lib')
+local utils = require('http.utils')
+local mime_types = require('http.mime_types')
+local response = require('http.router.response')
+
+local json = require('json')
+
+local function type_by_format(fmt)
+ if fmt == nil then
+ return 'application/octet-stream'
+ end
+
+ local t = mime_types[ fmt ]
+
+ if t ~= nil then
+ return t
+ end
+
+ return 'application/octet-stream'
+end
+
+local function catfile(...)
+ local sp = { ... }
+
+ local path
+
+ if #sp == 0 then
+ return
+ end
+
+ for i, pe in pairs(sp) do
+ if path == nil then
+ path = pe
+ elseif string.match(path, '.$') ~= '/' then
+ if string.match(pe, '^.') ~= '/' then
+ path = path .. '/' .. pe
+ else
+ path = path .. pe
+ end
+ else
+ if string.match(pe, '^.') == '/' then
+ path = path .. string.gsub(pe, '^/', '', 1)
+ else
+ path = path .. pe
+ end
+ end
+ end
+
+ return path
+end
+
+local function static_file(self, request, format)
+ local file = catfile(self.options.app_dir, 'public', request:path())
+
+ if self.options.cache_static and self.cache.static[ file ] ~= nil then
+ return {
+ code = 200,
+ headers = {
+ [ 'content-type'] = type_by_format(format),
+ },
+ body = self.cache.static[ file ]
+ }
+ end
+
+ local s, fh = pcall(io.input, file)
+
+ if not s then
+ return { status = 404 }
+ end
+
+ local body = fh:read('*a')
+ io.close(fh)
+
+ if self.options.cache_static then
+ self.cache.static[ file ] = body
+ end
+
+ return {
+ status = 200,
+ headers = {
+ [ 'content-type'] = type_by_format(format),
+ },
+ body = body
+ }
+end
+
+
+local function ctx_action(tx)
+ local ctx = tx.endpoint.controller
+ local action = tx.endpoint.action
+ if tx:router().options.cache_controllers then
+ if tx:router().cache[ ctx ] ~= nil then
+ if type(tx:router().cache[ ctx ][ action ]) ~= 'function' then
+ utils.errorf("Controller '%s' doesn't contain function '%s'",
+ ctx, action)
+ end
+ return tx:router().cache[ ctx ][ action ](tx)
+ end
+ end
+
+ local ppath = package.path
+ package.path = catfile(tx:router().options.app_dir, 'controllers', '?.lua')
+ .. ';'
+ .. catfile(tx:router().options.app_dir,
+ 'controllers', '?/init.lua')
+ if ppath ~= nil then
+ package.path = package.path .. ';' .. ppath
+ end
+
+ local st, mod = pcall(require, ctx)
+ package.path = ppath
+ package.loaded[ ctx ] = nil
+
+ if not st then
+ utils.errorf("Can't load module '%s': %s'", ctx, tostring(mod))
+ end
+
+ if type(mod) ~= 'table' then
+ utils.errorf("require '%s' didn't return table", ctx)
+ end
+
+ if type(mod[ action ]) ~= 'function' then
+ utils.errorf("Controller '%s' doesn't contain function '%s'", ctx, action)
+ end
+
+ if tx:router().options.cache_controllers then
+ tx:router().cache[ ctx ] = mod
+ end
+
+ return mod[action](tx)
+end
+
+local function load_template(self, r, format)
+ if r.template ~= nil then
+ return
+ end
+
+ if format == nil then
+ format = 'html'
+ end
+
+ local file
+ if r.file ~= nil then
+ file = r.file
+ elseif r.controller ~= nil and r.action ~= nil then
+ file = catfile(
+ string.gsub(r.controller, '[.]', '/'),
+ r.action .. '.' .. format .. '.el')
+ else
+ utils.errorf("Can not find template for '%s'", r.path)
+ end
+
+ if self.options.cache_templates then
+ if self.cache.tpl[ file ] ~= nil then
+ return self.cache.tpl[ file ]
+ end
+ end
+
+
+ local tpl = catfile(self.options.app_dir, 'templates', file)
+ local fh = io.input(tpl)
+ local template = fh:read('*a')
+ fh:close()
+
+ if self.options.cache_templates then
+ self.cache.tpl[ file ] = template
+ end
+ return template
+end
+
+
+local function render(tx, opts)
+ if tx == nil then
+ error("Usage: self:render({ ... })")
+ end
+
+ local resp = setmetatable({ headers = {} }, response.metatable)
+ local vars = {}
+ if opts ~= nil then
+ if opts.text ~= nil then
+ if tx:router().options.charset ~= nil then
+ resp.headers['content-type'] =
+ utils.sprintf("text/plain; charset=%s",
+ tx:router().options.charset
+ )
+ else
+ resp.headers['content-type'] = 'text/plain'
+ end
+ resp.body = tostring(opts.text)
+ return resp
+ end
+
+ -- TODO
+ if opts.json ~= nil then
+ if tx:router().options.charset ~= nil then
+ resp.headers['content-type'] =
+ utils.sprintf('application/json; charset=%s',
+ tx:router().options.charset
+ )
+ else
+ resp.headers['content-type'] = 'application/json'
+ end
+ resp.body = json.encode(opts.json)
+ return resp
+ end
+
+ if opts.data ~= nil then
+ resp.body = tostring(opts.data)
+ return resp
+ end
+
+ vars = utils.extend(tx.tstash, opts, false)
+ end
+
+ local tpl
+
+ local format = tx.tstash.format
+ if format == nil then
+ format = 'html'
+ end
+
+ if tx.endpoint.template ~= nil then
+ tpl = tx.endpoint.template
+ else
+ tpl = load_template(tx:router(), tx.endpoint, format)
+ if tpl == nil then
+ utils.errorf('template is not defined for the route')
+ end
+ end
+
+ if type(tpl) == 'function' then
+ tpl = tpl()
+ end
+
+ for hname, sub in pairs(tx:router().helpers) do
+ vars[hname] = function(...) return sub(tx, ...) end
+ end
+ vars.action = tx.endpoint.action
+ vars.controller = tx.endpoint.controller
+ vars.format = format
+
+ resp.body = lib.template(tpl, vars)
+ resp.headers['content-type'] = type_by_format(format)
+
+ if tx:router().options.charset ~= nil then
+ if format == 'html' or format == 'js' or format == 'json' then
+ resp.headers['content-type'] = resp.headers['content-type']
+ .. '; charset=' .. tx:router().options.charset
+ end
+ end
+ return resp
+end
+
+return {
+ render = render,
+ ctx_action = ctx_action,
+ static_file = static_file,
+}
diff --git a/http/router/init.lua b/http/router/init.lua
new file mode 100644
index 0000000..db6aa63
--- /dev/null
+++ b/http/router/init.lua
@@ -0,0 +1,344 @@
+local fs = require('http.router.fs')
+local middleware = require('http.router.middleware')
+local matching = require('http.router.matching')
+
+local bless_request = require('http.router.request').bless
+
+local utils = require('http.utils')
+local tsgi = require('http.tsgi')
+
+local checks = require('checks')
+
+local function uri_file_extension(s, default)
+ -- cut from last dot till the end
+ local ext = string.match(s, '[.]([^.]+)$')
+ if ext ~= nil then
+ return ext
+ end
+ return default
+end
+
+-- TODO: move to router.request?
+local function url_for_helper(tx, name, args, query)
+ return tx:url_for(name, args, query)
+end
+
+local function main_endpoint_middleware(request)
+ local self = request:router()
+ local format = uri_file_extension(request:path(), 'html')
+ local r = request[tsgi.KEY_ROUTE]
+
+ if r == nil then
+ return fs.static_file(self, request, format)
+ end
+ local stash = utils.extend(r.stash, { format = format })
+ request.endpoint = r.endpoint -- THIS IS ROUTE, BUT IS NAMED `ENDPOINT`! OH-MY-GOD!
+ request.tstash = stash
+ return r.endpoint.handler(request)
+end
+
+local function populate_chain_with_middleware(env, middleware_obj)
+ local filter = matching.transform_filter({
+ path = env:path(),
+ method = env:method()
+ })
+ for _, m in pairs(middleware_obj:ordered()) do
+ if matching.matches(m, filter) then
+ tsgi.push_back_handler(env, m.handler)
+ end
+ end
+end
+
+local function dispatch_middleware(req)
+ local self = req:router()
+
+ local r = self:match(req:method(), req:path())
+ req[tsgi.KEY_ROUTE] = r
+
+ populate_chain_with_middleware(req, self.middleware)
+
+ -- finally, add user specified handler
+ tsgi.push_back_handler(req, main_endpoint_middleware)
+
+ return req:next()
+end
+
+local function router_handler(self, env)
+ -- attach a metatable with helper methods
+ -- to otherwise raw TSGI table
+ -- (e.g. to be able to write request:path() instead of request['PATH_INFO'])
+ local request = bless_request(env, self)
+
+ request:set_router(self)
+
+ -- set-up middleware chain
+ tsgi.init_handlers(request)
+
+ populate_chain_with_middleware(request, self.preroute_middleware)
+
+ -- add routing
+ tsgi.push_back_handler(request, dispatch_middleware)
+
+ -- execute middleware chain from first
+ return request:next()
+end
+
+-- TODO: `route` is not route, but path...
+local function match_route(self, method, route)
+ local filter = matching.transform_filter({
+ method = method,
+ path = route
+ })
+
+ local best_match = nil
+ for _, r in pairs(self.routes) do
+ local ok, match = matching.matches(r, filter)
+ if ok and matching.better_than(match, best_match) then
+ best_match = match
+ end
+ end
+
+ if best_match == nil or best_match.route == nil then
+ return nil
+ end
+
+ local resstash = {}
+ for i = 1, #best_match.route.stash do
+ resstash[best_match.route.stash[i]] = best_match.stash[i]
+ end
+ return {endpoint = best_match.route, stash = resstash}
+end
+
+local function set_helper(self, name, handler)
+ if handler == nil or type(handler) == 'function' then
+ self.helpers[ name ] = handler
+ return self
+ end
+ utils.errorf("Wrong type for helper function: %s", type(handler))
+end
+
+local function set_hook(self, name, handler)
+ if handler == nil or type(handler) == 'function' then
+ self.hooks[ name ] = handler
+ return self
+ end
+ utils.errorf("Wrong type for hook function: %s", type(handler))
+end
+
+local function url_for_route(r, args, query)
+ if args == nil then
+ args = {}
+ end
+ local name = r.path
+ for _, sn in pairs(r.stash) do
+ local sv = args[sn]
+ if sv == nil then
+ sv = ''
+ end
+ name = string.gsub(name, '[*:]' .. sn, sv, 1)
+ end
+
+ if query ~= nil then
+ if type(query) == 'table' then
+ local sep = '?'
+ for k, v in pairs(query) do
+ name = name .. sep .. utils.uri_escape(k) .. '=' .. utils.uri_escape(v)
+ sep = '&'
+ end
+ else
+ name = name .. '?' .. query
+ end
+ end
+
+ if string.match(name, '^/') == nil then
+ return '/' .. name
+ else
+ return name
+ end
+end
+
+local possible_methods = {
+ GET = 'GET',
+ HEAD = 'HEAD',
+ POST = 'POST',
+ PUT = 'PUT',
+ DELETE = 'DELETE',
+ PATCH = 'PATCH',
+}
+
+local function use_middleware(self, handler, opts)
+ checks('table', 'function', {
+ path = '?string',
+ method = '?string|table',
+ name = '?string',
+ preroute = '?boolean',
+ before = '?string|table',
+ after = '?string|table',
+ })
+ local opts = table.deepcopy(opts)
+ opts.handler = handler
+
+ local uuid = require('uuid')
+ opts.path = opts.path or '/.*'
+ opts.method = opts.method or 'ANY'
+ opts.name = opts.name or uuid.str()
+ opts.before = opts.before or {}
+ opts.after = opts.after or {}
+
+ for _, order_key in ipairs({'before', 'after'}) do
+ local opt = opts[order_key]
+ assert(type(opt) ~= 'string' or type(opt) ~= 'table',
+ ('%s must be a table of strings or a string'):format(order_key))
+ if type(opt) == 'table' then
+ for _, name in ipairs(opt) do
+ local fmt = ('%s of table type, must contain strings, got %s')
+ :format(order_key, type(opt[name]))
+ assert(type(opt[name]) == 'string', fmt)
+ end
+ end
+ end
+
+ -- helpers for matching and retrieving pattern words
+ opts.match, opts.stash = matching.transform_pattern(opts.path)
+
+ if opts.preroute == true then
+ return self.preroute_middleware:use(opts)
+ end
+ return self.middleware:use(opts)
+end
+
+local function add_route(self, opts, handler)
+ if type(opts) ~= 'table' or type(self) ~= 'table' then
+ error("Usage: router:route({ ... }, function(cx) ... end)")
+ end
+
+ opts = utils.extend({method = 'ANY'}, opts, false)
+
+ local ctx
+ local action
+
+ if handler == nil then
+ handler = fs.render
+ elseif type(handler) == 'string' then
+
+ ctx, action = string.match(handler, '(.+)#(.*)')
+
+ if ctx == nil or action == nil then
+ utils.errorf("Wrong controller format '%s', must be 'module#action'", handler)
+ end
+
+ handler = fs.ctx_action
+
+ elseif type(handler) ~= 'function' then
+ utils.errorf("wrong argument: expected function, but received %s",
+ type(handler))
+ end
+
+ opts.method = possible_methods[string.upper(opts.method)] or 'ANY'
+
+ if opts.path == nil then
+ error("path is not defined")
+ end
+
+ opts.controller = ctx
+ opts.action = action
+
+ opts.match, opts.stash = matching.transform_pattern(opts.path)
+ opts.handler = handler
+ opts.url_for = url_for_route
+
+ -- register new route in a router
+ if opts.name ~= nil then
+ if opts.name == 'current' then
+ error("Route can not have name 'current'")
+ end
+ if self.iroutes[ opts.name ] ~= nil then
+ utils.errorf("Route with name '%s' is already exists", opts.name)
+ end
+ table.insert(self.routes, opts)
+ self.iroutes[ opts.name ] = #self.routes
+ else
+ table.insert(self.routes, opts)
+ end
+ return self
+end
+
+local function url_for(self, name, args, query)
+ local idx = self.iroutes[ name ]
+ if idx ~= nil then
+ return self.routes[ idx ]:url_for(args, query)
+ end
+
+ if string.match(name, '^/') == nil then
+ if string.match(name, '^https?://') ~= nil then
+ return name
+ else
+ return '/' .. name
+ end
+ else
+ return name
+ end
+end
+
+local exports = {
+ new = function(options)
+ if options == nil then
+ options = {}
+ end
+ if type(options) ~= 'table' then
+ utils.errorf("options must be table not '%s'", type(options))
+ end
+
+ local default = {
+ max_header_size = 4096,
+ header_timeout = 100,
+ app_dir = '.',
+ charset = 'utf-8',
+ cache_templates = true,
+ cache_controllers = true,
+ cache_static = true,
+ }
+
+ local self = {
+ options = utils.extend(default, options, true),
+
+ routes = { }, -- routes array
+ iroutes = { }, -- routes by name
+ middleware = middleware.new(), -- new middleware
+ preroute_middleware = middleware.new(), -- new middleware (preroute)
+ helpers = { -- for use in templates
+ url_for = url_for_helper,
+ },
+ hooks = { }, -- middleware
+
+ -- methods
+ use = use_middleware, -- new middleware
+ route = add_route, -- add route
+ helper = set_helper, -- for use in templates
+ hook = set_hook, -- middleware
+ url_for = url_for,
+
+ -- private
+ match = match_route,
+
+ -- caches
+ cache = {
+ tpl = {},
+ ctx = {},
+ static = {},
+ },
+ }
+
+ -- make router object itself callable
+ --
+ -- BE AWARE:
+ -- 1) router(env) is valid, but
+ -- 2) type(router) == 'table':
+ --
+ return setmetatable(self, {
+ __call = router_handler,
+ })
+ end
+}
+
+return exports
diff --git a/http/router/matching.lua b/http/router/matching.lua
new file mode 100644
index 0000000..4a51ef5
--- /dev/null
+++ b/http/router/matching.lua
@@ -0,0 +1,119 @@
+local utils = require('http.utils')
+
+local function transform_filter(filter)
+ local path = filter.path
+ -- route must have '/' at the begin and end
+ if string.match(path, '.$') ~= '/' then
+ path = path .. '/'
+ end
+ if string.match(path, '^.') ~= '/' then
+ path = '/' .. path
+ end
+
+ return {
+ path = path,
+ method = string.upper(filter.method)
+ }
+end
+
+-- converts user-defined path pattern to a matcher string.
+-- used on adding new route.
+local function transform_pattern(path)
+ local match = path
+ match = string.gsub(match, '[-]', "[-]")
+
+ -- convert user-specified route URL to regexp,
+ -- and initialize stashes
+
+ local estash = { } -- helper table, name -> boolean
+ local stash = { } -- i -> word
+
+ -- when no such pattern is found, returns false
+ local find_and_replace_stash_pattern = function(pattern_regex, replace_with)
+ local name = string.match(match, pattern_regex)
+ if name == nil then
+ return false
+ end
+ if estash[name] then
+ utils.errorf("duplicate stash: %s", name)
+ end
+ estash[name] = true
+ match = string.gsub(match, pattern_regex, replace_with, 1)
+
+ table.insert(stash, name)
+ return true
+ end
+
+ -- patterns starting with :
+ while find_and_replace_stash_pattern(':([%a_][%w_]*)', '([^/]-)') do end
+ -- extended patterns starting with *
+ while find_and_replace_stash_pattern('[*]([%a_][%w_]*)', '(.-)') do end
+
+ -- ensure match is like '^/xxx/$'
+ do
+ if string.match(match, '.$') ~= '/' then
+ match = match .. '/'
+ end
+ if string.match(match, '^.') ~= '/' then
+ match = '/' .. match
+ end
+ match = '^' .. match .. '$'
+ end
+
+ return match, stash
+end
+
+local function matches(r, filter)
+ local methods_match = r.method == filter.method or r.method == 'ANY'
+ if not methods_match then
+ return false
+ end
+
+ local regex_groups_matched = {string.match(filter.path, r.match)}
+ if #regex_groups_matched == 0 then
+ return false
+ end
+ if #r.stash > 0 and #r.stash ~= #regex_groups_matched then
+ return false
+ end
+
+ -- how many symbols were not known (were hidden behind : and * patterns)
+ local symbols_didnt_know = 0
+ for _, matched_part in ipairs(regex_groups_matched) do
+ symbols_didnt_know = symbols_didnt_know + #matched_part
+ end
+
+ return true, {
+ route = r,
+ stash = regex_groups_matched,
+
+ -- the more symbols were known in advance by route,
+ -- the more priority we give the route
+ specificity = -symbols_didnt_know,
+ }
+end
+
+local function better_than(newmatch, oldmatch)
+ if newmatch == nil then
+ return false
+ end
+ if oldmatch == nil then
+ return true
+ end
+
+ -- newmatch route is prioritized over oldmatch iff:
+ -- 1. its' path is more specific (see matches() function), or
+ -- 2. if current route has more specific method filter
+ if newmatch.specificity > oldmatch.specificity then
+ return true
+ end
+ return newmatch.route.method ~= oldmatch.route.method and
+ oldmatch.method == 'ANY'
+end
+
+return {
+ matches = matches,
+ better_than = better_than,
+ transform_filter = transform_filter,
+ transform_pattern = transform_pattern,
+}
diff --git a/http/router/middleware.lua b/http/router/middleware.lua
new file mode 100644
index 0000000..151a9d7
--- /dev/null
+++ b/http/router/middleware.lua
@@ -0,0 +1,144 @@
+local COLOR_NOT_VISITED = 0
+local COLOR_IN_PROGRESS = 1
+local COLOR_VISITED = 2
+
+local function dfs(self, v, colors, reverse_order)
+ if colors[v] == COLOR_VISITED then
+ -- already traversed v
+ return true
+ elseif colors[v] == COLOR_IN_PROGRESS then
+ -- loop detected
+ return false
+ end
+ colors[v] = COLOR_IN_PROGRESS
+
+ for _, to in ipairs(self.edges[v]) do
+ local ok = self:dfs(to, colors, reverse_order)
+ if not ok then
+ return false
+ end
+ end
+
+ table.insert(reverse_order, v)
+ colors[v] = COLOR_VISITED
+ return true
+end
+
+local function prepare_graph(self)
+ local numvertices = #self.nodes
+
+ self.edges = {}
+ for v = 1, numvertices do
+ self.edges[v] = {}
+ end
+
+ for v, node in pairs(self.nodes) do
+ for _, from_name in pairs(node.after) do
+ local from = self.id_by_name[from_name]
+ if from ~= nil then
+ table.insert(self.edges[from], v)
+ end
+ end
+
+ for _, to_name in pairs(node.before) do
+ local to = self.id_by_name[to_name]
+ if to ~= nil then
+ table.insert(self.edges[v], to)
+ end
+ end
+ end
+end
+
+local function find_order(self)
+ self:prepare_graph()
+
+ local numvertices = #self.nodes
+
+ local reverse_order = {}
+ local colors = {}
+ for v = 1, numvertices do
+ colors[v] = COLOR_NOT_VISITED
+ end
+
+ local conflict = false
+ for v = 1, numvertices do
+ if colors[v] == COLOR_NOT_VISITED then
+ local ok = self:dfs(v, colors, reverse_order)
+ if not ok then
+ conflict = true
+ break
+ end
+ end
+ end
+ if conflict then
+ return false
+ end
+
+ assert(#reverse_order, numvertices, 'ordered every node')
+
+ self.order = {}
+ for i = numvertices, 1, -1 do
+ table.insert(self.order, reverse_order[i])
+ end
+
+ return true
+end
+
+local function listify(val)
+ return type(val) == 'table' and val or {val}
+end
+
+local function ordered(self)
+ local ret = {}
+ for _, v in ipairs(self.order) do
+ table.insert(ret, self.nodes[v])
+ end
+ return ret
+end
+
+-- TODO: error-handling
+local function use(self, m)
+ m.after = listify(m.after)
+ m.before = listify(m.before)
+
+ table.insert(self.nodes, m)
+ self.id_by_name[m.name] = #self.nodes
+
+ local ok = self:find_order()
+ if not ok then
+ -- rollback
+ table.remove(self.nodes)
+
+ ok = self:find_order()
+ assert(ok, 'rollback failed!')
+ return false
+ end
+ return true
+end
+
+local function clear(self)
+ self.nodes = {}
+ self.id_by_name = {}
+ self.order = {}
+end
+
+local function new()
+ return {
+ nodes = {},
+ id_by_name = {},
+ order = {},
+
+ use = use,
+ clear = clear,
+ ordered = ordered,
+
+ -- private
+ prepare_graph = prepare_graph,
+ find_order = find_order,
+ dfs = dfs,
+ }
+end
+
+return {
+ new = new,
+}
diff --git a/http/router/request.lua b/http/router/request.lua
new file mode 100644
index 0000000..1ba4dc5
--- /dev/null
+++ b/http/router/request.lua
@@ -0,0 +1,300 @@
+local fs = require('http.router.fs')
+local response = require('http.router.response')
+local utils = require('http.utils')
+local lib = require('http.lib')
+local tsgi = require('http.tsgi')
+
+local json = require('json')
+
+local function request_set_router(self, router)
+ self[tsgi.KEY_ROUTER] = router
+end
+
+local function request_router(self)
+ return self[tsgi.KEY_ROUTER]
+end
+
+local function cached_query_param(self, name)
+ if name == nil then
+ return self.query_params
+ end
+ return self.query_params[ name ]
+end
+
+local function cached_post_param(self, name)
+ if name == nil then
+ return self.post_params
+ end
+ return self.post_params[ name ]
+end
+
+local function request_tostring(self)
+ local res = self:request_line() .. "\r\n"
+
+ for hn, hv in pairs(tsgi.headers(self.env)) do
+ res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv)
+ end
+
+ return utils.sprintf("%s\r\n%s", res, self:read_cached())
+end
+
+local function request_line(self)
+ local rstr = self:path()
+
+ local query_string = self:query()
+ if query_string ~= nil and query_string ~= '' then
+ rstr = rstr .. '?' .. query_string
+ end
+
+ return utils.sprintf("%s %s %s",
+ self['REQUEST_METHOD'],
+ rstr,
+ self['SERVER_PROTOCOL'] or 'HTTP/?')
+end
+
+local function query_param(self, name)
+ if self:query() ~= nil and string.len(self:query()) == 0 then
+ rawset(self, 'query_params', {})
+ else
+ local params = lib.params(self['QUERY_STRING'])
+ local pres = {}
+ for k, v in pairs(params) do
+ pres[ utils.uri_unescape(k) ] = utils.uri_unescape(v)
+ end
+ rawset(self, 'query_params', pres)
+ end
+
+ rawset(self, 'query_param', cached_query_param)
+ return self:query_param(name)
+end
+
+local function request_content_type(self)
+ -- returns content type without encoding string
+ if self['HEADER_CONTENT-TYPE'] == nil then
+ return nil
+ end
+
+ return string.match(self['HEADER_CONTENT-TYPE'],
+ '^([^;]*)$') or
+ string.match(self['HEADER_CONTENT-TYPE'],
+ '^(.*);.*')
+end
+
+local function post_param(self, name)
+ local content_type = self:content_type()
+ if self:content_type() == 'multipart/form-data' then
+ -- TODO: do that!
+ rawset(self, 'post_params', {})
+ elseif self:content_type() == 'application/json' then
+ local params = self:json()
+ rawset(self, 'post_params', params)
+ elseif self:content_type() == 'application/x-www-form-urlencoded' then
+ local params = lib.params(self:read_cached())
+ local pres = {}
+ for k, v in pairs(params) do
+ pres[ utils.uri_unescape(k) ] = utils.uri_unescape(v, true)
+ end
+ rawset(self, 'post_params', pres)
+ else
+ local params = lib.params(self:read_cached())
+ local pres = {}
+ for k, v in pairs(params) do
+ pres[ utils.uri_unescape(k) ] = utils.uri_unescape(v)
+ end
+ rawset(self, 'post_params', pres)
+ end
+
+ rawset(self, 'post_param', cached_post_param)
+ return self:post_param(name)
+end
+
+
+local function param(self, name)
+ if name ~= nil then
+ local v = self:post_param(name)
+ if v ~= nil then
+ return v
+ end
+ return self:query_param(name)
+ end
+
+ local post = self:post_param()
+ local query = self:query_param()
+ return utils.extend(post, query, false)
+end
+
+local function cookie(self, cookiename)
+ if self:header('cookie') == nil then
+ return nil
+ end
+ for k, v in string.gmatch(
+ self:header('cookie'), "([^=,; \t]+)=([^,; \t]+)") do
+ if k == cookiename then
+ return utils.uri_unescape(v)
+ end
+ end
+ return nil
+end
+
+local function iterate(self, gen, param, state)
+ return setmetatable({ body = { gen = gen, param = param, state = state } },
+ response.metatable)
+end
+
+local function redirect_to(self, name, args, query)
+ local location = self:url_for(name, args, query)
+ return setmetatable({ status = 302, headers = { location = location } },
+ response.metatable)
+end
+
+local function access_stash(self, name, ...)
+ if type(self) ~= 'table' then
+ error("usage: request:stash('name'[, 'value'])")
+ end
+ if select('#', ...) > 0 then
+ self.tstash[ name ] = select(1, ...)
+ end
+
+ return self.tstash[ name ]
+end
+
+local function url_for_request(self, name, args, query)
+ if name == 'current' then
+ return self.endpoint:url_for(args, query)
+ end
+ return self.router:url_for(name, args, query)
+end
+
+
+local function request_json(req)
+ local data = req:read_cached()
+ local s, json = pcall(json.decode, data)
+ if s then
+ return json
+ else
+ error(utils.sprintf("Can't decode json in request '%s': %s",
+ data, tostring(json)))
+ return nil
+ end
+end
+
+local function request_read(self, opts, timeout)
+ return self['tsgi.input']:read(opts, timeout) -- TODO: TSGI spec is violated
+end
+
+local function request_read_cached(self)
+ if self.cached_data == nil then
+ local data = self['tsgi.input']:read()
+ rawset(self, 'cached_data', data)
+ return data
+ else
+ return self.cached_data
+ end
+end
+
+-------------------------------------
+local function request_peer(self)
+ return self[tsgi.KEY_PEER]
+end
+
+local function request_method(self)
+ return self['REQUEST_METHOD']
+end
+
+local function request_path(self)
+ return self['PATH_INFO']
+end
+
+local function request_query(self)
+ return self['QUERY_STRING']
+end
+
+local function request_proto(self)
+ -- parse SERVER_PROTOCOL which is 'HTTP/.'
+ local maj = self['SERVER_PROTOCOL']:sub(-3, -3)
+ local min = self['SERVER_PROTOCOL']:sub(-1, -1)
+ return {
+ [1] = tonumber(maj),
+ [2] = tonumber(min),
+ }
+end
+
+local function request_headers(self)
+ local headers = {}
+ for name, value in pairs(tsgi.headers(self)) do
+ -- strip HEADER_ part and convert to lowercase
+ local converted_name = name:sub(8):lower()
+ headers[converted_name] = value
+ end
+ return headers
+end
+
+local function request_header(self, name)
+ name = 'HEADER_' .. name:upper()
+ return self[name]
+end
+
+----------------------------------
+
+local function request_next(self)
+ return tsgi.next(self)
+end
+
+local function request_hijack(self)
+ return self['tsgi.hijack']()
+end
+
+local metatable = {
+ __index = {
+ router = request_router,
+ set_router = request_set_router,
+
+ render = fs.render,
+ cookie = cookie,
+ redirect_to = redirect_to,
+ iterate = iterate,
+ stash = access_stash,
+ url_for = url_for_request,
+ content_type= request_content_type,
+ request_line= request_line,
+ read_cached = request_read_cached,
+
+ query_param = query_param,
+ post_param = post_param,
+ param = param,
+
+ read = request_read,
+ json = request_json,
+
+ peer = request_peer,
+ method = request_method,
+ path = request_path,
+ query = request_query,
+ proto = request_proto,
+ headers = request_headers,
+ header = request_header,
+
+ next = request_next,
+ hijack = request_hijack,
+ },
+ __tostring = request_tostring;
+}
+
+local function bless(request)
+ local mt = getmetatable(request)
+ if mt == nil then
+ return setmetatable(request, metatable)
+ end
+
+ -- merge to existing metatable
+ for name, value in pairs(metatable) do
+ if mt[name] ~= nil then
+ require('log').info('merge_metatable: name already set: ' .. name)
+ end
+ assert(mt[name] == nil)
+ mt[name] = value
+ end
+ return request
+end
+
+return {bless = bless}
diff --git a/http/router/response.lua b/http/router/response.lua
new file mode 100644
index 0000000..ace53d3
--- /dev/null
+++ b/http/router/response.lua
@@ -0,0 +1,75 @@
+local utils = require('http.utils')
+
+local function expires_str(str)
+ local now = os.time()
+ local gmtnow = now - os.difftime(now, os.time(os.date("!*t", now)))
+ local fmt = '%a, %d-%b-%Y %H:%M:%S GMT'
+
+ if str == 'now' or str == 0 or str == '0' then
+ return os.date(fmt, gmtnow)
+ end
+
+ local diff, period = string.match(str, '^[+]?(%d+)([hdmy])$')
+ if period == nil then
+ return str
+ end
+
+ diff = tonumber(diff)
+ if period == 'h' then
+ diff = diff * 3600
+ elseif period == 'd' then
+ diff = diff * 86400
+ elseif period == 'm' then
+ diff = diff * 86400 * 30
+ else
+ diff = diff * 86400 * 365
+ end
+
+ return os.date(fmt, gmtnow + diff)
+end
+
+local function setcookie(resp, cookie)
+ local name = cookie.name
+ local value = cookie.value
+
+ if name == nil then
+ error('cookie.name is undefined')
+ end
+ if value == nil then
+ error('cookie.value is undefined')
+ end
+
+ local str = utils.sprintf('%s=%s', name, utils.uri_escape(value))
+ if cookie.path ~= nil then
+ str = utils.sprintf('%s;path=%s', str, cookie.path)
+ end
+ if cookie.domain ~= nil then
+ str = utils.sprintf('%s;domain=%s', str, cookie.domain)
+ end
+
+ if cookie.expires ~= nil then
+ str = utils.sprintf('%s;expires=%s', str, expires_str(cookie.expires))
+ end
+
+ if not resp.headers then
+ resp.headers = {}
+ end
+ if resp.headers['set-cookie'] == nil then
+ resp.headers['set-cookie'] = { str }
+ elseif type(resp.headers['set-cookie']) == 'string' then
+ resp.headers['set-cookie'] = {
+ resp.headers['set-cookie'],
+ str
+ }
+ else
+ table.insert(resp.headers['set-cookie'], str)
+ end
+ return resp
+end
+
+local metatable = {
+ __index = {
+ setcookie = setcookie;
+ }
+}
+return {metatable = metatable}
diff --git a/http/server.lua b/http/server.lua
deleted file mode 100644
index e7dac9a..0000000
--- a/http/server.lua
+++ /dev/null
@@ -1,1270 +0,0 @@
--- http.server
-
-local lib = require('http.lib')
-
-local io = io
-local require = require
-local package = package
-local mime_types = require('http.mime_types')
-local codes = require('http.codes')
-
-local log = require('log')
-local socket = require('socket')
-local json = require('json')
-local errno = require 'errno'
-
-local DETACHED = 101
-
-local function errorf(fmt, ...)
- error(string.format(fmt, ...))
-end
-
-local function sprintf(fmt, ...)
- return string.format(fmt, ...)
-end
-
-local function uri_escape(str)
- local res = {}
- if type(str) == 'table' then
- for _, v in pairs(str) do
- table.insert(res, uri_escape(v))
- end
- else
- res = string.gsub(str, '[^a-zA-Z0-9_]',
- function(c)
- return string.format('%%%02X', string.byte(c))
- end
- )
- end
- return res
-end
-
-local function uri_unescape(str, unescape_plus_sign)
- local res = {}
- if type(str) == 'table' then
- for _, v in pairs(str) do
- table.insert(res, uri_unescape(v))
- end
- else
- if unescape_plus_sign ~= nil then
- str = string.gsub(str, '+', ' ')
- end
-
- res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])',
- function(c)
- return string.char(tonumber(c, 16))
- end
- )
- end
- return res
-end
-
-local function extend(tbl, tblu, raise)
- local res = {}
- for k, v in pairs(tbl) do
- res[ k ] = v
- end
- for k, v in pairs(tblu) do
- if raise then
- if res[ k ] == nil then
- errorf("Unknown option '%s'", k)
- end
- end
- res[ k ] = v
- end
- return res
-end
-
-local function type_by_format(fmt)
- if fmt == nil then
- return 'application/octet-stream'
- end
-
- local t = mime_types[ fmt ]
-
- if t ~= nil then
- return t
- end
-
- return 'application/octet-stream'
-end
-
-local function reason_by_code(code)
- code = tonumber(code)
- if codes[code] ~= nil then
- return codes[code]
- end
- return sprintf('Unknown code %d', code)
-end
-
-local function ucfirst(str)
- return str:gsub("^%l", string.upper, 1)
-end
-
-local function cached_query_param(self, name)
- if name == nil then
- return self.query_params
- end
- return self.query_params[ name ]
-end
-
-local function cached_post_param(self, name)
- if name == nil then
- return self.post_params
- end
- return self.post_params[ name ]
-end
-
-local function request_tostring(self)
- local res = self:request_line() .. "\r\n"
-
- for hn, hv in pairs(self.headers) do
- res = sprintf("%s%s: %s\r\n", res, ucfirst(hn), hv)
- end
-
- return sprintf("%s\r\n%s", res, self.body)
-end
-
-local function request_line(self)
- local rstr = self.path
- if string.len(self.query) then
- rstr = rstr .. '?' .. self.query
- end
- return sprintf("%s %s HTTP/%d.%d",
- self.method, rstr, self.proto[1], self.proto[2])
-end
-
-local function query_param(self, name)
- if self.query == nil and string.len(self.query) == 0 then
- rawset(self, 'query_params', {})
- else
- local params = lib.params(self.query)
- local pres = {}
- for k, v in pairs(params) do
- pres[ uri_unescape(k) ] = uri_unescape(v)
- end
- rawset(self, 'query_params', pres)
- end
-
- rawset(self, 'query_param', cached_query_param)
- return self:query_param(name)
-end
-
-local function request_content_type(self)
- -- returns content type without encoding string
- if self.headers['content-type'] == nil then
- return nil
- end
-
- return string.match(self.headers['content-type'],
- '^([^;]*)$') or
- string.match(self.headers['content-type'],
- '^(.*);.*')
-end
-
-local function post_param(self, name)
- local content_type = self:content_type()
- if self:content_type() == 'multipart/form-data' then
- -- TODO: do that!
- rawset(self, 'post_params', {})
- elseif self:content_type() == 'application/json' then
- local params = self:json()
- rawset(self, 'post_params', params)
- elseif self:content_type() == 'application/x-www-form-urlencoded' then
- local params = lib.params(self:read_cached())
- local pres = {}
- for k, v in pairs(params) do
- pres[ uri_unescape(k) ] = uri_unescape(v, true)
- end
- rawset(self, 'post_params', pres)
- else
- local params = lib.params(self:read_cached())
- local pres = {}
- for k, v in pairs(params) do
- pres[ uri_unescape(k) ] = uri_unescape(v)
- end
- rawset(self, 'post_params', pres)
- end
-
- rawset(self, 'post_param', cached_post_param)
- return self:post_param(name)
-end
-
-local function param(self, name)
- if name ~= nil then
- local v = self:post_param(name)
- if v ~= nil then
- return v
- end
- return self:query_param(name)
- end
-
- local post = self:post_param()
- local query = self:query_param()
- return extend(post, query, false)
-end
-
-local function catfile(...)
- local sp = { ... }
-
- local path
-
- if #sp == 0 then
- return
- end
-
- for i, pe in pairs(sp) do
- if path == nil then
- path = pe
- elseif string.match(path, '.$') ~= '/' then
- if string.match(pe, '^.') ~= '/' then
- path = path .. '/' .. pe
- else
- path = path .. pe
- end
- else
- if string.match(pe, '^.') == '/' then
- path = path .. string.gsub(pe, '^/', '', 1)
- else
- path = path .. pe
- end
- end
- end
-
- return path
-end
-
-local response_mt
-local request_mt
-
-local function expires_str(str)
-
- local now = os.time()
- local gmtnow = now - os.difftime(now, os.time(os.date("!*t", now)))
- local fmt = '%a, %d-%b-%Y %H:%M:%S GMT'
-
- if str == 'now' or str == 0 or str == '0' then
- return os.date(fmt, gmtnow)
- end
-
- local diff, period = string.match(str, '^[+]?(%d+)([hdmy])$')
- if period == nil then
- return str
- end
-
- diff = tonumber(diff)
- if period == 'h' then
- diff = diff * 3600
- elseif period == 'd' then
- diff = diff * 86400
- elseif period == 'm' then
- diff = diff * 86400 * 30
- else
- diff = diff * 86400 * 365
- end
-
- return os.date(fmt, gmtnow + diff)
-end
-
-local function setcookie(resp, cookie)
- local name = cookie.name
- local value = cookie.value
-
- if name == nil then
- error('cookie.name is undefined')
- end
- if value == nil then
- error('cookie.value is undefined')
- end
-
- local str = sprintf('%s=%s', name, uri_escape(value))
- if cookie.path ~= nil then
- str = sprintf('%s;path=%s', str, cookie.path)
- end
- if cookie.domain ~= nil then
- str = sprintf('%s;domain=%s', str, cookie.domain)
- end
-
- if cookie.expires ~= nil then
- str = sprintf('%s;expires=%s', str, expires_str(cookie.expires))
- end
-
- if not resp.headers then
- resp.headers = {}
- end
- if resp.headers['set-cookie'] == nil then
- resp.headers['set-cookie'] = { str }
- elseif type(resp.headers['set-cookie']) == 'string' then
- resp.headers['set-cookie'] = {
- resp.headers['set-cookie'],
- str
- }
- else
- table.insert(resp.headers['set-cookie'], str)
- end
- return resp
-end
-
-local function cookie(tx, cookie)
- if tx.headers.cookie == nil then
- return nil
- end
- for k, v in string.gmatch(
- tx.headers.cookie, "([^=,; \t]+)=([^,; \t]+)") do
- if k == cookie then
- return uri_unescape(v)
- end
- end
- return nil
-end
-
-local function url_for_helper(tx, name, args, query)
- return tx:url_for(name, args, query)
-end
-
-local function load_template(self, r, format)
- if r.template ~= nil then
- return
- end
-
- if format == nil then
- format = 'html'
- end
-
- local file
- if r.file ~= nil then
- file = r.file
- elseif r.controller ~= nil and r.action ~= nil then
- file = catfile(
- string.gsub(r.controller, '[.]', '/'),
- r.action .. '.' .. format .. '.el')
- else
- errorf("Can not find template for '%s'", r.path)
- end
-
- if self.options.cache_templates then
- if self.cache.tpl[ file ] ~= nil then
- return self.cache.tpl[ file ]
- end
- end
-
-
- local tpl = catfile(self.options.app_dir, 'templates', file)
- local fh = io.input(tpl)
- local template = fh:read('*a')
- fh:close()
-
- if self.options.cache_templates then
- self.cache.tpl[ file ] = template
- end
- return template
-end
-
-local function render(tx, opts)
- if tx == nil then
- error("Usage: self:render({ ... })")
- end
-
- local resp = setmetatable({ headers = {} }, response_mt)
- local vars = {}
- if opts ~= nil then
- if opts.text ~= nil then
- if tx.httpd.options.charset ~= nil then
- resp.headers['content-type'] =
- sprintf("text/plain; charset=%s",
- tx.httpd.options.charset
- )
- else
- resp.headers['content-type'] = 'text/plain'
- end
- resp.body = tostring(opts.text)
- return resp
- end
-
- if opts.json ~= nil then
- if tx.httpd.options.charset ~= nil then
- resp.headers['content-type'] =
- sprintf('application/json; charset=%s',
- tx.httpd.options.charset
- )
- else
- resp.headers['content-type'] = 'application/json'
- end
- resp.body = json.encode(opts.json)
- return resp
- end
-
- if opts.data ~= nil then
- resp.body = tostring(opts.data)
- return resp
- end
-
- vars = extend(tx.tstash, opts, false)
- end
-
- local tpl
-
- local format = tx.tstash.format
- if format == nil then
- format = 'html'
- end
-
- if tx.endpoint.template ~= nil then
- tpl = tx.endpoint.template
- else
- tpl = load_template(tx.httpd, tx.endpoint, format)
- if tpl == nil then
- errorf('template is not defined for the route')
- end
- end
-
- if type(tpl) == 'function' then
- tpl = tpl()
- end
-
- for hname, sub in pairs(tx.httpd.helpers) do
- vars[hname] = function(...) return sub(tx, ...) end
- end
- vars.action = tx.endpoint.action
- vars.controller = tx.endpoint.controller
- vars.format = format
-
- resp.body = lib.template(tpl, vars)
- resp.headers['content-type'] = type_by_format(format)
-
- if tx.httpd.options.charset ~= nil then
- if format == 'html' or format == 'js' or format == 'json' then
- resp.headers['content-type'] = resp.headers['content-type']
- .. '; charset=' .. tx.httpd.options.charset
- end
- end
- return resp
-end
-
-local function iterate(tx, gen, param, state)
- return setmetatable({ body = { gen = gen, param = param, state = state } },
- response_mt)
-end
-
-local function redirect_to(tx, name, args, query)
- local location = tx:url_for(name, args, query)
- return setmetatable({ status = 302, headers = { location = location } },
- response_mt)
-end
-
-local function access_stash(tx, name, ...)
- if type(tx) ~= 'table' then
- error("usage: ctx:stash('name'[, 'value'])")
- end
- if select('#', ...) > 0 then
- tx.tstash[ name ] = select(1, ...)
- end
-
- return tx.tstash[ name ]
-end
-
-local function url_for_tx(tx, name, args, query)
- if name == 'current' then
- return tx.endpoint:url_for(args, query)
- end
- return tx.httpd:url_for(name, args, query)
-end
-
-local function request_json(req)
- local data = req:read_cached()
- local s, json = pcall(json.decode, data)
- if s then
- return json
- else
- error(sprintf("Can't decode json in request '%s': %s",
- data, tostring(json)))
- return nil
- end
-end
-
-local function request_read(req, opts, timeout)
- local remaining = req._remaining
- if not remaining then
- remaining = tonumber(req.headers['content-length'])
- if not remaining then
- return ''
- end
- end
-
- if opts == nil then
- opts = remaining
- elseif type(opts) == 'number' then
- if opts > remaining then
- opts = remaining
- end
- elseif type(opts) == 'string' then
- opts = { size = remaining, delimiter = opts }
- elseif type(opts) == 'table' then
- local size = opts.size or opts.chunk
- if size and size > remaining then
- opts.size = remaining
- opts.chunk = nil
- end
- end
-
- local buf = req.s:read(opts, timeout)
- if buf == nil then
- req._remaining = 0
- return ''
- end
- remaining = remaining - #buf
- assert(remaining >= 0)
- req._remaining = remaining
- return buf
-end
-
-local function request_read_cached(self)
- if self.cached_data == nil then
- local data = self:read()
- rawset(self, 'cached_data', data)
- return data
- else
- return self.cached_data
- end
-end
-
-local function static_file(self, request, format)
- local file = catfile(self.options.app_dir, 'public', request.path)
-
- if self.options.cache_static and self.cache.static[ file ] ~= nil then
- return {
- code = 200,
- headers = {
- [ 'content-type'] = type_by_format(format),
- },
- body = self.cache.static[ file ]
- }
- end
-
- local s, fh = pcall(io.input, file)
-
- if not s then
- return { status = 404 }
- end
-
- local body = fh:read('*a')
- io.close(fh)
-
- if self.options.cache_static then
- self.cache.static[ file ] = body
- end
-
- return {
- status = 200,
- headers = {
- [ 'content-type'] = type_by_format(format),
- },
- body = body
- }
-end
-
-request_mt = {
- __index = {
- render = render,
- cookie = cookie,
- redirect_to = redirect_to,
- iterate = iterate,
- stash = access_stash,
- url_for = url_for_tx,
- content_type= request_content_type,
- request_line= request_line,
- read_cached = request_read_cached,
- query_param = query_param,
- post_param = post_param,
- param = param,
- read = request_read,
- json = request_json
- },
- __tostring = request_tostring;
-}
-
-response_mt = {
- __index = {
- setcookie = setcookie;
- }
-}
-
-local function is_function(obj)
- return type(obj) == 'function'
-end
-
-local function get_request_logger(server_opts, route_opts)
- if route_opts and route_opts.endpoint.log_requests ~= nil then
- if is_function(route_opts.endpoint.log_requests) then
- return route_opts.endpoint.log_requests
- elseif route_opts.endpoint.log_requests == false then
- return log.debug
- end
- end
-
- if server_opts.log_requests then
- if is_function(server_opts.log_requests) then
- return server_opts.log_requests
- end
-
- return log.info
- end
-
- return log.debug
-end
-
-local function get_error_logger(server_opts, route_opts)
- if route_opts and route_opts.endpoint.log_errors ~= nil then
- if is_function(route_opts.endpoint.log_errors) then
- return route_opts.endpoint.log_errors
- elseif route_opts.endpoint.log_errors == false then
- return log.debug
- end
- end
-
- if server_opts.log_errors then
- if is_function(server_opts.log_errors) then
- return server_opts.log_errors
- end
-
- return log.error
- end
-
- return log.debug
-end
-
-local function handler(self, request)
- if self.hooks.before_dispatch ~= nil then
- self.hooks.before_dispatch(self, request)
- end
-
- local format = 'html'
-
- local pformat = string.match(request.path, '[.]([^.]+)$')
- if pformat ~= nil then
- format = pformat
- end
-
- local r = self:match(request.method, request.path)
- if r == nil then
- return static_file(self, request, format)
- end
-
- local stash = extend(r.stash, { format = format })
-
- request.endpoint = r.endpoint
- request.tstash = stash
-
- local resp = r.endpoint.sub(request)
- if self.hooks.after_dispatch ~= nil then
- self.hooks.after_dispatch(request, resp)
- end
- return resp
-end
-
-local function normalize_headers(hdrs)
- local res = {}
- for h, v in pairs(hdrs) do
- res[ string.lower(h) ] = v
- end
- return res
-end
-
-local function parse_request(req)
- local p = lib._parse_request(req)
- if p.error then
- return p
- end
- p.path = uri_unescape(p.path)
- if p.path:sub(1, 1) ~= "/" or p.path:find("./", nil, true) ~= nil then
- p.error = "invalid uri"
- return p
- end
- return p
-end
-
-local function process_client(self, s, peer)
- while true do
- local hdrs = ''
-
- local is_eof = false
- while true do
- local chunk = s:read{
- delimiter = { "\n\n", "\r\n\r\n" }
- }
-
- if chunk == '' then
- is_eof = true
- break -- eof
- elseif chunk == nil then
- log.error('failed to read request: %s', errno.strerror())
- return
- end
-
- hdrs = hdrs .. chunk
-
- if string.endswith(hdrs, "\n\n") or string.endswith(hdrs, "\r\n\r\n") then
- break
- end
- end
-
- if is_eof then
- break
- end
-
- log.debug("request:\n%s", hdrs)
- local p = parse_request(hdrs)
- if p.error ~= nil then
- log.error('failed to parse request: %s', p.error)
- s:write(sprintf("HTTP/1.0 400 Bad request\r\n\r\n%s", p.error))
- break
- end
- p.httpd = self
- p.s = s
- p.peer = peer
- setmetatable(p, request_mt)
-
- if p.headers['expect'] == '100-continue' then
- s:write('HTTP/1.0 100 Continue\r\n\r\n')
- end
-
- local route = self:match(p.method, p.path)
- local logreq = get_request_logger(self.options, route)
- logreq("%s %s%s", p.method, p.path,
- p.query ~= "" and "?"..p.query or "")
-
- local res, reason = pcall(self.options.handler, self, p)
- p:read() -- skip remaining bytes of request body
- local status, hdrs, body
-
- if not res then
- status = 500
- hdrs = {}
- local trace = debug.traceback()
- local logerror = get_error_logger(self.options, route)
- logerror('unhandled error: %s\n%s\nrequest:\n%s',
- tostring(reason), trace, tostring(p))
- if self.options.display_errors then
- body =
- "Unhandled error: " .. tostring(reason) .. "\n"
- .. trace .. "\n\n"
- .. "\n\nRequest:\n"
- .. tostring(p)
- else
- body = "Internal Error"
- end
- elseif type(reason) == 'table' then
- if reason.status == nil then
- status = 200
- elseif type(reason.status) == 'number' then
- status = reason.status
- else
- error('response.status must be a number')
- end
- if reason.headers == nil then
- hdrs = {}
- elseif type(reason.headers) == 'table' then
- hdrs = normalize_headers(reason.headers)
- else
- error('response.headers must be a table')
- end
- body = reason.body
- elseif reason == nil then
- status = 200
- hdrs = {}
- elseif type(reason) == 'number' then
- if reason == DETACHED then
- break
- end
- else
- error('invalid response')
- end
-
- local gen, param, state
- if type(body) == 'string' then
- -- Plain string
- hdrs['content-length'] = #body
- elseif type(body) == 'function' then
- -- Generating function
- gen = body
- hdrs['transfer-encoding'] = 'chunked'
- elseif type(body) == 'table' and body.gen then
- -- Iterator
- gen, param, state = body.gen, body.param, body.state
- hdrs['transfer-encoding'] = 'chunked'
- elseif body == nil then
- -- Empty body
- hdrs['content-length'] = 0
- else
- body = tostring(body)
- hdrs['content-length'] = #body
- end
-
- if hdrs.server == nil then
- hdrs.server = sprintf('Tarantool http (tarantool v%s)', _TARANTOOL)
- end
-
- if p.proto[1] ~= 1 then
- hdrs.connection = 'close'
- elseif p.broken then
- hdrs.connection = 'close'
- elseif rawget(p, 'body') == nil then
- hdrs.connection = 'close'
- elseif p.proto[2] == 1 then
- if p.headers.connection == nil then
- hdrs.connection = 'keep-alive'
- elseif string.lower(p.headers.connection) ~= 'keep-alive' then
- hdrs.connection = 'close'
- else
- hdrs.connection = 'keep-alive'
- end
- elseif p.proto[2] == 0 then
- if p.headers.connection == nil then
- hdrs.connection = 'close'
- elseif string.lower(p.headers.connection) == 'keep-alive' then
- hdrs.connection = 'keep-alive'
- else
- hdrs.connection = 'close'
- end
- end
-
- local response = {
- "HTTP/1.1 ";
- status;
- " ";
- reason_by_code(status);
- "\r\n";
- };
- for k, v in pairs(hdrs) do
- if type(v) == 'table' then
- for i, sv in pairs(v) do
- table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), sv))
- end
- else
- table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), v))
- end
- end
- table.insert(response, "\r\n")
-
- if type(body) == 'string' then
- table.insert(response, body)
- response = table.concat(response)
- if not s:write(response) then
- break
- end
- elseif gen then
- response = table.concat(response)
- if not s:write(response) then
- break
- end
- response = nil
- -- Transfer-Encoding: chunked
- for _, part in gen, param, state do
- part = tostring(part)
- if not s:write(sprintf("%x\r\n%s\r\n", #part, part)) then
- break
- end
- end
- if not s:write("0\r\n\r\n") then
- break
- end
- else
- response = table.concat(response)
- if not s:write(response) then
- break
- end
- end
-
- if p.proto[1] ~= 1 then
- break
- end
-
- if hdrs.connection ~= 'keep-alive' then
- break
- end
- end
-end
-
-local function httpd_stop(self)
- if type(self) ~= 'table' then
- error("httpd: usage: httpd:stop()")
- end
- if self.is_run then
- self.is_run = false
- else
- error("server is already stopped")
- end
-
- if self.tcp_server ~= nil then
- self.tcp_server:close()
- self.tcp_server = nil
- end
- return self
-end
-
-local function match_route(self, method, route)
- -- route must have '/' at the begin and end
- if string.match(route, '.$') ~= '/' then
- route = route .. '/'
- end
- if string.match(route, '^.') ~= '/' then
- route = '/' .. route
- end
-
- method = string.upper(method)
-
- local fit
- local stash = {}
-
- for k, r in pairs(self.routes) do
- if r.method == method or r.method == 'ANY' then
- local m = { string.match(route, r.match) }
- local nfit
- if #m > 0 then
- if #r.stash > 0 then
- if #r.stash == #m then
- nfit = r
- end
- else
- nfit = r
- end
-
- if nfit ~= nil then
- if fit == nil then
- fit = nfit
- stash = m
- else
- if #fit.stash > #nfit.stash then
- fit = nfit
- stash = m
- elseif r.method ~= fit.method then
- if fit.method == 'ANY' then
- fit = nfit
- stash = m
- end
- end
- end
- end
- end
- end
- end
-
- if fit == nil then
- return fit
- end
- local resstash = {}
- for i = 1, #fit.stash do
- resstash[ fit.stash[ i ] ] = stash[ i ]
- end
- return { endpoint = fit, stash = resstash }
-end
-
-local function set_helper(self, name, sub)
- if sub == nil or type(sub) == 'function' then
- self.helpers[ name ] = sub
- return self
- end
- errorf("Wrong type for helper function: %s", type(sub))
-end
-
-local function set_hook(self, name, sub)
- if sub == nil or type(sub) == 'function' then
- self.hooks[ name ] = sub
- return self
- end
- errorf("Wrong type for hook function: %s", type(sub))
-end
-
-local function url_for_route(r, args, query)
- if args == nil then
- args = {}
- end
- local name = r.path
- for i, sn in pairs(r.stash) do
- local sv = args[sn]
- if sv == nil then
- sv = ''
- end
- name = string.gsub(name, '[*:]' .. sn, sv, 1)
- end
-
- if query ~= nil then
- if type(query) == 'table' then
- local sep = '?'
- for k, v in pairs(query) do
- name = name .. sep .. uri_escape(k) .. '=' .. uri_escape(v)
- sep = '&'
- end
- else
- name = name .. '?' .. query
- end
- end
-
- if string.match(name, '^/') == nil then
- return '/' .. name
- else
- return name
- end
-end
-
-local function ctx_action(tx)
- local ctx = tx.endpoint.controller
- local action = tx.endpoint.action
- if tx.httpd.options.cache_controllers then
- if tx.httpd.cache[ ctx ] ~= nil then
- if type(tx.httpd.cache[ ctx ][ action ]) ~= 'function' then
- errorf("Controller '%s' doesn't contain function '%s'",
- ctx, action)
- end
- return tx.httpd.cache[ ctx ][ action ](tx)
- end
- end
-
- local ppath = package.path
- package.path = catfile(tx.httpd.options.app_dir, 'controllers', '?.lua')
- .. ';'
- .. catfile(tx.httpd.options.app_dir,
- 'controllers', '?/init.lua')
- if ppath ~= nil then
- package.path = package.path .. ';' .. ppath
- end
-
- local st, mod = pcall(require, ctx)
- package.path = ppath
- package.loaded[ ctx ] = nil
-
- if not st then
- errorf("Can't load module '%s': %s'", ctx, tostring(mod))
- end
-
- if type(mod) ~= 'table' then
- errorf("require '%s' didn't return table", ctx)
- end
-
- if type(mod[ action ]) ~= 'function' then
- errorf("Controller '%s' doesn't contain function '%s'", ctx, action)
- end
-
- if tx.httpd.options.cache_controllers then
- tx.httpd.cache[ ctx ] = mod
- end
-
- return mod[action](tx)
-end
-
-local possible_methods = {
- GET = 'GET',
- HEAD = 'HEAD',
- POST = 'POST',
- PUT = 'PUT',
- DELETE = 'DELETE',
- PATCH = 'PATCH',
-}
-
-local function add_route(self, opts, sub)
- if type(opts) ~= 'table' or type(self) ~= 'table' then
- error("Usage: httpd:route({ ... }, function(cx) ... end)")
- end
-
- opts = extend({method = 'ANY'}, opts, false)
-
- local ctx
- local action
-
- if sub == nil then
- sub = render
- elseif type(sub) == 'string' then
-
- ctx, action = string.match(sub, '(.+)#(.*)')
-
- if ctx == nil or action == nil then
- errorf("Wrong controller format '%s', must be 'module#action'", sub)
- end
-
- sub = ctx_action
-
- elseif type(sub) ~= 'function' then
- errorf("wrong argument: expected function, but received %s",
- type(sub))
- end
-
- opts.method = possible_methods[string.upper(opts.method)] or 'ANY'
-
- if opts.path == nil then
- error("path is not defined")
- end
-
- opts.controller = ctx
- opts.action = action
- opts.match = opts.path
- opts.match = string.gsub(opts.match, '[-]', "[-]")
-
- local estash = { }
- local stash = { }
- while true do
- local name = string.match(opts.match, ':([%a_][%w_]*)')
- if name == nil then
- break
- end
- if estash[name] then
- errorf("duplicate stash: %s", name)
- end
- estash[name] = true
- opts.match = string.gsub(opts.match, ':[%a_][%w_]*', '([^/]-)', 1)
-
- table.insert(stash, name)
- end
- while true do
- local name = string.match(opts.match, '[*]([%a_][%w_]*)')
- if name == nil then
- break
- end
- if estash[name] then
- errorf("duplicate stash: %s", name)
- end
- estash[name] = true
- opts.match = string.gsub(opts.match, '[*][%a_][%w_]*', '(.-)', 1)
-
- table.insert(stash, name)
- end
-
- if string.match(opts.match, '.$') ~= '/' then
- opts.match = opts.match .. '/'
- end
- if string.match(opts.match, '^.') ~= '/' then
- opts.match = '/' .. opts.match
- end
-
- opts.match = '^' .. opts.match .. '$'
-
- estash = nil
-
- opts.stash = stash
- opts.sub = sub
- opts.url_for = url_for_route
-
- if opts.log_requests ~= nil then
- if type(opts.log_requests) ~= 'function' and type(opts.log_requests) ~= 'boolean' then
- error("'log_requests' option should be a function or a boolean")
- end
- end
-
- if opts.log_errors ~= nil then
- if type(opts.log_errors) ~= 'function' and type(opts.log_errors) ~= 'boolean' then
- error("'log_errors' option should be a function or a boolean")
- end
- end
-
- if opts.name ~= nil then
- if opts.name == 'current' then
- error("Route can not have name 'current'")
- end
- if self.iroutes[ opts.name ] ~= nil then
- errorf("Route with name '%s' is already exists", opts.name)
- end
- table.insert(self.routes, opts)
- self.iroutes[ opts.name ] = #self.routes
- else
- table.insert(self.routes, opts)
- end
- return self
-end
-
-local function url_for_httpd(httpd, name, args, query)
-
- local idx = httpd.iroutes[ name ]
- if idx ~= nil then
- return httpd.routes[ idx ]:url_for(args, query)
- end
-
- if string.match(name, '^/') == nil then
- if string.match(name, '^https?://') ~= nil then
- return name
- else
- return '/' .. name
- end
- else
- return name
- end
-end
-
-local function httpd_start(self)
- if type(self) ~= 'table' then
- error("httpd: usage: httpd:start()")
- end
-
- local server = socket.tcp_server(self.host, self.port,
- { name = 'http',
- handler = function(...)
- local res = process_client(self, ...)
- end})
- if server == nil then
- error(sprintf("Can't create tcp_server: %s", errno.strerror()))
- end
-
- rawset(self, 'is_run', true)
- rawset(self, 'tcp_server', server)
- rawset(self, 'stop', httpd_stop)
-
- return self
-end
-
-local exports = {
- DETACHED = DETACHED,
-
- new = function(host, port, options)
- if options == nil then
- options = {}
- end
- if type(options) ~= 'table' then
- errorf("options must be table not '%s'", type(options))
- end
- local default = {
- max_header_size = 4096,
- header_timeout = 100,
- handler = handler,
- app_dir = '.',
- charset = 'utf-8',
- cache_templates = true,
- cache_controllers = true,
- cache_static = true,
- log_requests = true,
- log_errors = true,
- display_errors = true,
- }
-
- local self = {
- host = host,
- port = port,
- is_run = false,
- stop = httpd_stop,
- start = httpd_start,
- options = extend(default, options, true),
-
- routes = { },
- iroutes = { },
- helpers = {
- url_for = url_for_helper,
- },
- hooks = { },
-
- -- methods
- route = add_route,
- match = match_route,
- helper = set_helper,
- hook = set_hook,
- url_for = url_for_httpd,
-
- -- caches
- cache = {
- tpl = {},
- ctx = {},
- static = {},
- },
- }
-
- return self
- end
-}
-
-return exports
diff --git a/http/server/init.lua b/http/server/init.lua
new file mode 100644
index 0000000..3b3385b
--- /dev/null
+++ b/http/server/init.lua
@@ -0,0 +1,353 @@
+local tsgi_adapter = require('http.server.tsgi_adapter')
+
+local tsgi = require('http.tsgi')
+local lib = require('http.lib')
+local utils = require('http.utils')
+
+local log = require('log')
+local socket = require('socket')
+local errno = require('errno')
+
+local DETACHED = 101
+
+local ok, VERSION = pcall(require, 'http.VERSION')
+if not ok then
+ VERSION = 'unknown'
+end
+
+---------
+-- Utils
+---------
+
+local function normalize_headers(hdrs)
+ local res = {}
+ for h, v in pairs(hdrs) do
+ res[ string.lower(h) ] = v
+ end
+ return res
+end
+
+local function headers_ended(hdrs)
+ return string.endswith(hdrs, "\n\n")
+ or string.endswith(hdrs, "\r\n\r\n")
+end
+
+----------
+-- Server
+----------
+
+local function parse_request(req)
+ local p = lib._parse_request(req)
+ if p.error then
+ return p
+ end
+ p.path = utils.uri_unescape(p.path)
+ if p.path:sub(1, 1) ~= "/" or p.path:find("./", nil, true) ~= nil then
+ p.error = "invalid uri"
+ return p
+ end
+ return p
+end
+
+local function process_client(self, s, peer)
+ while true do
+ -- read headers, until double CRLF
+ local hdrs = ''
+
+ local is_eof = false
+ while true do
+ local chunk = s:read{
+ delimiter = { "\n\n", "\r\n\r\n" }
+ }
+
+ if chunk == '' then
+ is_eof = true
+ break -- eof
+ elseif chunk == nil then
+ log.error('failed to read request: %s', errno.strerror())
+ return
+ end
+
+ hdrs = hdrs .. chunk
+
+ if headers_ended(hdrs) then
+ break
+ end
+ end
+
+ if is_eof then
+ break
+ end
+
+ -- parse headers
+ log.debug("request:\n%s", hdrs)
+ local p = parse_request(hdrs)
+ if p.error ~= nil then
+ log.error('failed to parse request: %s', p.error)
+ s:write(utils.sprintf("HTTP/1.0 400 Bad request\r\n\r\n%s", p.error))
+ break
+ end
+
+ local env = tsgi_adapter.make_env({
+ parsed_request = p,
+ sock = s,
+ httpd = self,
+ peer = peer,
+ })
+
+ if env['HEADER_EXPECT'] == '100-continue' then
+ s:write('HTTP/1.0 100 Continue\r\n\r\n')
+ end
+
+ local logreq = self.options.log_requests and log.info or log.debug
+ logreq("%s %s%s", p.method, p.path,
+ p.query ~= "" and "?"..p.query or "")
+
+ local ok, resp = pcall(self.options.router, env)
+ env['tsgi.input']:read() -- skip remaining bytes of request body
+ local status, body
+
+ -- DETACHED: dont close socket, but quit processing HTTP
+ if env[tsgi.KEY_IS_HIJACKED] == true then
+ break
+ end
+
+ -- set response headers
+ if not ok then
+ status = 500
+ hdrs = {}
+ local trace = debug.traceback()
+ local logerror = self.options.log_errors and log.error or log.debug
+
+ -- TODO: copypaste
+ logerror('unhandled error: %s\n%s\nrequest:\n%s',
+ tostring(resp), trace, tsgi.serialize_request(env))
+ if self.options.display_errors then
+ -- TODO: env could be changed. we need to save a copy of it
+ body =
+ "Unhandled error: " .. tostring(resp) .. "\n"
+ .. trace .. "\n\n"
+ .. "\n\nRequest:\n"
+ .. tsgi.serialize_request(env)
+ else
+ body = "Internal Error"
+ end
+ elseif type(resp) == 'table' then
+ if resp.status == nil then
+ status = 200
+ elseif type(resp.status) == 'number' then
+ status = resp.status
+ else
+ error('response.status must be a number')
+ end
+ if resp.headers == nil then
+ hdrs = {}
+ elseif type(resp.headers) == 'table' then
+ hdrs = normalize_headers(resp.headers)
+ else
+ error('response.headers must be a table')
+ end
+ body = resp.body
+ elseif resp == nil then
+ status = 200
+ hdrs = {}
+ elseif type(resp) == 'number' then
+ if resp == DETACHED then
+ break
+ end
+ else
+ error('invalid response')
+ end
+
+ -- set more response headers
+ local gen, param, state
+ if type(body) == 'string' then
+ -- Plain string
+ hdrs['content-length'] = #body
+ elseif type(body) == 'function' then
+ -- Generating function
+ gen = body
+ hdrs['transfer-encoding'] = 'chunked'
+ elseif type(body) == 'table' and body.gen then
+ -- Iterator
+ gen, param, state = body.gen, body.param, body.state
+ hdrs['transfer-encoding'] = 'chunked'
+ elseif body == nil then
+ -- Empty body
+ hdrs['content-length'] = 0
+ else
+ body = tostring(body)
+ hdrs['content-length'] = #body
+ end
+
+ if hdrs.server == nil then
+ hdrs.server = utils.sprintf('Tarantool http (tarantool v%s)', _TARANTOOL)
+ end
+
+ -- handle even more response headers
+ if p.proto[1] ~= 1 then
+ hdrs.connection = 'close'
+ elseif p.broken then
+ hdrs.connection = 'close'
+ elseif rawget(p, 'body') == nil then
+ hdrs.connection = 'close'
+ elseif p.proto[2] == 1 then
+ if p.headers.connection == nil then
+ hdrs.connection = 'keep-alive'
+ elseif string.lower(p.headers.connection) ~= 'keep-alive' then
+ hdrs.connection = 'close'
+ else
+ hdrs.connection = 'keep-alive'
+ end
+ elseif p.proto[2] == 0 then
+ if p.headers.connection == nil then
+ hdrs.connection = 'close'
+ elseif string.lower(p.headers.connection) == 'keep-alive' then
+ hdrs.connection = 'keep-alive'
+ else
+ hdrs.connection = 'close'
+ end
+ end
+
+ -- generate response {{{
+ local response = {
+ "HTTP/1.1 ";
+ status;
+ " ";
+ utils.reason_by_code(status);
+ "\r\n";
+ };
+ for k, v in pairs(hdrs) do
+ if type(v) == 'table' then
+ for _, sv in pairs(v) do
+ table.insert(response, utils.sprintf("%s: %s\r\n", utils.ucfirst(k), sv))
+ end
+ else
+ table.insert(response, utils.sprintf("%s: %s\r\n", utils.ucfirst(k), v))
+ end
+ end
+ table.insert(response, "\r\n")
+
+ if type(body) == 'string' then
+ table.insert(response, body)
+ response = table.concat(response)
+ if not s:write(response) then
+ break
+ end
+ elseif gen then
+ response = table.concat(response)
+ if not s:write(response) then
+ break
+ end
+ response = nil -- luacheck: ignore 311
+ -- Transfer-Encoding: chunked
+ for _, part in gen, param, state do
+ part = tostring(part)
+ if not s:write(utils.sprintf("%x\r\n%s\r\n", #part, part)) then
+ break
+ end
+ end
+ if not s:write("0\r\n\r\n") then
+ break
+ end
+ else
+ response = table.concat(response)
+ if not s:write(response) then
+ break
+ end
+ end
+ -- }}}
+
+ if p.proto[1] ~= 1 then
+ break
+ end
+
+ if hdrs.connection ~= 'keep-alive' then
+ break
+ end
+ end
+end
+
+local function httpd_stop(self)
+ if type(self) ~= 'table' then
+ error("httpd: usage: httpd:stop()")
+ end
+ if self.is_run then
+ self.is_run = false
+ else
+ error("server is already stopped")
+ end
+
+ if self.tcp_server ~= nil then
+ self.tcp_server:close()
+ self.tcp_server = nil
+ end
+ return self
+end
+
+
+local function httpd_start(self)
+ if type(self) ~= 'table' then
+ error("httpd: usage: httpd:start()")
+ end
+
+ assert(self.options.router ~= nil, 'Router must be set before calling server:start()')
+
+ local server = socket.tcp_server(self.host, self.port,
+ { name = 'http',
+ handler = function(...)
+ local _ = process_client(self, ...)
+ end})
+ if server == nil then
+ error(utils.sprintf("Can't create tcp_server: %s", errno.strerror()))
+ end
+
+ rawset(self, 'is_run', true)
+ rawset(self, 'tcp_server', server)
+ rawset(self, 'stop', httpd_stop)
+
+ return self
+end
+
+local function httpd_set_router(self, router)
+ self.options.router = router
+end
+
+local function httpd_router(self)
+ return self.options.router
+end
+
+local new = function(host, port, options)
+ if options == nil then
+ options = {}
+ end
+ if type(options) ~= 'table' then
+ utils.errorf("options must be table not '%s'", type(options))
+ end
+
+ local default = {
+ router = nil, -- no router set-up initially
+ log_requests = true,
+ log_errors = true,
+ display_errors = true,
+ }
+
+ local self = {
+ host = host,
+ port = port,
+ is_run = false,
+ stop = httpd_stop,
+ start = httpd_start,
+ set_router = httpd_set_router,
+ router = httpd_router,
+ options = utils.extend(default, options, true),
+ }
+
+ return self
+end
+
+return {
+ VERSION = VERSION,
+ DETACHED = DETACHED,
+ new = new,
+}
diff --git a/http/server/tsgi_adapter.lua b/http/server/tsgi_adapter.lua
new file mode 100644
index 0000000..a7f352f
--- /dev/null
+++ b/http/server/tsgi_adapter.lua
@@ -0,0 +1,117 @@
+local tsgi = require('http.tsgi')
+
+local checks = require('checks')
+
+local function tsgi_hijack(env)
+ env[tsgi.KEY_IS_HIJACKED] = true
+
+ local sock = env[tsgi.KEY_SOCK]
+ return sock
+end
+
+-- TODO: understand this. Maybe rewrite it to only follow
+-- TSGI logic, and not router logic.
+--
+-- if opts is number, it specifies number of bytes to be read
+-- if opts is a table, it specifies options
+local function tsgi_input_read(self, opts, timeout)
+ checks('table', '?number|string|table', '?number')
+ local env = self._env
+
+ local remaining = env[tsgi.KEY_REMAINING]
+ if not remaining then
+ remaining = tonumber(env['HEADER_CONTENT-LENGTH']) -- TODO: hyphen
+ if not remaining then
+ return ''
+ end
+ end
+
+ if opts == nil then
+ opts = remaining
+ elseif type(opts) == 'number' then
+ if opts > remaining then
+ opts = remaining
+ end
+ elseif type(opts) == 'string' then
+ opts = { size = remaining, delimiter = opts }
+ elseif type(opts) == 'table' then
+ local size = opts.size or opts.chunk
+ if size and size > remaining then
+ opts.size = remaining
+ opts.chunk = nil
+ end
+ end
+
+ local buf = env[tsgi.KEY_SOCK]:read(opts, timeout)
+ if buf == nil then
+ env[tsgi.KEY_REMAINING] = 0
+ return ''
+ end
+ remaining = remaining - #buf
+ assert(remaining >= 0)
+ env[tsgi.KEY_REMAINING] = remaining
+ return buf
+end
+
+local function convert_headername(name)
+ return 'HEADER_' .. string.upper(name) -- TODO: hyphens
+end
+
+local function make_env(opts)
+ local p = opts.parsed_request
+
+ local env = {
+ [tsgi.KEY_SOCK] = opts.sock,
+ [tsgi.KEY_HTTPD] = opts.httpd,
+ [tsgi.KEY_PARSED_REQUEST] = p, -- TODO: delete?
+ [tsgi.KEY_PEER] = opts.peer, -- TODO: delete?
+
+ ['tsgi.version'] = '1',
+ ['tsgi.url_scheme'] = 'http', -- no support for https yet
+ ['tsgi.input'] = {
+ read = tsgi_input_read,
+ rewind = nil, -- non-rewindable by default
+ },
+
+ ['REQUEST_METHOD'] = p.method,
+ ['PATH_INFO'] = p.path,
+ ['QUERY_STRING'] = p.query,
+ ['SERVER_NAME'] = opts.httpd.host,
+ ['SERVER_PORT'] = opts.httpd.port,
+ ['SERVER_PROTOCOL'] = string.format('HTTP/%d.%d', p.proto[1], p.proto[2]),
+ }
+
+ -- Pass through `env` to env['tsgi.*']:*() functions
+ env['tsgi.input']._env = env
+ env['tsgi.hijack'] = setmetatable(env, {
+ __call = tsgi_hijack,
+ })
+
+ -- set headers
+ for name, value in pairs(p.headers) do
+ env[convert_headername(name)] = value
+ end
+
+ -- SCRIPT_NAME is a virtual location of your app.
+ --
+ -- Imagine you want to serve your HTTP API under prefix /test
+ -- and later move it to /.
+ --
+ -- Instead of rewriting endpoints to your application, you do:
+ --
+ -- location /test/ {
+ -- proxy_pass http://127.0.0.1:8001/test/;
+ -- proxy_redirect http://127.0.0.1:8001/test/ http://$host/test/;
+ -- proxy_set_header SCRIPT_NAME /test;
+ -- }
+ --
+ -- Application source code is not touched.
+ env['SCRIPT_NAME'] = env['HTTP_SCRIPT_NAME'] or ''
+ env['HTTP_SCRIPT_NAME'] = nil
+
+ return env
+end
+
+return {
+ make_env = make_env,
+}
diff --git a/http/tsgi.lua b/http/tsgi.lua
new file mode 100644
index 0000000..7ab5c50
--- /dev/null
+++ b/http/tsgi.lua
@@ -0,0 +1,93 @@
+-- TSGI helper functions
+
+local utils = require('http.utils')
+
+local KEY_HTTPD = 'tarantool.http.httpd'
+local KEY_SOCK = 'tarantool.http.sock'
+local KEY_REMAINING = 'tarantool.http.sock_remaining_len'
+local KEY_PARSED_REQUEST = 'tarantool.http.parsed_request'
+local KEY_PEER = 'tarantool.http.peer'
+local KEY_ROUTE = 'tarantool.http.route'
+local KEY_ROUTER = 'tarantool.http.router'
+local KEY_IS_HIJACKED = 'tarantool.http.server.is_hijacked'
+
+local KEY_MIDDLEWARE_CALLCHAIN_CURRENT = 'tarantool.middleware.callchain_current'
+local KEY_MIDDLEWARE_CALLCHAIN_TABLE = 'tarantool.middleware.callchain_table'
+
+-- XXX: do it with lua-iterators
+local function headers(env)
+ local map = {}
+ for name, value in pairs(env) do
+ if string.startswith(name, 'HEADER_') then
+ map[name] = value
+ end
+ end
+ return map
+end
+
+local function serialize_request(env)
+ -- {{{
+ -- TODO: copypaste from router/request.lua.
+ -- maybe move it to tsgi.lua.
+
+ local res = env['PATH_INFO']
+ local query_string = env['QUERY_STRING']
+ if query_string ~= nil and query_string ~= '' then
+ res = res .. '?' .. query_string
+ end
+
+ res = utils.sprintf("%s %s %s",
+ env['REQUEST_METHOD'],
+ res,
+ env['SERVER_PROTOCOL'] or 'HTTP/?')
+ res = res .. "\r\n"
+ -- }}} end of request_line copypaste
+
+ for hn, hv in pairs(headers(env)) do
+ res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv)
+ end
+
+ -- return utils.sprintf("%s\r\n%s", res, self:read_cached())
+ -- NOTE: no body is logged.
+ return res
+end
+
+local function middleware_init_handlers(env)
+ env[KEY_MIDDLEWARE_CALLCHAIN_CURRENT] = 0
+ env[KEY_MIDDLEWARE_CALLCHAIN_TABLE] = {}
+end
+
+local function middleware_invoke_next_handler(env)
+ local callchain = env[KEY_MIDDLEWARE_CALLCHAIN_TABLE]
+ local next_handler_id = env[KEY_MIDDLEWARE_CALLCHAIN_CURRENT] + 1
+ local next_handler = callchain[next_handler_id]
+ env[KEY_MIDDLEWARE_CALLCHAIN_CURRENT] = next_handler_id
+ return next_handler(env)
+end
+
+local function middleware_push_back_handler(env, f)
+ local callchain = env[KEY_MIDDLEWARE_CALLCHAIN_TABLE]
+ table.insert(callchain, f)
+end
+
+return {
+ KEY_HTTPD = KEY_HTTPD,
+ KEY_SOCK = KEY_SOCK,
+ KEY_REMAINING = KEY_REMAINING,
+ KEY_PARSED_REQUEST = KEY_PARSED_REQUEST,
+ KEY_PEER = KEY_PEER,
+ KEY_IS_HIJACKED = KEY_IS_HIJACKED,
+
+ headers = headers,
+ serialize_request = serialize_request,
+
+ -- middleware support
+ KEY_MIDDLEWARE_CALLCHAIN_CURRENT = KEY_MIDDLEWARE_CALLCHAIN_CURRENT,
+ KEY_MIDDLEWARE_CALLCHAIN_TABLE = KEY_MIDDLEWARE_CALLCHAIN_TABLE,
+ KEY_ROUTE = KEY_ROUTE,
+ KEY_ROUTER = KEY_ROUTER,
+
+ init_handlers = middleware_init_handlers,
+ next = middleware_invoke_next_handler,
+ push_back_handler = middleware_push_back_handler,
+}
diff --git a/http/utils.lua b/http/utils.lua
new file mode 100644
index 0000000..c4a4245
--- /dev/null
+++ b/http/utils.lua
@@ -0,0 +1,83 @@
+local codes = require('http.codes')
+
+local function errorf(fmt, ...)
+ error(string.format(fmt, ...))
+end
+
+local function sprintf(fmt, ...)
+ return string.format(fmt, ...)
+end
+
+local function ucfirst(str)
+ return str:gsub("^%l", string.upper, 1)
+end
+
+local function reason_by_code(code)
+ code = tonumber(code)
+ if codes[code] ~= nil then
+ return codes[code]
+ end
+ return sprintf('Unknown code %d', code)
+end
+
+local function extend(tbl, tblu, raise)
+ local res = {}
+ for k, v in pairs(tbl) do
+ res[ k ] = v
+ end
+ for k, v in pairs(tblu) do
+ if raise then
+ if res[ k ] == nil then
+ errorf("Unknown option '%s'", k)
+ end
+ end
+ res[ k ] = v
+ end
+ return res
+end
+
+local function uri_unescape(str, unescape_plus_sign)
+ local res = {}
+ if type(str) == 'table' then
+ for _, v in pairs(str) do
+ table.insert(res, uri_unescape(v))
+ end
+ else
+ if unescape_plus_sign ~= nil then
+ str = string.gsub(str, '+', ' ')
+ end
+
+ res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])',
+ function(c)
+ return string.char(tonumber(c, 16))
+ end
+ )
+ end
+ return res
+end
+
+local function uri_escape(str)
+ local res = {}
+ if type(str) == 'table' then
+ for _, v in pairs(str) do
+ table.insert(res, uri_escape(v))
+ end
+ else
+ res = string.gsub(str, '[^a-zA-Z0-9_]',
+ function(c)
+ return string.format('%%%02X', string.byte(c))
+ end
+ )
+ end
+ return res
+end
+
+return {
+ errorf = errorf,
+ sprintf = sprintf,
+ ucfirst = ucfirst,
+ reason_by_code = reason_by_code,
+ extend = extend,
+ uri_unescape = uri_unescape,
+ uri_escape = uri_escape,
+}
diff --git a/rockspecs/http-1.0.1-1.rockspec b/rockspecs/http-1.0.1-1.rockspec
deleted file mode 100644
index e328c41..0000000
--- a/rockspecs/http-1.0.1-1.rockspec
+++ /dev/null
@@ -1,36 +0,0 @@
-package = 'http'
-version = '1.0.1-1'
-source = {
- url = 'git://github.com/tarantool/http.git',
- tag = '1.0.1',
-}
-description = {
- summary = "HTTP server for Tarantool",
- homepage = 'https://github.com/tarantool/http/',
- license = 'BSD',
-}
-dependencies = {
- 'lua >= 5.1'
-}
-external_dependencies = {
- TARANTOOL = {
- header = "tarantool/module.h"
- }
-}
-build = {
- type = 'builtin',
-
- modules = {
- ['http.lib'] = {
- sources = 'http/lib.c',
- incdirs = {
- "$(TARANTOOL_INCDIR)"
- }
- },
- ['http.server'] = 'http/server.lua',
- ['http.mime_types'] = 'http/mime_types.lua',
- ['http.codes'] = 'http/codes.lua',
- }
-}
-
--- vim: syntax=lua
diff --git a/rockspecs/http-1.0.2-1.rockspec b/rockspecs/http-1.0.2-1.rockspec
deleted file mode 100644
index 31b5ed7..0000000
--- a/rockspecs/http-1.0.2-1.rockspec
+++ /dev/null
@@ -1,36 +0,0 @@
-package = 'http'
-version = '1.0.2-1'
-source = {
- url = 'git://github.com/tarantool/http.git',
- tag = '1.0.2',
-}
-description = {
- summary = "HTTP server for Tarantool",
- homepage = 'https://github.com/tarantool/http/',
- license = 'BSD',
-}
-dependencies = {
- 'lua >= 5.1'
-}
-external_dependencies = {
- TARANTOOL = {
- header = "tarantool/module.h"
- }
-}
-build = {
- type = 'builtin',
-
- modules = {
- ['http.lib'] = {
- sources = 'http/lib.c',
- incdirs = {
- "$(TARANTOOL_INCDIR)"
- }
- },
- ['http.server'] = 'http/server.lua',
- ['http.mime_types'] = 'http/mime_types.lua',
- ['http.codes'] = 'http/codes.lua',
- }
-}
-
--- vim: syntax=lua
diff --git a/rpm/tarantool-http.spec b/rpm/tarantool-http.spec
index 45690e1..38251fc 100644
--- a/rpm/tarantool-http.spec
+++ b/rpm/tarantool-http.spec
@@ -11,6 +11,7 @@ BuildRequires: gcc >= 4.5
BuildRequires: tarantool-devel >= 1.7.5.0
BuildRequires: /usr/bin/prove
Requires: tarantool >= 1.7.5.0
+Requires: tarantool-checks
%description
This package provides a HTTP server for Tarantool.
@@ -19,12 +20,10 @@ This package provides a HTTP server for Tarantool.
%setup -q -n %{name}-%{version}
%build
+tarantoolctl rocks install luatest
%cmake . -DCMAKE_BUILD_TYPE=RelWithDebInfo
make %{?_smp_mflags}
-%check
-make %{?_smp_mflags} check
-
%install
%make_install
diff --git a/test/Procfile.test.nginx b/test/Procfile.test.nginx
new file mode 100644
index 0000000..47a0db7
--- /dev/null
+++ b/test/Procfile.test.nginx
@@ -0,0 +1,2 @@
+nginx: nginx -g "daemon off;" -c $PWD/test/nginx.conf
+nginx_tsgi_test: SERVER_TYPE=nginx ./.rocks/bin/luatest 2>&1
diff --git a/test/http.test.lua b/test/http.test.lua
deleted file mode 100755
index 396cbaf..0000000
--- a/test/http.test.lua
+++ /dev/null
@@ -1,524 +0,0 @@
-#!/usr/bin/env tarantool
-
-local tap = require('tap')
-local fio = require('fio')
-local http_lib = require('http.lib')
-local http_client = require('http.client')
-local http_server = require('http.server')
-local json = require('json')
-local yaml = require 'yaml'
-local urilib = require('uri')
-
-local test = tap.test("http")
-test:plan(8)
-
-test:test("split_uri", function(test)
- test:plan(65)
- local function check(uri, rhs)
- local lhs = urilib.parse(uri)
- local extra = { lhs = lhs, rhs = rhs }
- if lhs.query == '' then
- lhs.query = nil
- end
- test:is(lhs.scheme, rhs.scheme, uri.." scheme", extra)
- test:is(lhs.host, rhs.host, uri.." host", extra)
- test:is(lhs.service, rhs.service, uri.." service", extra)
- test:is(lhs.path, rhs.path, uri.." path", extra)
- test:is(lhs.query, rhs.query, uri.." query", extra)
- end
- check('http://abc', { scheme = 'http', host = 'abc'})
- check('http://abc/', { scheme = 'http', host = 'abc', path ='/'})
- check('http://abc?', { scheme = 'http', host = 'abc'})
- check('http://abc/?', { scheme = 'http', host = 'abc', path ='/'})
- check('http://abc/?', { scheme = 'http', host = 'abc', path ='/'})
- check('http://abc:123', { scheme = 'http', host = 'abc', service = '123' })
- check('http://abc:123?', { scheme = 'http', host = 'abc', service = '123'})
- check('http://abc:123?query', { scheme = 'http', host = 'abc',
- service = '123', query = 'query'})
- check('http://domain.subdomain.com:service?query', { scheme = 'http',
- host = 'domain.subdomain.com', service = 'service', query = 'query'})
- check('google.com', { host = 'google.com'})
- check('google.com?query', { host = 'google.com', query = 'query'})
- check('google.com/abc?query', { host = 'google.com', path = '/abc',
- query = 'query'})
- check('https://google.com:443/abc?query', { scheme = 'https',
- host = 'google.com', service = '443', path = '/abc', query = 'query'})
-end)
-
-test:test("template", function(test)
- test:plan(5)
- test:is(http_lib.template("<% for i = 1, cnt do %> <%= abc %> <% end %>",
- {abc = '1 <3>&" ', cnt = 3}),
- ' 1 <3>&" 1 <3>&" 1 <3>&" ',
- "tmpl1")
- test:is(http_lib.template("<% for i = 1, cnt do %> <%= ab %> <% end %>",
- {abc = '1 <3>&" ', cnt = 3}),
- ' nil nil nil ', "tmpl2")
- local r, msg = pcall(http_lib.template, "<% ab() %>", {ab = '1'})
- test:ok(r == false and msg:match("call local 'ab'") ~= nil, "bad template")
-
- -- gh-18: rendered tempate is truncated
- local template = [[
-
-
-
- % for i,v in pairs(t) do
-
- <%= i %> |
- <%= v %> |
-
- % end
-
-
-
-]]
-
- local t = {}
- for i=1, 100 do
- t[i] = string.rep('#', i)
- end
-
- local rendered, code = http_lib.template(template, { t = t })
- test:ok(#rendered > 10000, "rendered size")
- test:is(rendered:sub(#rendered - 7, #rendered - 1), "