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"> [![Build Status](https://travis-ci.org/tarantool/http.png?branch=tarantool-1.7)](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 %> - - - - - -``` - -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 - - - - - % end -
<%= i %><%= v %>
- - -]] - - 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), "", "rendered eof") -end) - -test:test('parse_request', function(test) - test:plan(6) - - test:is_deeply(http_lib._parse_request('abc'), - { error = 'Broken request line', headers = {} }, 'broken request') - - - - test:is( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").path, - '/', - 'path' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").proto, - {1,1}, - 'proto' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").headers, - {host = 's.com'}, - 'host' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").method, - 'GET', - 'method' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").query, - '', - 'query' - ) -end) - -test:test('params', function(test) - test:plan(6) - test:is_deeply(http_lib.params(), {}, 'nil string') - test:is_deeply(http_lib.params(''), {}, 'empty string') - test:is_deeply(http_lib.params('a'), {a = ''}, 'separate literal') - test:is_deeply(http_lib.params('a=b'), {a = 'b'}, 'one variable') - test:is_deeply(http_lib.params('a=b&b=cde'), {a = 'b', b = 'cde'}, 'some') - test:is_deeply(http_lib.params('a=b&b=cde&a=1'), - {a = { 'b', '1' }, b = 'cde'}, 'array') -end) - -local function cfgserv() - local path = os.getenv('LUA_SOURCE_DIR') or './' - path = fio.pathjoin(path, 'test') - local httpd = http_server.new('127.0.0.1', 12345, { app_dir = path, - log_requests = false, log_errors = false }) - :route({path = '/abc/:cde/:def', name = 'test'}, function() end) - :route({path = '/abc'}, function() end) - :route({path = '/ctxaction'}, 'module.controller#action') - :route({path = '/absentaction'}, 'module.controller#absent') - :route({path = '/absent'}, 'module.absent#action') - :route({path = '/abc/:cde'}, function() end) - :route({path = '/abc_:cde_def'}, function() end) - :route({path = '/abc-:cde-def'}, function() end) - :route({path = '/aba*def'}, function() end) - :route({path = '/abb*def/cde', name = 'star'}, function() end) - :route({path = '/banners/:token'}) - :helper('helper_title', function(self, a) return 'Hello, ' .. a end) - :route({path = '/helper', file = 'helper.html.el'}) - :route({ path = '/test', file = 'test.html.el' }, - function(cx) return cx:render({ title = 'title: 123' }) end) - return httpd -end - -test:test("server url match", function(test) - test:plan(18) - local httpd = cfgserv() - test:istable(httpd, "httpd object") - test:isnil(httpd:match('GET', '/')) - test:is(httpd:match('GET', '/abc').endpoint.path, "/abc", "/abc") - test:is(#httpd:match('GET', '/abc').stash, 0, "/abc") - test:is(httpd:match('GET', '/abc/123').endpoint.path, "/abc/:cde", "/abc/123") - test:is(httpd:match('GET', '/abc/123').stash.cde, "123", "/abc/123") - test:is(httpd:match('GET', '/abc/123/122').endpoint.path, "/abc/:cde/:def", - "/abc/123/122") - test:is(httpd:match('GET', '/abc/123/122').stash.def, "122", - "/abc/123/122") - test:is(httpd:match('GET', '/abc/123/122').stash.cde, "123", - "/abc/123/122") - test:is(httpd:match('GET', '/abc_123-122').endpoint.path, "/abc_:cde_def", - "/abc_123-122") - test:is(httpd:match('GET', '/abc_123-122').stash.cde_def, "123-122", - "/abc_123-122") - test:is(httpd:match('GET', '/abc-123-def').endpoint.path, "/abc-:cde-def", - "/abc-123-def") - test:is(httpd:match('GET', '/abc-123-def').stash.cde, "123", - "/abc-123-def") - test:is(httpd:match('GET', '/aba-123-dea/1/2/3').endpoint.path, - "/aba*def", '/aba-123-dea/1/2/3') - test:is(httpd:match('GET', '/aba-123-dea/1/2/3').stash.def, - "-123-dea/1/2/3", '/aba-123-dea/1/2/3') - test:is(httpd:match('GET', '/abb-123-dea/1/2/3/cde').endpoint.path, - "/abb*def/cde", '/abb-123-dea/1/2/3/cde') - test:is(httpd:match('GET', '/abb-123-dea/1/2/3/cde').stash.def, - "-123-dea/1/2/3", '/abb-123-dea/1/2/3/cde') - test:is(httpd:match('GET', '/banners/1wulc.z8kiy.6p5e3').stash.token, - '1wulc.z8kiy.6p5e3', "stash with dots") -end) - -test:test("server url_for", function(test) - test:plan(5) - local httpd = cfgserv() - test:is(httpd:url_for('abcdef'), '/abcdef', '/abcdef') - test:is(httpd:url_for('test'), '/abc//', '/abc//') - test:is(httpd:url_for('test', { cde = 'cde_v', def = 'def_v' }), - '/abc/cde_v/def_v', '/abc/cde_v/def_v') - test:is(httpd:url_for('star', { def = '/def_v' }), - '/abb/def_v/cde', '/abb/def_v/cde') - test:is(httpd:url_for('star', { def = '/def_v' }, { a = 'b', c = 'd' }), - '/abb/def_v/cde?a=b&c=d', '/abb/def_v/cde?a=b&c=d') -end) - -test:test("server requests", function(test) - test:plan(36) - local httpd = cfgserv() - httpd:start() - - local r = http_client.get('http://127.0.0.1:12345/test') - test:is(r.status, 200, '/test code') - test:is(r.proto[1], 1, '/test http 1.1') - test:is(r.proto[2], 1, '/test http 1.1') - test:is(r.reason, 'Ok', '/test reason') - test:is(string.match(r.body, 'title: 123'), 'title: 123', '/test body') - - local r = http_client.get('http://127.0.0.1:12345/test404') - test:is(r.status, 404, '/test404 code') - -- broken in built-in tarantool/http - --test:is(r.reason, 'Not found', '/test404 reason') - - local r = http_client.get('http://127.0.0.1:12345/absent') - test:is(r.status, 500, '/absent code') - --test:is(r.reason, 'Internal server error', '/absent reason') - test:is(string.match(r.body, 'load module'), 'load module', '/absent body') - - local r = http_client.get('http://127.0.0.1:12345/ctxaction') - test:is(r.status, 200, '/ctxaction code') - test:is(r.reason, 'Ok', '/ctxaction reason') - test:is(string.match(r.body, 'Hello, Tarantool'), 'Hello, Tarantool', - '/ctxaction body') - test:is(string.match(r.body, 'action: action'), 'action: action', - '/ctxaction body action') - test:is(string.match(r.body, 'controller: module[.]controller'), - 'controller: module.controller', '/ctxaction body controller') - - local r = http_client.get('http://127.0.0.1:12345/ctxaction.invalid') - test:is(r.status, 404, '/ctxaction.invalid code') -- WTF? - --test:is(r.reason, 'Not found', '/ctxaction.invalid reason') - --test:is(r.body, '', '/ctxaction.invalid body') - - local r = http_client.get('http://127.0.0.1:12345/hello.html') - test:is(r.status, 200, '/hello.html code') - test:is(r.reason, 'Ok', '/hello.html reason') - test:is(string.match(r.body, 'static html'), 'static html', - '/hello.html body') - - local r = http_client.get('http://127.0.0.1:12345/absentaction') - test:is(r.status, 500, '/absentaction 500') - --test:is(r.reason, 'Internal server error', '/absentaction reason') - test:is(string.match(r.body, 'contain function'), 'contain function', - '/absentaction body') - - local r = http_client.get('http://127.0.0.1:12345/helper') - test:is(r.status, 200, 'helper 200') - test:is(r.reason, 'Ok', 'helper reason') - test:is(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') - - local r = http_client.get('http://127.0.0.1:12345/helper?abc') - test:is(r.status, 200, 'helper?abc 200') - test:is(r.reason, 'Ok', 'helper?abc reason') - test:is(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') - - httpd:route({path = '/die', file = 'helper.html.el'}, - function() error(123) end ) - - local r = http_client.get('http://127.0.0.1:12345/die') - test:is(r.status, 500, 'die 500') - --test:is(r.reason, 'Internal server error', 'die reason') - - httpd:route({ path = '/info' }, function(cx) - return cx:render({ json = cx.peer }) - end) - local r = json.decode(http_client.get('http://127.0.0.1:12345/info').body) - test:is(r.host, '127.0.0.1', 'peer.host') - test:isnumber(r.port, 'peer.port') - - local r = httpd:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, - function(tx) - return tx:render({text = 'POST = ' .. tx:read()}) - end) - test:istable(r, ':route') - - - test:test('GET/POST at one route', function(test) - test:plan(8) - - r = httpd:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, - function(tx) - return tx:render({text = 'POST = ' .. tx:read()}) - end) - test:istable(r, 'add POST method') - - r = httpd:route({method = 'GET', path = '/dit', file = 'helper.html.el'}, - function(tx) - return tx:render({text = 'GET = ' .. tx:read()}) - end ) - test:istable(r, 'add GET method') - - r = httpd:route({method = 'DELETE', path = '/dit', file = 'helper.html.el'}, - function(tx) - return tx:render({text = 'DELETE = ' .. tx:read()}) - end ) - test:istable(r, 'add DELETE method') - - r = httpd:route({method = 'PATCH', path = '/dit', file = 'helper.html.el'}, - function(tx) - return tx:render({text = 'PATCH = ' .. tx:read()}) - end ) - test:istable(r, 'add PATCH method') - - r = http_client.request('POST', 'http://127.0.0.1:12345/dit', 'test') - test:is(r.body, 'POST = test', 'POST reply') - - r = http_client.request('GET', 'http://127.0.0.1:12345/dit') - test:is(r.body, 'GET = ', 'GET reply') - - r = http_client.request('DELETE', 'http://127.0.0.1:12345/dit', 'test1') - test:is(r.body, 'DELETE = test1', 'DELETE reply') - - r = http_client.request('PATCH', 'http://127.0.0.1:12345/dit', 'test2') - test:is(r.body, 'PATCH = test2', 'PATCH reply') - end) - - httpd:route({path = '/chunked'}, function(self) - return self:iterate(ipairs({'chunked', 'encoding', 't\r\nest'})) - end) - - -- http client currently doesn't support chunked encoding - local r = http_client.get('http://127.0.0.1:12345/chunked') - test:is(r.status, 200, 'chunked 200') - test:is(r.headers['transfer-encoding'], 'chunked', 'chunked headers') - test:is(r.body, 'chunkedencodingt\r\nest', 'chunked body') - - test:test('get cookie', function(test) - test:plan(2) - httpd:route({path = '/receive_cookie'}, function(req) - local foo = req:cookie('foo') - local baz = req:cookie('baz') - return req:render({ - text = ('foo=%s; baz=%s'):format(foo, baz) - }) - end) - local r = http_client.get('http://127.0.0.1:12345/receive_cookie', { - headers = { - cookie = 'foo=bar; baz=feez', - } - }) - test:is(r.status, 200, 'status') - test:is(r.body, 'foo=bar; baz=feez', 'body') - end) - - test:test('cookie', function(test) - test:plan(2) - httpd:route({path = '/cookie'}, function(req) - local resp = req:render({text = ''}) - resp:setcookie({ name = 'test', value = 'tost', - expires = '+1y', path = '/abc' }) - resp:setcookie({ name = 'xxx', value = 'yyy' }) - return resp - end) - local r = http_client.get('http://127.0.0.1:12345/cookie') - test:is(r.status, 200, 'status') - test:ok(r.headers['set-cookie'] ~= nil, "header") - end) - - test:test('post body', function(test) - test:plan(2) - httpd:route({ path = '/post', method = 'POST'}, function(req) - local t = { - #req:read("\n"); - #req:read(10); - #req:read({ size = 10, delimiter = "\n"}); - #req:read("\n"); - #req:read(); - #req:read(); - #req:read(); - } - return req:render({json = t}) - end) - local bodyf = os.getenv('LUA_SOURCE_DIR') or './' - bodyf = io.open(fio.pathjoin(bodyf, 'test/public/lorem.txt')) - local body = bodyf:read('*a') - bodyf:close() - local r = http_client.post('http://127.0.0.1:12345/post', body) - test:is(r.status, 200, 'status') - test:is_deeply(json.decode(r.body), { 541,10,10,458,1375,0,0 }, - 'req:read() results') - end) - - httpd:stop() -end) - -local log_queue = {} - -local custom_logger = { - debug = function() end, - verbose = function(...) - table.insert(log_queue, { log_lvl = 'verbose', }) - end, - info = function(...) - table.insert(log_queue, { log_lvl = 'info', msg = string.format(...)}) - end, - warn = function(...) - table.insert(log_queue, { log_lvl = 'warn', msg = string.format(...)}) - end, - error = function(...) - table.insert(log_queue, { log_lvl = 'error', msg = string.format(...)}) - end -} - -local function find_msg_in_log_queue(msg, strict) - for _, log in ipairs(log_queue) do - if not strict then - if log.msg:match(msg) then - return log - end - else - if log.msg == msg then - return log - end - end - end -end - -local function clear_log_queue() - log_queue = {} -end - -test:test("Custom log functions for route", function(test) - test:plan(5) - - test:test("Setting log option for server instance", function(test) - test:plan(2) - - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = custom_logger.info, log_errors = custom_logger.error }) - httpd:route({ path='/' }, function(_) end) - httpd:route({ path='/error' }, function(_) error('Some error...') end) - httpd:start() - - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route should logging requests in custom logger if it's presents") - clear_log_queue() - - http_client.get("127.0.0.1:12345/error") - test:ok(find_msg_in_log_queue("Some error...", false), "Route should logging error in custom logger if it's presents") - clear_log_queue() - - httpd:stop() - end) - - test:test("Setting log options for route", function(test) - test:plan(8) - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = true, log_errors = false }) - local dummy_logger = function() end - - local ok, err = pcall(httpd.route, httpd, { path = '/', log_requests = 3 }) - test:is(ok, false, "Route logger can't be a log_level digit") - test:like(err, "'log_requests' option should be a function", "route() should return error message in case of incorrect logger option") - - ok, err = pcall(httpd.route, httpd, { path = '/', log_requests = { info = dummy_logger } }) - test:is(ok, false, "Route logger can't be a table") - test:like(err, "'log_requests' option should be a function", "route() should return error message in case of incorrect logger option") - - local ok, err = pcall(httpd.route, httpd, { path = '/', log_errors = 3 }) - test:is(ok, false, "Route error logger can't be a log_level digit") - test:like(err, "'log_errors' option should be a function", "route() should return error message in case of incorrect logger option") - - ok, err = pcall(httpd.route, httpd, { path = '/', log_errors = { error = dummy_logger } }) - test:is(ok, false, "Route error logger can't be a table") - test:like(err, "'log_errors' option should be a function", "route() should return error message in case of incorrect log_errors option") - end) - - test:test("Log output with custom loggers on route", function(test) - test:plan(3) - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = true, log_errors = true }) - httpd:start() - - httpd:route({ path = '/', log_requests = custom_logger.info, log_errors = custom_logger.error }, function(_) end) - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route should logging requests in custom logger if it's presents") - clear_log_queue() - - httpd.routes = {} - httpd:route({ path = '/', log_requests = custom_logger.info, log_errors = custom_logger.error }, function(_) - error("User business logic exception...") - end) - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route should logging request and error in case of route exception") - test:ok(find_msg_in_log_queue("User business logic exception...", false), - "Route should logging error custom logger if it's presents in case of route exception") - clear_log_queue() - - httpd:stop() - end) - - test:test("Log route requests with turned off 'log_requests' option", function(test) - test:plan(1) - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = false }) - httpd:start() - - httpd:route({ path = '/', log_requests = custom_logger.info }, function(_) end) - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route can override logging requests if the http server have turned off 'log_requests' option") - clear_log_queue() - - httpd:stop() - end) - - test:test("Log route requests with turned off 'log_errors' option", function(test) - test:plan(1) - local httpd = http_server.new("127.0.0.1", 12345, { log_errors = false }) - httpd:start() - - httpd:route({ path = '/', log_errors = custom_logger.error }, function(_) - error("User business logic exception...") - end) - http_client.get("127.0.0.1:12345") - test:ok(find_msg_in_log_queue("User business logic exception...", false), "Route can override logging requests if the http server have turned off 'log_errors' option") - clear_log_queue() - - httpd:stop() - end) -end) - -os.exit(test:check() == true and 0 or 1) diff --git a/test/http_test.lua b/test/http_test.lua new file mode 100755 index 0000000..c60ee68 --- /dev/null +++ b/test/http_test.lua @@ -0,0 +1,692 @@ +#!/usr/bin/env tarantool + +local t = require('luatest') +local g = t.group('http') +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 ngx_server = require('http.nginx_server') +local http_router = require('http.router') +local json = require('json') +local urilib = require('uri') + +-- fix tap and http logs interleaving. +-- +-- tap module writes to stdout, +-- http-server logs to stderr. +-- this results in non-synchronized output. +-- +-- somehow redirecting stdout to stderr doesn't +-- remove buffering of tap logs (at least on OSX). +-- Monkeypatching to the rescue! + +local orig_iowrite = io.write +package.loaded['io'].write = function(...) + orig_iowrite(...) + io.flush() +end + +g.before_all = function() + box.cfg{listen = '127.0.0.1:3301'} + box.schema.user.grant( + 'guest', 'read,write,execute', 'universe', nil, {if_not_exists = true} + ) +end + +g.test_split_uri = function() + local function check(uri, rhs) + local lhs = urilib.parse(uri) + local extra = { lhs = lhs, rhs = rhs } + if lhs.query == '' then + lhs.query = nil + end + t.assertEquals(lhs.scheme, rhs.scheme, uri.." scheme", extra) + t.assertEquals(lhs.host, rhs.host, uri.." host", extra) + t.assertEquals(lhs.service, rhs.service, uri.." service", extra) + t.assertEquals(lhs.path, rhs.path, uri.." path", extra) + t.assertEquals(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 + +g.test_template = function() + t.assertEquals(http_lib.template("<% for i = 1, cnt do %> <%= abc %> <% end %>", + {abc = '1 <3>&" ', cnt = 3}), + ' 1 <3>&" 1 <3>&" 1 <3>&" ', + "tmpl1") + t.assertEquals(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'}) + t.assertTrue(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 + + + + + % end +
<%= i %><%= v %>
+ + +]] + + local tt = {} + for i=1, 100 do + tt[i] = string.rep('#', i) + end + + local rendered, code = http_lib.template(template, { t = tt }) + t.assertTrue(#rendered > 10000, "rendered size") + t.assertEquals(rendered:sub(#rendered - 7, #rendered - 1), "", "rendered eof") +end + +g.test_parse_request = function(test) + + t.assertEquals(http_lib._parse_request('abc'), + { error = 'Broken request line', headers = {} }, 'broken request') + + + + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").path, + '/', + 'path' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").proto, + {1,1}, + 'proto' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").headers, + {host = 's.com'}, + 'host' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").method, + 'GET', + 'method' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").query, + '', + 'query' + ) +end + +g.test_params = function(test) + t.assertEquals(http_lib.params(), {}, 'nil string') + t.assertEquals(http_lib.params(''), {}, 'empty string') + t.assertEquals(http_lib.params('a'), {a = ''}, 'separate literal') + t.assertEquals(http_lib.params('a=b'), {a = 'b'}, 'one variable') + t.assertEquals(http_lib.params('a=b&b=cde'), {a = 'b', b = 'cde'}, 'some') + t.assertEquals(http_lib.params('a=b&b=cde&a=1'), + {a = { 'b', '1' }, b = 'cde'}, 'array') +end + +local function is_nginx_test() + local server_type = os.getenv('SERVER_TYPE') or 'builtin' + return server_type:lower() == 'nginx' +end + +local function is_builtin_test() + return not is_nginx_test() +end + +local function choose_server() + local log_requests = true + local log_errors = true + + if is_nginx_test() then + -- host and port are for SERVER_NAME, SERVER_PORT only. + -- TODO: are they required? + + return ngx_server.new({ + host = '127.0.0.1', + port = 12345, + tnt_method = 'nginx_entrypoint', + log_requests = log_requests, + log_errors = log_errors, + }) + end + + return http_server.new('127.0.0.1', 12345, { + log_requests = log_requests, + log_errors = log_errors + }) +end + +local function cfgserv() + local path = os.getenv('LUA_SOURCE_DIR') or './' + path = fio.pathjoin(path, 'test') + + local httpd = choose_server() + local router = http_router.new({app_dir = path}) + :route({path = '/abc/:cde/:def', name = 'test'}, function() end) + :route({path = '/abc'}, function() end) + :route({path = '/ctxaction'}, 'module.controller#action') + :route({path = '/absentaction'}, 'module.controller#absent') + :route({path = '/absent'}, 'module.absent#action') + :route({path = '/abc/:cde'}, function() end) + :route({path = '/abc_:cde_def'}, function() end) + :route({path = '/abc-:cde-def'}, function() end) + :route({path = '/aba*def'}, function() end) + :route({path = '/abb*def/cde', name = 'star'}, function() end) + :route({path = '/banners/:token'}) + :helper('helper_title', function(self, a) return 'Hello, ' .. a end) + :route({path = '/helper', file = 'helper.html.el'}) + :route({ path = '/test', file = 'test.html.el' }, + function(cx) return cx:render({ title = 'title: 123' }) end) + httpd:set_router(router) + return httpd, router +end + +g.test_server_url_match = function(test) + local httpd, router = cfgserv() + t.assertIsTable(httpd, "httpd object") + t.assertIsNil(router:match('GET', '/')) + t.assertEquals(router:match('GET', '/abc').endpoint.path, "/abc", "/abc") + t.assertEquals(#router:match('GET', '/abc').stash, 0, "/abc") + t.assertEquals(router:match('GET', '/abc/123').endpoint.path, "/abc/:cde", "/abc/123") + t.assertEquals(router:match('GET', '/abc/123').stash.cde, "123", "/abc/123") + t.assertEquals(router:match('GET', '/abc/123/122').endpoint.path, "/abc/:cde/:def", + "/abc/123/122") + t.assertEquals(router:match('GET', '/abc/123/122').stash.def, "122", + "/abc/123/122") + t.assertEquals(router:match('GET', '/abc/123/122').stash.cde, "123", + "/abc/123/122") + t.assertEquals(router:match('GET', '/abc_123-122').endpoint.path, "/abc_:cde_def", + "/abc_123-122") + t.assertEquals(router:match('GET', '/abc_123-122').stash.cde_def, "123-122", + "/abc_123-122") + t.assertEquals(router:match('GET', '/abc-123-def').endpoint.path, "/abc-:cde-def", + "/abc-123-def") + t.assertEquals(router:match('GET', '/abc-123-def').stash.cde, "123", + "/abc-123-def") + t.assertEquals(router:match('GET', '/aba-123-dea/1/2/3').endpoint.path, + "/aba*def", '/aba-123-dea/1/2/3') + t.assertEquals(router:match('GET', '/aba-123-dea/1/2/3').stash.def, + "-123-dea/1/2/3", '/aba-123-dea/1/2/3') + t.assertEquals(router:match('GET', '/abb-123-dea/1/2/3/cde').endpoint.path, + "/abb*def/cde", '/abb-123-dea/1/2/3/cde') + t.assertEquals(router:match('GET', '/abb-123-dea/1/2/3/cde').stash.def, + "-123-dea/1/2/3", '/abb-123-dea/1/2/3/cde') + t.assertEquals(router:match('GET', '/banners/1wulc.z8kiy.6p5e3').stash.token, + '1wulc.z8kiy.6p5e3', "stash with dots") +end + + +g.test_server_url_for = function() + local httpd, router = cfgserv() + t.assertEquals(router:url_for('abcdef'), '/abcdef', '/abcdef') + t.assertEquals(router:url_for('test'), '/abc//', '/abc//') + t.assertEquals(router:url_for('test', { cde = 'cde_v', def = 'def_v' }), + '/abc/cde_v/def_v', '/abc/cde_v/def_v') + t.assertEquals(router:url_for('star', { def = '/def_v' }), + '/abb/def_v/cde', '/abb/def_v/cde') + t.assertEquals(router:url_for('star', { def = '/def_v' }, { a = 'b', c = 'd' }), + '/abb/def_v/cde?a=b&c=d', '/abb/def_v/cde?a=b&c=d') +end + +g.test_server_requests = function() + local httpd, router = cfgserv() + httpd:start() + + local r = http_client.get('http://127.0.0.1:12345/test') + t.assertEquals(r.status, 200, '/test code') + + t.assertEquals(r.proto[1], 1, '/test http 1.1') + t.assertEquals(r.proto[2], 1, '/test http 1.1') + t.assertEquals(r.reason, 'Ok', '/test reason') + t.assertEquals(string.match(r.body, 'title: 123'), 'title: 123', '/test body') + + local r = http_client.get('http://127.0.0.1:12345/test404') + t.assertEquals(r.status, 404, '/test404 code') + -- broken in built-in tarantool/http + --t.assertEquals(r.reason, 'Not found', '/test404 reason') + + local r = http_client.get('http://127.0.0.1:12345/absent') + t.assertEquals(r.status, 500, '/absent code') + --t.assertEquals(r.reason, 'Internal server error', '/absent reason') + t.assertEquals(string.match(r.body, 'load module'), 'load module', '/absent body') + + local r = http_client.get('http://127.0.0.1:12345/ctxaction') + t.assertEquals(r.status, 200, '/ctxaction code') + t.assertEquals(r.reason, 'Ok', '/ctxaction reason') + t.assertEquals(string.match(r.body, 'Hello, Tarantool'), 'Hello, Tarantool', + '/ctxaction body') + t.assertEquals(string.match(r.body, 'action: action'), 'action: action', + '/ctxaction body action') + t.assertEquals(string.match(r.body, 'controller: module[.]controller'), + 'controller: module.controller', '/ctxaction body controller') + + local r = http_client.get('http://127.0.0.1:12345/ctxaction.invalid') + t.assertEquals(r.status, 404, '/ctxaction.invalid code') -- WTF? + --t.assertEquals(r.reason, 'Not found', '/ctxaction.invalid reason') + --t.assertEquals(r.body, '', '/ctxaction.invalid body') + + local r = http_client.get('http://127.0.0.1:12345/hello.html') + t.assertEquals(r.status, 200, '/hello.html code') + t.assertEquals(r.reason, 'Ok', '/hello.html reason') + t.assertEquals(string.match(r.body, 'static html'), 'static html', + '/hello.html body') + + local r = http_client.get('http://127.0.0.1:12345/absentaction') + t.assertEquals(r.status, 500, '/absentaction 500') + --t.assertEquals(r.reason, 'Internal server error', '/absentaction reason') + t.assertEquals(string.match(r.body, 'contain function'), 'contain function', + '/absentaction body') + + local r = http_client.get('http://127.0.0.1:12345/helper') + t.assertEquals(r.status, 200, 'helper 200') + t.assertEquals(r.reason, 'Ok', 'helper reason') + t.assertEquals(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') + + local r = http_client.get('http://127.0.0.1:12345/helper?abc') + t.assertEquals(r.status, 200, 'helper?abc 200') + t.assertEquals(r.reason, 'Ok', 'helper?abc reason') + t.assertEquals(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') + + router:route({path = '/die', file = 'helper.html.el'}, + function() error(123) end ) + + local r = http_client.get('http://127.0.0.1:12345/die') + t.assertEquals(r.status, 500, 'die 500') + --t.assertEquals(r.reason, 'Internal server error', 'die reason') + + router:route({ path = '/info' }, function(cx) + return cx:render({ json = cx:peer() }) + end) + + local r = json.decode(http_client.get('http://127.0.0.1:12345/info').body) + t.assertEquals(r.host, '127.0.0.1', 'peer.host') + t.assertIsNumber(r.port, 'peer.port') + + local r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'POST = ' .. tx:read()}) + end) + t.assertIsTable(r, ':route') + + + -- GET/POST at one route + r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'POST = ' .. tx:read()}) + end) + t.assertIsTable(r, 'add POST method') + + r = router:route({method = 'GET', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'GET = ' .. tx:read()}) + end ) + t.assertIsTable(r, 'add GET method') + + r = router:route({method = 'DELETE', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'DELETE = ' .. tx:read()}) + end ) + t.assertIsTable(r, 'add DELETE method') + + r = router:route({method = 'PATCH', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'PATCH = ' .. tx:read()}) + end ) + t.assertIsTable(r, 'add PATCH method') + + -- TODO + r = http_client.request('POST', 'http://127.0.0.1:12345/dit', 'test') + t.assertEquals(r.body, 'POST = test', 'POST reply') + + r = http_client.request('GET', 'http://127.0.0.1:12345/dit') + t.assertEquals(r.body, 'GET = ', 'GET reply') + + r = http_client.request('DELETE', 'http://127.0.0.1:12345/dit', 'test1') + t.assertEquals(r.body, 'DELETE = test1', 'DELETE reply') + + r = http_client.request('PATCH', 'http://127.0.0.1:12345/dit', 'test2') + t.assertEquals(r.body, 'PATCH = test2', 'PATCH reply') + + router:route({path = '/chunked'}, function(self) + return self:iterate(ipairs({'chunked', 'encoding', 't\r\nest'})) + end) + + -- http client currently doesn't support chunked encoding + local r = http_client.get('http://127.0.0.1:12345/chunked') + t.assertEquals(r.status, 200, 'chunked 200') + t.assertEquals(r.headers['transfer-encoding'], 'chunked', 'chunked headers') + t.assertEquals(r.body, 'chunkedencodingt\r\nest', 'chunked body') + + -- get cookie + router:route({path = '/receive_cookie'}, function(req) + local foo = req:cookie('foo') + local baz = req:cookie('baz') + return req:render({ + text = ('foo=%s; baz=%s'):format(foo, baz) + }) + end) + local r = http_client.get('http://127.0.0.1:12345/receive_cookie', { + headers = { + cookie = 'foo=bar; baz=feez', + } + }) + t.assertEquals(r.status, 200, 'status') + t.assertEquals(r.body, 'foo=bar; baz=feez', 'body') + + -- cookie + router:route({path = '/cookie'}, function(req) + local resp = req:render({text = ''}) + resp:setcookie({ name = 'test', value = 'tost', + expires = '+1y', path = '/abc' }) + resp:setcookie({ name = 'xxx', value = 'yyy' }) + return resp + end) + local r = http_client.get('http://127.0.0.1:12345/cookie') + t.assertEquals(r.status, 200, 'status') + t.assertTrue(r.headers['set-cookie'] ~= nil, "header") + + + -- request object with GET method + router:route({path = '/check_req_properties'}, function(req) + return { + headers = {}, + body = json.encode({ + headers = req:headers(), + method = req:method(), + path = req:path(), + query = req:query(), + proto = req:proto(), + query_param_bar = req:query_param('bar'), + }), + status = 200, + } + end) + local r = http_client.get( + 'http://127.0.0.1:12345/check_req_properties?foo=1&bar=2', { + headers = { + ['X-test-header'] = 'test-value' + } + }) + t.assertEquals(r.status, 200, 'status') + + local parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.headers['x-test-header'], 'test-value', 'req.headers') + t.assertEquals(parsed_body.method, 'GET', 'req.method') + t.assertEquals(parsed_body.path, '/check_req_properties', 'req.path') + t.assertEquals(parsed_body.query, 'foo=1&bar=2', 'req.query') + t.assertEquals(parsed_body.query_param_bar, '2', 'req:query_param()') + t.assertEquals(parsed_body.proto, {1, 1}, 'req.proto') + + -- request object methods + router:route({path = '/check_req_methods_for_json', method = 'POST'}, function(req) + return { + headers = {}, + body = json.encode({ + request_line = req:request_line(), + read_cached = req:read_cached(), + json = req:json(), + post_param_for_kind = req:post_param('kind'), + }), + status = 200, + } + end) + router:route({path = '/check_req_methods', method = 'POST'}, function(req) + return { + headers = {}, + body = json.encode({ + request_line = req:request_line(), + read_cached = req:read_cached(), + }), + status = 200, + } + end) + + r = http_client.post( + 'http://127.0.0.1:12345/check_req_methods_for_json', + '{"kind": "json"}', { + headers = { + ['Content-type'] = 'application/json', + ['X-test-header'] = 'test-value' + } + }) + t.assertEquals(r.status, 200, 'status') + + local parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.request_line, 'POST /check_req_methods_for_json HTTP/1.1', 'req.request_line') + t.assertEquals(parsed_body.read_cached, '{"kind": "json"}', 'json req:read_cached()') + t.assertEquals(parsed_body.json, {kind = "json"}, 'req:json()') + t.assertEquals(parsed_body.post_param_for_kind, "json", 'req:post_param()') + + r = http_client.post( + 'http://127.0.0.1:12345/check_req_methods', + 'hello mister' + ) + t.assertEquals(r.status, 200, 'status') + parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.read_cached, 'hello mister', 'non-json req:read_cached()') + + if is_builtin_test() then + router:route({ path = '/post', method = 'POST'}, function(req) + local t = { + #req:read("\n"); + #req:read(10); + #req:read({ size = 10, delimiter = "\n"}); + #req:read("\n"); + #req:read(); + #req:read(); + #req:read(); + } + return req:render({json = t}) + end) + local bodyf = os.getenv('LUA_SOURCE_DIR') or './' + bodyf = io.open(fio.pathjoin(bodyf, 'test/public/lorem.txt')) + local body = bodyf:read('*a') + bodyf:close() + local r = http_client.post('http://127.0.0.1:12345/post', body) + t.assertEquals(r.status, 200, 'status') + t.assertEquals(json.decode(r.body), { 541,10,10,458,1375,0,0 }, + 'req:read() results') + else + t.assertTrue(true, 'post body - ignore on NGINX') + end + + -- hijacking + if is_builtin_test() then + -- 0. create a route (simplest) in which env:hijack() is called, + -- and then do ping-pong. + router:route({method = 'POST', path = '/upgrade'}, function(req) + -- intercept raw socket connection + local sock = req:hijack() + assert(sock ~= nil, 'hijacked socket is not empty') + + -- receive ping, send pong + sock:write('ready') + local ping = sock:read(4) + assert(ping == 'ping') + sock:write('pong') + end) + + -- 1. set-up socket + local socket = require('socket') + local sock = socket.tcp_connect('127.0.0.1', 12345) + t.assertTrue(sock ~= nil, 'HTTP client connection established') + + -- 2. over raw-socket send HTTP POST (to get it routed to route) + local upgrade_request = 'POST /upgrade HTTP/1.1\r\nConnection: upgrade\r\n\r\n' + local bytessent = sock:write(upgrade_request) + t.assertEquals(bytessent, #upgrade_request, 'upgrade request sent fully') + + -- 3. send ping, receive pong + t.assertEquals(sock:read(5), 'ready', 'server is ready') + sock:write('ping') + t.assertEquals(sock:read(4), 'pong', 'pong receieved') + else + t.assertTrue(true, 'HTTP client connection established - ignored on NGINX') + t.assertTrue(true, 'upgrade request sent fully - ignored on NGINX') + t.assertTrue(true, 'server is ready - ignored on NGINX') + t.assertTrue(true, 'pong received - ignored on NGINX') + end + + -- prioritization of more specific routes + router:route({method = 'GET', path = '*stashname'}, function(_) + return { + status = 200, + body = 'GET *', + } + end) + local r = http_client.get('http://127.0.0.1:12345/a/b/c') + t.assertEquals(r.status, 200, '/a/b/c request returns 200') + t.assertEquals(r.body, 'GET *', 'GET * matches') + + router:route({method = 'ANY', path = '/a/:foo/:bar'}, function(_) + return { + status = 200, + body = 'ANY /a/:foo/:bar', + } + end) + local r = http_client.get('http://127.0.0.1:12345/a/b/c') + t.assertEquals(r.status, 200, '/a/b/c request returns 200') + t.assertEquals( + r.body, + 'ANY /a/:foo/:bar', + '# of stashes matched doesnt matter - only # of known symbols by the route matters' + ) + + httpd:stop() +end + +g.test_middleware = function() + local httpd, router = cfgserv() + + local add_helloworld_before_to_response = function(req) + local resp = req:next() + + local lua_body = json.decode(resp.body) + lua_body.message = 'hello world! (before)' + resp.body = json.encode(lua_body) + + return resp + end + + local add_helloworld_to_response = function(req) + local resp = req:next() + + local lua_body = json.decode(resp.body) + lua_body.message = 'hello world!' + resp.body = json.encode(lua_body) + + return resp + end + + local ok = router:use(add_helloworld_to_response, { + name = 'hello_world', + path = '/.*', + method = {'GET', 'POST'}, + }) + t.assertTrue(ok, 'hello_world middleware added successfully') + + local middlewares_ordered = router.middleware:ordered() + t.assertEquals(#middlewares_ordered, 1, 'one middleware is registered') + + ok = router:use(add_helloworld_before_to_response, { + name = 'hello_world_before', + path = '/.*', + method = 'ANY', + before = 'hello_world', + }) + t.assertTrue(ok, 'hello_world_before middleware added successfully') + + middlewares_ordered = router.middleware:ordered() + t.assertEquals(#middlewares_ordered, 2, 'both middlewares are registered') + t.assertEquals(middlewares_ordered[1].name, 'hello_world_before', + 'hello_world_before is first') + t.assertEquals(middlewares_ordered[2].name, 'hello_world', + 'hello_world is last') + + local apple_handler = function() + return {status = 200, body = json.encode({kind = 'apple'})} + end + + local orange_handler = function() + return {status = 200, body = json.encode({kind = 'orange'})} + end + + router:route( + { + method = 'GET', + path = '/fruits/apple', + }, + apple_handler + ) + router:route( + { + method = 'GET', + path = '/fruits/orange', + }, + orange_handler + ) + + httpd:start() + + local r = http_client.get( + 'http://127.0.0.1:12345/fruits/apple' + ) + t.assertEquals(r.status, 200, 'status') + require('log').info('DEBUG: /fruits/apple response: %s', r.body) + local parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.kind, 'apple', 'body is correct') + t.assertEquals(parsed_body.message, 'hello world! (before)', 'hello_world middleware invoked last') + + local function swap_orange_and_apple(req) + local path_info = req['PATH_INFO'] + local log = require('log') + log.info('swap_orange_and_apple: path_info = %s', path_info) + + if path_info == '/fruits/orange' then + req['PATH_INFO'] = '/fruits/apple' + elseif path_info == '/fruits/apple' then + req['PATH_INFO'] = '/fruits/orange' + end + + return req:next() + end + + ok = router:use(swap_orange_and_apple, { + preroute = true, + name = 'swap_orange_and_apple', + }) + t.assertTrue(ok, 'swap_orange_and_apple middleware added successfully') + + r = http_client.get( + 'http://127.0.0.1:12345/fruits/apple' + ) + t.assertEquals(r.status, 200, 'status') + parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.kind, 'orange', 'route swapped from apple handler to orange') + + httpd:stop() +end diff --git a/test/middleware.test.lua b/test/middleware.test.lua new file mode 100755 index 0000000..ad9c5c1 --- /dev/null +++ b/test/middleware.test.lua @@ -0,0 +1,81 @@ +#!/usr/bin/env tarantool + +local tap = require('tap') +local middleware_module = require('http.router.middleware') + +-- fix tap and http logs interleaving. +-- +-- tap module writes to stdout, +-- http-server logs to stderr. +-- this results in non-synchronized output. +-- +-- somehow redirecting stdout to stderr doesn't +-- remove buffering of tap logs (at least on OSX). +-- Monkeypatching to the rescue! + +local orig_iowrite = io.write +package.loaded['io'].write = function(...) + orig_iowrite(...) + io.flush() +end + +local test = tap.test("http") +test:plan(1) + +test:test("ordering", function(test) + test:plan(7) + + local middleware = middleware_module.new() + + local add = function(opts, add_opts) + local should_be_ok = not (add_opts or {}).must_fail + + local msg = ('adding middleware %s is successful'):format(opts.name) + if not should_be_ok then + msg = ('adding middleware %s must fail'):format(opts.name) + end + + local ok = middleware:use(opts) + test:is(ok, should_be_ok, msg) + end + + local ensure_before = function(mwname1, mwname2) + local msg = ('%s must be ordered before %s'):format(mwname1, mwname2) + for _, mw in ipairs(middleware:ordered()) do + if mw.name == mwname1 then + test:ok(true, msg) + return + elseif mw.name == mwname2 then + test:fail(msg) + return + end + end + end + + add({ + name = 'a' + }) + + add({ + name = 'b', + after = 'a', + before = 'c' + }) + add({ + name = 'c', + }) + + ensure_before('a', 'b') + ensure_before('b', 'c') + ensure_before('b', 'c') + + add({ + name = 'd', + before = 'a', + after = 'c' + }, { + must_fail = true + }) + + middleware:clear() +end) diff --git a/test/nginx.conf b/test/nginx.conf new file mode 100644 index 0000000..7090cd7 --- /dev/null +++ b/test/nginx.conf @@ -0,0 +1,84 @@ +worker_processes 8; + +events { + worker_connections 4096; +} + +http { +access_log /dev/stdout; +error_log /dev/stderr debug; + +upstream tnt_backend { + server 127.0.0.1:3301 max_fails=5 fail_timeout=60s; + keepalive 32; +} + +server { + listen 127.0.0.1:12345; + server_name localhost; + + location /tnt_proxy { + internal; + tnt_method "nginx_entrypoint"; + tnt_http_methods all; + tnt_buffer_size 100k; + tnt_pass_http_request on pass_body; # parse_args; + tnt_pass tnt_backend; + } + + location / { + rewrite_by_lua ' + local cjson = require("cjson") + local map = { + GET = ngx.HTTP_GET, + POST = ngx.HTTP_POST, + PUT = ngx.HTTP_PUT, + PATCH = ngx.HTTP_PATCH, + DELETE = ngx.HTTP_DELETE, + } + + -- hide `{"params": [...]}` from a user + + ngx.req.read_body() + local body = ngx.req.get_body_data() + + -- cjson.encode is needed to json-escape the body + if body then + body = "{\\"params\\": " .. cjson.encode(body) .. "}" + end + + local res = ngx.location.capture("/tnt_proxy", { + args = ngx.var.args, + method = map[ngx.var.request_method], + body = body + }) + if res.status == ngx.HTTP_OK then + local answ = cjson.decode(res.body) + -- Read reply + local result = answ["result"] + if result ~= nil then + ngx.status = result[1] + for k, v in pairs(result[2]) do + ngx.header[k] = v + end + ngx.print(result[3]) + else + ngx.status = 502 + ngx.say("Tarantool does not work") + end + -- Finalize execution + ngx.exit(ngx.OK) + else + ngx.status = res.status + ngx.say(res.body) + end + '; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root html; + } +} + +} diff --git a/test_locally.sh b/test_locally.sh new file mode 100755 index 0000000..64754cb --- /dev/null +++ b/test_locally.sh @@ -0,0 +1,11 @@ +set -e + +echo "Builtin server" +echo "--------------------" +echo "" +SERVER_TYPE=builtin ./.rocks/bin/luatest + +echo "Nginx server" +echo "--------------------" +echo "" +honcho start -f ./test/Procfile.test.nginx