• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tarantool / http / 3793250174

pending completion
3793250174

push

github

GitHub
<a href="https://github.com/tarantool/http/commit/<a class=hub.com/tarantool/http/commit/6e2e004b7696b81993b8f8b4783bbd439fd288ef">6e2e004b7<a href="https://github.com/tarantool/http/commit/6e2e004b7696b81993b8f8b4783bbd439fd288ef">">Merge </a><a class="double-link" href="https://github.com/tarantool/http/commit/<a class="double-link" href="https://github.com/tarantool/http/commit/64eb0baa3a1acd21a7e01469080db7c93cd1590d">64eb0baa3</a>">64eb0baa3</a><a href="https://github.com/tarantool/http/commit/6e2e004b7696b81993b8f8b4783bbd439fd288ef"> into 305dc8bcd">305dc8bcd</a>

6 of 6 new or added lines in 1 file covered. (100.0%)

611 of 769 relevant lines covered (79.45%)

236.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.35
/http/server.lua
1
-- http.server
2

3
local lib = require('http.lib')
2✔
4

5
local fio = require('fio')
2✔
6
local require = require
2✔
7
local package = package
2✔
8
local mime_types = require('http.mime_types')
2✔
9
local codes = require('http.codes')
2✔
10

11
local log = require('log')
2✔
12
local socket = require('socket')
2✔
13
local json = require('json')
2✔
14
local errno = require 'errno'
2✔
15

16
local DETACHED = 101
2✔
17

18
local function errorf(fmt, ...)
19
    error(string.format(fmt, ...))
4✔
20
end
21

22
local function sprintf(fmt, ...)
23
    return string.format(fmt, ...)
2,034✔
24
end
25

26
-- Converts a table to a map, values becomes keys with value 'true'.
27
-- { 'a', 'b', 'c' } -> { 'a' = true, 'b' == 'true', 'c' = true }
28
local function tomap(tbl)
29
    local map = {}
98✔
30
    for _, v in pairs(tbl) do
102✔
31
        map[v] = true
4✔
32
    end
33
    return map
98✔
34
end
35

36
local function valid_cookie_value_byte(byte)
37
    -- https://tools.ietf.org/html/rfc6265#section-4.1.1
38
    -- US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon,
39
    -- and backslash.
40
    return 32 < byte and byte < 127 and byte ~= string.byte('"') and
1,604✔
41
            byte ~= string.byte(",") and byte ~= string.byte(";") and byte ~= string.byte("\\")
1,604✔
42
end
43

44
local function valid_cookie_path_byte(byte)
45
    -- https://tools.ietf.org/html/rfc6265#section-4.1.1
46
    -- <any CHAR except CTLs or ";">
47
    return 32 <= byte and byte < 127 and byte ~= string.byte(";")
604✔
48
end
49

50
local function escape_char(char)
51
    return string.format('%%%02X', string.byte(char))
28✔
52
end
53

54
local function unescape_char(char)
55
    return string.char(tonumber(char, 16))
14✔
56
end
57

58
local function escape_string(str, byte_filter)
59
    local result = {}
598✔
60
    for i = 1, str:len() do
3,404✔
61
        local char = str:sub(i,i)
2,208✔
62
        if byte_filter(string.byte(char)) then
4,416✔
63
            result[i] = char
2,180✔
64
        else
65
            result[i] = escape_char(char)
56✔
66
        end
67
    end
68
    return table.concat(result)
598✔
69
end
70

71
local function escape_value(cookie_value)
72
    return escape_string(cookie_value, valid_cookie_value_byte)
398✔
73
end
74

75
local function escape_path(cookie_path)
76
    return escape_string(cookie_path, valid_cookie_path_byte)
200✔
77
end
78

79
local function uri_escape(str)
80
    local res = {}
8✔
81
    if type(str) == 'table' then
8✔
82
        for _, v in pairs(str) do
×
83
            table.insert(res, uri_escape(v))
×
84
        end
85
    else
86
        res = string.gsub(str, '[^a-zA-Z0-9_]', escape_char)
8✔
87
    end
88
    return res
8✔
89
end
90

91
local function uri_unescape(str, unescape_plus_sign)
92
    local res = {}
114✔
93
    if type(str) == 'table' then
114✔
94
        for _, v in pairs(str) do
×
95
            table.insert(res, uri_unescape(v))
×
96
        end
97
    else
98
        if unescape_plus_sign ~= nil then
114✔
99
            str = string.gsub(str, '+', ' ')
12✔
100
        end
101

102
        res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])', unescape_char)
114✔
103
    end
104
    return res
114✔
105
end
106

107
local function extend(tbl, tblu, raise)
108
    local res = {}
2,986✔
109
    for k, v in pairs(tbl) do
7,242✔
110
        res[ k ] = v
4,256✔
111
    end
112
    for k, v in pairs(tblu) do
8,678✔
113
        if raise then
5,692✔
114
            if res[ k ] == nil then
348✔
115
                errorf("Unknown option '%s'", k)
×
116
            end
117
        end
118
        res[ k ] = v
5,692✔
119
    end
120
    return res
2,986✔
121
end
122

123
local function type_by_format(fmt)
124
    if fmt == nil then
20✔
125
        return 'application/octet-stream'
×
126
    end
127

128
    local t = mime_types[ fmt ]
20✔
129

130
    if t ~= nil then
20✔
131
        return t
20✔
132
    end
133

134
    return 'application/octet-stream'
×
135
end
136

137
local function reason_by_code(code)
138
    code = tonumber(code)
100✔
139
    if codes[code] ~= nil then
100✔
140
        return codes[code]
100✔
141
    end
142
    return sprintf('Unknown code %d', code)
×
143
end
144

145
local function ucfirst(str)
146
    return str:gsub("^%l", string.upper, 1)
458✔
147
end
148

149
local function cached_query_param(self, name)
150
    if name == nil then
6✔
151
        return self.query_params
6✔
152
    end
153
    return self.query_params[ name ]
×
154
end
155

156
local function cached_post_param(self, name)
157
    if name == nil then
8✔
158
        return self.post_params
6✔
159
    end
160
    return self.post_params[ name ]
2✔
161
end
162

163
local function request_tostring(self)
164
        local res = self:request_line() .. "\r\n"
36✔
165

166
        for hn, hv in pairs(self.headers) do
72✔
167
            res = sprintf("%s%s: %s\r\n", res, ucfirst(hn), hv)
162✔
168
        end
169

170
        return sprintf("%s\r\n%s", res, self.body)
18✔
171
end
172

173
local function request_line(self)
174
        local rstr = self.path
22✔
175
        if string.len(self.query) then
44✔
176
            rstr = rstr .. '?' .. self.query
22✔
177
        end
178
        return sprintf("%s %s HTTP/%d.%d",
22✔
179
            self.method, rstr, self.proto[1], self.proto[2])
22✔
180
end
181

182
local function query_param(self, name)
183
        if self.query == nil and string.len(self.query) == 0 then
6✔
184
            rawset(self, 'query_params', {})
×
185
        else
186
            local params = lib.params(self.query)
6✔
187
            local pres = {}
6✔
188
            for k, v in pairs(params) do
12✔
189
                pres[ uri_unescape(k, true) ] = uri_unescape(v, true)
18✔
190
            end
191
            rawset(self, 'query_params', pres)
6✔
192
        end
193

194
        rawset(self, 'query_param', cached_query_param)
6✔
195
        return self:query_param(name)
6✔
196
end
197

198
local function request_content_type(self)
199
    -- returns content type without encoding string
200
    if self.headers['content-type'] == nil then
22✔
201
        return nil
18✔
202
    end
203

204
    return string.match(self.headers['content-type'],
8✔
205
                        '^([^;]*)$') or
8✔
206
        string.match(self.headers['content-type'],
×
207
                     '^(.*);.*')
4✔
208
end
209

210
local function post_param(self, name)
211
    if self:content_type() == 'multipart/form-data' then
16✔
212
        -- TODO: do that!
213
        rawset(self, 'post_params', {})
×
214
    elseif self:content_type() == 'application/json' then
16✔
215
        local params = self:json()
2✔
216
        rawset(self, 'post_params', params)
2✔
217
    elseif self:content_type() == 'application/x-www-form-urlencoded' then
12✔
218
        local params = lib.params(self:read_cached())
×
219
        local pres = {}
×
220
        for k, v in pairs(params) do
×
221
            pres[ uri_unescape(k) ] = uri_unescape(v, true)
×
222
        end
223
        rawset(self, 'post_params', pres)
×
224
    else
225
        local params = lib.params(self:read_cached())
12✔
226
        local pres = {}
6✔
227
        for k, v in pairs(params) do
6✔
228
            pres[ uri_unescape(k) ] = uri_unescape(v)
×
229
        end
230
        rawset(self, 'post_params', pres)
6✔
231
    end
232

233
    rawset(self, 'post_param', cached_post_param)
8✔
234
    return self:post_param(name)
8✔
235
end
236

237
local function param(self, name)
238
        if name ~= nil then
6✔
239
            local v = self:post_param(name)
×
240
            if v ~= nil then
×
241
                return v
×
242
            end
243
            return self:query_param(name)
×
244
        end
245

246
        local post = self:post_param()
6✔
247
        local query = self:query_param()
6✔
248
        return extend(post, query, false)
6✔
249
end
250

251
local function catfile(...)
252
    local sp = { ... }
38✔
253

254
    local path
255

256
    if #sp == 0 then
38✔
257
        return
×
258
    end
259

260
    for _, pe in pairs(sp) do
150✔
261
        if path == nil then
112✔
262
            path = pe
38✔
263
        elseif string.match(path, '.$') ~= '/' then
74✔
264
            if string.match(pe, '^.') ~= '/' then
74✔
265
                path = path .. '/' .. pe
68✔
266
            else
267
                path = path .. pe
6✔
268
            end
269
        else
270
            if string.match(pe, '^.') == '/' then
×
271
                path = path .. string.gsub(pe, '^/', '', 1)
×
272
            else
273
                path = path .. pe
×
274
            end
275
        end
276
    end
277

278
    return path
38✔
279
end
280

281
local response_mt
282
local request_mt
283

284
local function expires_str(str)
285

286
    local now = os.time()
2✔
287
    local gmtnow = now - os.difftime(now, os.time(os.date("!*t", now)))
2✔
288
    local fmt = '%a, %d-%b-%Y %H:%M:%S GMT'
2✔
289

290
    if str == 'now' or str == 0 or str == '0' then
2✔
291
        return os.date(fmt, gmtnow)
×
292
    end
293

294
    local diff, period = string.match(str, '^[+]?(%d+)([hdmy])$')
2✔
295
    if period == nil then
2✔
296
        return str
2✔
297
    end
298

299
    diff = tonumber(diff)
×
300
    if period == 'h' then
×
301
        diff = diff * 3600
×
302
    elseif period == 'd' then
×
303
        diff = diff * 86400
×
304
    elseif period == 'm' then
×
305
        diff = diff * 86400 * 30
×
306
    else
307
        diff = diff * 86400 * 365
×
308
    end
309

310
    return os.date(fmt, gmtnow + diff)
×
311
end
312

313
local function setcookie(resp, cookie, options)
314
    options = options or {}
916✔
315
    local name = cookie.name
916✔
316
    local value = cookie.value
916✔
317

318
    if name == nil then
916✔
319
        error('cookie.name is undefined')
×
320
    end
321
    if value == nil then
916✔
322
        error('cookie.value is undefined')
×
323
    end
324

325
    if not options.raw then
916✔
326
        value = escape_value(value)
796✔
327
    end
328
    local str = sprintf('%s=%s', name, value)
916✔
329
    if cookie.path ~= nil then
916✔
330
        local cookie_path = cookie.path
458✔
331
        if not options.raw then
458✔
332
            cookie_path = escape_path(cookie.path)
400✔
333
        end
334
        str = sprintf('%s;path=%s', str, cookie_path)
916✔
335
    end
336
    if cookie.domain ~= nil then
916✔
337
        str = sprintf('%s;domain=%s', str, cookie.domain)
8✔
338
    end
339

340
    if cookie.expires ~= nil then
916✔
341
        str = sprintf('%s;expires=%s', str, expires_str(cookie.expires))
6✔
342
    end
343

344
    if not resp.headers then
916✔
345
        resp.headers = {}
912✔
346
    end
347
    if resp.headers['set-cookie'] == nil then
916✔
348
        resp.headers['set-cookie'] = { str }
916✔
349
    elseif type(resp.headers['set-cookie']) == 'string' then
×
350
        resp.headers['set-cookie'] = {
×
351
            resp.headers['set-cookie'],
×
352
            str
353
        }
354
    else
355
        table.insert(resp.headers['set-cookie'], str)
×
356
    end
357
    return resp
916✔
358
end
359

360
local function cookie(tx, cookie, options)
361
    options = options or {}
4✔
362
    if tx.headers.cookie == nil then
4✔
363
        return nil
×
364
    end
365
    for k, v in string.gmatch(
12✔
366
                tx.headers.cookie, "([^=,; \t]+)=([^,; \t]+)") do
8✔
367
        if k == cookie then
4✔
368
            if not options.raw then
4✔
369
                v = uri_unescape(v)
4✔
370
            end
371
            return v
4✔
372
        end
373
    end
374
    return nil
×
375
end
376

377
local function url_for_helper(tx, name, args, query)
378
    return tx:url_for(name, args, query)
×
379
end
380

381
local function load_template(self, r, format)
382
    if r.template ~= nil then
18✔
383
        return
×
384
    end
385

386
    if format == nil then
18✔
387
        format = 'html'
×
388
    end
389

390
    local file
391
    if r.file ~= nil then
18✔
392
        file = r.file
16✔
393
    elseif r.controller ~= nil and r.action ~= nil then
2✔
394
        file = catfile(
4✔
395
            string.gsub(r.controller, '[.]', '/'),
2✔
396
            r.action .. '.' .. format .. '.el')
4✔
397
    else
398
        errorf("Can not find template for '%s'", r.path)
×
399
    end
400

401
    if self.options.cache_templates then
18✔
402
        if self.cache.tpl[ file ] ~= nil then
18✔
403
            return self.cache.tpl[ file ]
×
404
        end
405
    end
406

407

408
    local tpl = catfile(self.options.app_dir, 'templates', file)
18✔
409
    local fh, err = fio.open(tpl)
18✔
410
    if err ~= nil then
18✔
411
        errorf("Can not load template for '%s': '%s'", r.path, err)
×
412
    end
413

414
    local template
415
    template, err = fh:read()
36✔
416
    if err ~= nil then
18✔
417
        errorf("Can not load template for '%s': '%s'", r.path, err)
×
418
    end
419

420
    fh:close()
18✔
421

422
    if self.options.cache_templates then
18✔
423
        self.cache.tpl[ file ] = template
18✔
424
    end
425
    return template
18✔
426
end
427

428
local function render(tx, opts)
429
    if tx == nil then
68✔
430
        error("Usage: self:render({ ... })")
×
431
    end
432

433
    local resp = setmetatable({ headers = {} }, response_mt)
68✔
434
    local vars = {}
68✔
435
    if opts ~= nil then
68✔
436
        if opts.text ~= nil then
60✔
437
            if tx.httpd.options.charset ~= nil then
48✔
438
                resp.headers['content-type'] =
48✔
439
                    sprintf("text/plain; charset=%s",
96✔
440
                        tx.httpd.options.charset
48✔
441
                    )
96✔
442
            else
443
                resp.headers['content-type'] = 'text/plain'
×
444
            end
445
            resp.body = tostring(opts.text)
48✔
446
            return resp
48✔
447
        end
448

449
        if opts.json ~= nil then
12✔
450
            if tx.httpd.options.charset ~= nil then
2✔
451
                resp.headers['content-type'] =
2✔
452
                    sprintf('application/json; charset=%s',
4✔
453
                        tx.httpd.options.charset
2✔
454
                    )
4✔
455
            else
456
                resp.headers['content-type'] = 'application/json'
×
457
            end
458
            resp.body = json.encode(opts.json)
2✔
459
            return resp
2✔
460
        end
461

462
        if opts.data ~= nil then
10✔
463
            resp.body = tostring(opts.data)
×
464
            return resp
×
465
        end
466

467
        vars = extend(tx.tstash, opts, false)
20✔
468
    end
469

470
    local tpl
471

472
    local format = tx.tstash.format
18✔
473
    if format == nil then
18✔
474
        format = 'html'
×
475
    end
476

477
    if tx.endpoint.template ~= nil then
18✔
478
        tpl = tx.endpoint.template
×
479
    else
480
        tpl = load_template(tx.httpd, tx.endpoint, format)
36✔
481
        if tpl == nil then
18✔
482
            errorf('template is not defined for the route')
×
483
        end
484
    end
485

486
    if type(tpl) == 'function' then
18✔
487
        tpl = tpl()
×
488
    end
489

490
    for hname, sub in pairs(tx.httpd.helpers) do
54✔
491
        vars[hname] = function(...) return sub(tx, ...) end
42✔
492
    end
493
    vars.action = tx.endpoint.action
18✔
494
    vars.controller = tx.endpoint.controller
18✔
495
    vars.format = format
18✔
496

497
    resp.body = lib.template(tpl, vars)
18✔
498
    resp.headers['content-type'] = type_by_format(format)
36✔
499

500
    if tx.httpd.options.charset ~= nil then
18✔
501
        if format == 'html' or format == 'js' or format == 'json' then
18✔
502
            resp.headers['content-type'] = resp.headers['content-type']
18✔
503
                .. '; charset=' .. tx.httpd.options.charset
18✔
504
        end
505
    end
506
    return resp
18✔
507
end
508

509
local function iterate(_, gen, param, state)
510
    return setmetatable({ body = { gen = gen, param = param, state = state } },
2✔
511
        response_mt)
2✔
512
end
513

514
local function redirect_to(tx, name, args, query)
515
    local location = tx:url_for(name, args, query)
×
516
    return setmetatable({ status = 302, headers = { location = location } },
×
517
        response_mt)
×
518
end
519

520
local function access_stash(tx, name, ...)
521
    if type(tx) ~= 'table' then
22✔
522
        error("usage: ctx:stash('name'[, 'value'])")
×
523
    end
524
    if select('#', ...) > 0 then
22✔
525
        tx.tstash[ name ] = select(1, ...)
×
526
    end
527

528
    return tx.tstash[ name ]
22✔
529
end
530

531
local function url_for_tx(tx, name, args, query)
532
    if name == 'current' then
×
533
        return tx.endpoint:url_for(args, query)
×
534
    end
535
    return tx.httpd:url_for(name, args, query)
×
536
end
537

538
local function request_json(req)
539
    local data = req:read_cached()
4✔
540
    local s, json = pcall(json.decode, data)
4✔
541
    if s then
4✔
542
       return json
4✔
543
    else
544
       error(sprintf("Can't decode json in request '%s': %s",
×
545
           data, tostring(json)))
×
546
       return nil
×
547
    end
548
end
549

550
local function request_read(req, opts, timeout)
551
    local remaining = req._remaining
128✔
552
    if not remaining then
128✔
553
        remaining = tonumber(req.headers['content-length'])
116✔
554
        if not remaining then
116✔
555
            return ''
104✔
556
        end
557
    end
558

559
    if opts == nil then
24✔
560
        opts = remaining
24✔
561
    elseif type(opts) == 'number' then
×
562
        if opts > remaining then
×
563
            opts = remaining
×
564
        end
565
    elseif type(opts) == 'string' then
×
566
        opts = { size = remaining, delimiter = opts }
×
567
    elseif type(opts) == 'table' then
×
568
        local size = opts.size or opts.chunk
×
569
        if size and size > remaining then
×
570
            opts.size = remaining
×
571
            opts.chunk = nil
×
572
        end
573
    end
574

575
    local buf = req.s:read(opts, timeout)
24✔
576
    if buf == nil then
24✔
577
        req._remaining = 0
×
578
        return ''
×
579
    end
580
    remaining = remaining - #buf
24✔
581
    assert(remaining >= 0)
24✔
582
    req._remaining = remaining
24✔
583
    return buf
24✔
584
end
585

586
local function request_read_cached(self)
587
    if self.cached_data == nil then
14✔
588
        local data = self:read()
10✔
589
        rawset(self, 'cached_data', data)
10✔
590
        return data
10✔
591
    else
592
        return self.cached_data
4✔
593
    end
594
end
595

596
local function static_file(self, request, format)
597
        local file = catfile(self.options.app_dir, 'public', request.path)
6✔
598

599
        if self.options.cache_static and self.cache.static[ file ] ~= nil then
6✔
600
            return {
×
601
                code = 200,
602
                headers = {
×
603
                    [ 'content-type'] = type_by_format(format),
604
                },
605
                body = self.cache.static[ file ]
×
606
            }
607
        end
608

609
        local fh, err = fio.open(file, {'O_RDONLY'})
6✔
610
        if err ~= nil then
6✔
611
            return { status = 404 }
4✔
612
        end
613

614
        local body
615
        body, err = fh:read()
4✔
616
        if err ~= nil then
2✔
617
            errorf("Can not return static file for '%s': '%s'", request:path(), err)
×
618
        end
619

620
        fh:close()
2✔
621

622
        if self.options.cache_static then
2✔
623
            self.cache.static[ file ] = body
2✔
624
        end
625

626
        return {
2✔
627
            status = 200,
628
            headers = {
2✔
629
                [ 'content-type'] = type_by_format(format),
4✔
630
            },
2✔
631
            body = body
2✔
632
        }
2✔
633
end
634

635
request_mt = {
2✔
636
    __index = {
2✔
637
        render      = render,
2✔
638
        cookie      = cookie,
2✔
639
        redirect_to = redirect_to,
2✔
640
        iterate     = iterate,
2✔
641
        stash       = access_stash,
2✔
642
        url_for     = url_for_tx,
2✔
643
        content_type= request_content_type,
2✔
644
        request_line= request_line,
2✔
645
        read_cached = request_read_cached,
2✔
646
        query_param = query_param,
2✔
647
        post_param  = post_param,
2✔
648
        param       = param,
2✔
649
        read        = request_read,
2✔
650
        json        = request_json
2✔
651
    },
2✔
652
    __tostring = request_tostring;
2✔
653
}
2✔
654

655
response_mt = {
2✔
656
    __index = {
2✔
657
        setcookie = setcookie;
2✔
658
    }
2✔
659
}
2✔
660

661
local function is_function(obj)
662
    return type(obj) == 'function'
18✔
663
end
664

665
local function get_request_logger(server_opts, route_opts)
666
    if route_opts and route_opts.endpoint.log_requests ~= nil then
100✔
667
        if is_function(route_opts.endpoint.log_requests) then
12✔
668
            return route_opts.endpoint.log_requests
6✔
669
        elseif route_opts.endpoint.log_requests == false then
×
670
            return log.debug
×
671
        end
672
    end
673

674
    if server_opts.log_requests then
94✔
675
        if is_function(server_opts.log_requests) then
12✔
676
            return server_opts.log_requests
4✔
677
        end
678

679
        return log.info
2✔
680
    end
681

682
    return log.debug
88✔
683
end
684

685
local function get_error_logger(server_opts, route_opts)
686
    if route_opts and route_opts.endpoint.log_errors ~= nil then
12✔
687
        if is_function(route_opts.endpoint.log_errors) then
8✔
688
            return route_opts.endpoint.log_errors
4✔
689
        elseif route_opts.endpoint.log_errors == false then
×
690
            return log.debug
×
691
        end
692
    end
693

694
    if server_opts.log_errors then
8✔
695
        if is_function(server_opts.log_errors) then
4✔
696
            return server_opts.log_errors
2✔
697
        end
698

699
        return log.error
×
700
    end
701

702
    return log.debug
6✔
703
end
704

705
local function handler(self, request)
706
    if self.hooks.before_dispatch ~= nil then
100✔
707
        self.hooks.before_dispatch(self, request)
×
708
    end
709

710
    local format = 'html'
100✔
711

712
    local pformat = string.match(request.path, '[.]([^.]+)$')
100✔
713
    if pformat ~= nil then
100✔
714
        format = pformat
6✔
715
    end
716

717
    local r = self:match(request.method, request.path)
100✔
718
    if r == nil then
100✔
719
        return static_file(self, request, format)
6✔
720
    end
721

722
    local stash = extend(r.stash, { format = format })
94✔
723

724
    request.endpoint = r.endpoint
94✔
725
    request.tstash   = stash
94✔
726

727
    local resp = r.endpoint.sub(request)
94✔
728
    if self.hooks.after_dispatch ~= nil then
82✔
729
        self.hooks.after_dispatch(request, resp)
×
730
    end
731
    return resp
82✔
732
end
733

734
local function normalize_headers(hdrs)
735
    local res = {}
74✔
736
    for h, v in pairs(hdrs) do
148✔
737
        res[ string.lower(h) ] = v
148✔
738
    end
739
    return res
74✔
740
end
741

742
local function parse_request(req)
743
    local p = lib._parse_request(req)
100✔
744
    if p.error then
100✔
745
        return p
×
746
    end
747
    p.path_raw = p.path
100✔
748
    p.path = uri_unescape(p.path)
200✔
749
    if p.path:sub(1, 1) ~= "/" or p.path:find("./", nil, true) ~= nil then
200✔
750
        p.error = "invalid uri"
×
751
        return p
×
752
    end
753
    return p
100✔
754
end
755

756
local function process_client(self, s, peer)
757
    while true do
758
        local hdrs = ''
106✔
759

760
        local is_eof = false
106✔
761
        while true do
762
            local chunk = s:read({
212✔
763
                delimiter = { "\n\n", "\r\n\r\n" },
106✔
764
            }, self.idle_timeout)
106✔
765

766
            if chunk == '' then
106✔
767
                is_eof = true
4✔
768
                break -- eof
4✔
769
            elseif chunk == nil then
102✔
770
                log.error('failed to read request: %s', errno.strerror())
4✔
771
                return
2✔
772
            end
773

774
            hdrs = hdrs .. chunk
100✔
775

776
            if string.endswith(hdrs, "\n\n") or string.endswith(hdrs, "\r\n\r\n") then
300✔
777
                break
100✔
778
            end
779
        end
780

781
        if is_eof then
104✔
782
            break
4✔
783
        end
784

785
        log.debug("request:\n%s", hdrs)
100✔
786
        local p = parse_request(hdrs)
100✔
787
        if p.error ~= nil then
100✔
788
            log.error('failed to parse request: %s', p.error)
×
789
            s:write(sprintf("HTTP/1.0 400 Bad request\r\n\r\n%s", p.error))
×
790
            break
791
        end
792
        p.httpd = self
100✔
793
        p.s = s
100✔
794
        p.peer = peer
100✔
795
        setmetatable(p, request_mt)
100✔
796

797
        if p.headers['expect'] == '100-continue' then
100✔
798
            s:write('HTTP/1.0 100 Continue\r\n\r\n')
×
799
        end
800

801
        local route = self:match(p.method, p.path)
100✔
802
        local logreq = get_request_logger(self.options, route)
100✔
803
        logreq("%s %s%s", p.method, p.path,
200✔
804
               p.query ~= "" and "?"..p.query or "")
100✔
805

806
        local res, reason = pcall(self.options.handler, self, p)
100✔
807
        p:read() -- skip remaining bytes of request body
100✔
808
        local status, hdrs, body
809

810
        if not res then
100✔
811
            status = 500
12✔
812
            hdrs = {}
12✔
813
            local trace = debug.traceback()
12✔
814
            local logerror = get_error_logger(self.options, route)
12✔
815
            logerror('unhandled error: %s\n%s\nrequest:\n%s',
24✔
816
                     tostring(reason), trace, tostring(p))
12✔
817
            if self.options.display_errors then
12✔
818
            body =
819
                  "Unhandled error: " .. tostring(reason) .. "\n"
6✔
820
                .. trace .. "\n\n"
6✔
821
                .. "\n\nRequest:\n"
6✔
822
                .. tostring(p)
12✔
823
            else
824
                body = "Internal Error"
6✔
825
            end
826
       elseif type(reason) == 'table' then
88✔
827
            if reason.status == nil then
80✔
828
                status = 200
70✔
829
            elseif type(reason.status) == 'number' then
10✔
830
                status = reason.status
10✔
831
            else
832
                error('response.status must be a number')
×
833
            end
834
            if reason.headers == nil then
80✔
835
                hdrs = {}
6✔
836
            elseif type(reason.headers) == 'table' then
74✔
837
                hdrs = normalize_headers(reason.headers)
148✔
838
            else
839
                error('response.headers must be a table')
×
840
            end
841
            body = reason.body
80✔
842
        elseif reason == nil then
8✔
843
            status = 200
8✔
844
            hdrs = {}
8✔
845
        elseif type(reason) == 'number' then
×
846
            if reason == DETACHED then
×
847
                break
848
            end
849
        else
850
            error('invalid response')
×
851
        end
852

853
        local gen, param, state
854
        if type(body) == 'string' then
100✔
855
            -- Plain string
856
            hdrs['content-length'] = #body
86✔
857
        elseif type(body) == 'function' then
14✔
858
            -- Generating function
859
            gen = body
×
860
            hdrs['transfer-encoding'] = 'chunked'
×
861
        elseif type(body) == 'table' and body.gen then
14✔
862
            -- Iterator
863
            gen, param, state = body.gen, body.param, body.state
2✔
864
            hdrs['transfer-encoding'] = 'chunked'
2✔
865
        elseif body == nil then
12✔
866
            -- Empty body
867
            hdrs['content-length'] = 0
12✔
868
        else
869
            body = tostring(body)
×
870
            hdrs['content-length'] = #body
×
871
        end
872

873
        if hdrs['content-type'] == nil then
100✔
874
            hdrs['content-type'] = 'text/plain; charset=utf-8'
30✔
875
        end
876

877
        if hdrs.server == nil then
100✔
878
            hdrs.server = sprintf('Tarantool http (tarantool v%s)', _TARANTOOL)
200✔
879
        end
880

881
        if p.proto[1] ~= 1 then
100✔
882
            hdrs.connection = 'close'
×
883
        elseif p.broken then
100✔
884
            hdrs.connection = 'close'
×
885
        elseif rawget(p, 'body') == nil then
100✔
886
            hdrs.connection = 'close'
×
887
        elseif p.proto[2] == 1 then
100✔
888
            if p.headers.connection == nil then
100✔
889
                hdrs.connection = 'keep-alive'
×
890
            elseif string.lower(p.headers.connection) ~= 'keep-alive' then
200✔
891
                hdrs.connection = 'close'
92✔
892
            else
893
                hdrs.connection = 'keep-alive'
8✔
894
            end
895
        elseif p.proto[2] == 0 then
×
896
            if p.headers.connection == nil then
×
897
                hdrs.connection = 'close'
×
898
            elseif string.lower(p.headers.connection) == 'keep-alive' then
×
899
                hdrs.connection = 'keep-alive'
×
900
            else
901
                hdrs.connection = 'close'
×
902
            end
903
        end
904

905
        local useragent = p.headers['user-agent']
100✔
906
        if self.disable_keepalive[useragent] == true then
100✔
907
            hdrs.connection = 'close'
2✔
908
        end
909

910
        local response = {
100✔
911
            "HTTP/1.1 ";
912
            status;
100✔
913
            " ";
914
            reason_by_code(status);
200✔
915
            "\r\n";
916
        };
917
        for k, v in pairs(hdrs) do
504✔
918
            if type(v) == 'table' then
404✔
919
                for _, sv in pairs(v) do
8✔
920
                    table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), sv))
12✔
921
                end
922
            else
923
                table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), v))
1,200✔
924
            end
925
        end
926
        table.insert(response, "\r\n")
100✔
927

928
        if type(body) == 'string' then
100✔
929
            table.insert(response, body)
86✔
930
            response = table.concat(response)
86✔
931
            if not s:write(response) then
172✔
932
                break
933
            end
934
        elseif gen then
14✔
935
            response = table.concat(response)
2✔
936
            if not s:write(response) then
4✔
937
                break
938
            end
939
            response = nil -- luacheck: no unused
2✔
940
            -- Transfer-Encoding: chunked
941
            for _, part in gen, param, state do
8✔
942
                part = tostring(part)
6✔
943
                if not s:write(sprintf("%x\r\n%s\r\n", #part, part)) then
18✔
944
                    break
945
                end
946
            end
947
            if not s:write("0\r\n\r\n") then
4✔
948
                break
949
            end
950
        else
951
            response = table.concat(response)
12✔
952
            if not s:write(response) then
24✔
953
                break
954
            end
955
        end
956

957
        if p.proto[1] ~= 1 then
100✔
958
            break
959
        end
960

961
        if hdrs.connection ~= 'keep-alive' then
100✔
962
            break
94✔
963
        end
964
    end
965
end
966

967
local function httpd_stop(self)
968
   if type(self) ~= 'table' then
96✔
969
       error("httpd: usage: httpd:stop()")
×
970
    end
971
    if self.is_run then
96✔
972
        self.is_run = false
96✔
973
    else
974
        error("server is already stopped")
×
975
    end
976

977
    if self.tcp_server ~= nil then
96✔
978
        self.tcp_server:close()
96✔
979
        self.tcp_server = nil
96✔
980
    end
981
    return self
96✔
982
end
983

984
local function match_route(self, method, route)
985
    if string.match(route, '^.') ~= '/' then
258✔
986
        route = '/' .. route
×
987
    end
988

989
    method = string.upper(method)
516✔
990

991
    local fit
992
    local stash = {}
258✔
993

994
    for _, r in pairs(self.routes) do
3,904✔
995
        local sroute = route
3,646✔
996
        if r.method == method or r.method == 'ANY' then
3,646✔
997
            if r.trailing_slash and string.match(route, '.$') ~= '/' then
3,630✔
998
                sroute = route .. '/'
2,896✔
999
            end
1000
            local m = { string.match(sroute, r.match)  }
3,630✔
1001
            local nfit
1002
            if #m > 0 then
3,630✔
1003
                if #r.stash > 0 then
244✔
1004
                    if #r.stash == #m then
96✔
1005
                        nfit = r
96✔
1006
                    end
1007
                else
1008
                    nfit = r
148✔
1009
                end
1010

1011
                if nfit ~= nil then
244✔
1012
                    if fit == nil then
244✔
1013
                        fit = nfit
244✔
1014
                        stash = m
244✔
1015
                    else
1016
                        if #fit.stash > #nfit.stash then
×
1017
                            fit = nfit
×
1018
                            stash = m
×
1019
                        elseif r.method ~= fit.method then
×
1020
                            if fit.method == 'ANY' then
×
1021
                                fit = nfit
×
1022
                                stash = m
×
1023
                            end
1024
                        end
1025
                    end
1026
                end
1027
            end
1028
        end
1029
    end
1030

1031
    if fit == nil then
258✔
1032
        return fit
14✔
1033
    end
1034
    local resstash = {}
244✔
1035
    for i = 1, #fit.stash do
346✔
1036
        resstash[ fit.stash[ i ] ] = stash[ i ]
102✔
1037
    end
1038
    return  { endpoint = fit, stash = resstash }
244✔
1039
end
1040

1041
local function set_helper(self, name, sub)
1042
    if sub == nil or type(sub) == 'function' then
86✔
1043
        self.helpers[ name ] = sub
86✔
1044
        return self
86✔
1045
    end
1046
    errorf("Wrong type for helper function: %s", type(sub))
×
1047
end
1048

1049
local function set_hook(self, name, sub)
1050
    if sub == nil or type(sub) == 'function' then
×
1051
        self.hooks[ name ] = sub
×
1052
        return self
×
1053
    end
1054
    errorf("Wrong type for hook function: %s", type(sub))
×
1055
end
1056

1057
local function url_for_route(r, args, query)
1058
    if args == nil then
8✔
1059
        args = {}
2✔
1060
    end
1061
    local name = r.path
8✔
1062
    for _, sn in pairs(r.stash) do
20✔
1063
        local sv = args[sn]
12✔
1064
        if sv == nil then
12✔
1065
            sv = ''
4✔
1066
        end
1067
        name = string.gsub(name, '[*:]' .. sn, sv, 1)
12✔
1068
    end
1069

1070
    if query ~= nil then
8✔
1071
        if type(query) == 'table' then
2✔
1072
            local sep = '?'
2✔
1073
            for k, v in pairs(query) do
6✔
1074
                name = name .. sep .. uri_escape(k) .. '=' .. uri_escape(v)
12✔
1075
                sep = '&'
4✔
1076
            end
1077
        else
1078
            name = name .. '?' .. query
×
1079
        end
1080
    end
1081

1082
    if string.match(name, '^/') == nil then
8✔
1083
        return '/' .. name
×
1084
    else
1085
        return name
8✔
1086
    end
1087
end
1088

1089
local function ctx_action(tx)
1090
    local ctx = tx.endpoint.controller
6✔
1091
    local action = tx.endpoint.action
6✔
1092
    if tx.httpd.options.cache_controllers then
6✔
1093
        if tx.httpd.cache[ ctx ] ~= nil then
6✔
1094
            if type(tx.httpd.cache[ ctx ][ action ]) ~= 'function' then
×
1095
                errorf("Controller '%s' doesn't contain function '%s'",
×
1096
                    ctx, action)
×
1097
            end
1098
            return tx.httpd.cache[ ctx ][ action ](tx)
×
1099
        end
1100
    end
1101

1102
    local ppath = package.path
6✔
1103
    package.path = catfile(tx.httpd.options.app_dir, 'controllers', '?.lua')
6✔
1104
                .. ';'
1105
                .. catfile(tx.httpd.options.app_dir,
12✔
1106
                    'controllers', '?/init.lua')
12✔
1107
    if ppath ~= nil then
6✔
1108
        package.path = package.path .. ';' .. ppath
6✔
1109
    end
1110

1111
    local st, mod = pcall(require, ctx)
6✔
1112
    package.path = ppath
6✔
1113
    package.loaded[ ctx ] = nil
6✔
1114

1115
    if not st then
6✔
1116
        errorf("Can't load module '%s': %s'", ctx, tostring(mod))
2✔
1117
    end
1118

1119
    if type(mod) ~= 'table' then
4✔
1120
        errorf("require '%s' didn't return table", ctx)
×
1121
    end
1122

1123
    if type(mod[ action ]) ~= 'function' then
4✔
1124
        errorf("Controller '%s' doesn't contain function '%s'", ctx, action)
2✔
1125
    end
1126

1127
    if tx.httpd.options.cache_controllers then
2✔
1128
        tx.httpd.cache[ ctx ] = mod
2✔
1129
    end
1130

1131
    return mod[action](tx)
2✔
1132
end
1133

1134
local possible_methods = {
2✔
1135
    GET    = 'GET',
1136
    HEAD   = 'HEAD',
1137
    POST   = 'POST',
1138
    PUT    = 'PUT',
1139
    DELETE = 'DELETE',
1140
    PATCH  = 'PATCH',
1141
}
1142

1143
local function add_route(self, opts, sub)
1144
    if type(opts) ~= 'table' or type(self) ~= 'table' then
1,346✔
1145
        error("Usage: httpd:route({ ... }, function(cx) ... end)")
×
1146
    end
1147

1148
    opts = extend({method = 'ANY'}, opts, false)
2,692✔
1149
    opts = extend({trailing_slash = true}, opts, false)
2,692✔
1150

1151
    local ctx
1152
    local action
1153

1154
    if sub == nil then
1,346✔
1155
        sub = render
180✔
1156
    elseif type(sub) == 'string' then
1,166✔
1157

1158
        ctx, action = string.match(sub, '(.+)#(.*)')
258✔
1159

1160
        if ctx == nil or action == nil then
258✔
1161
            errorf("Wrong controller format '%s', must be 'module#action'", sub)
×
1162
        end
1163

1164
        sub = ctx_action
258✔
1165

1166
    elseif type(sub) ~= 'function' then
908✔
1167
        errorf("wrong argument: expected function, but received %s",
×
1168
            type(sub))
×
1169
    end
1170

1171
    opts.method = possible_methods[string.upper(opts.method)] or 'ANY'
2,692✔
1172

1173
    if opts.path == nil then
1,346✔
1174
        error("path is not defined")
×
1175
    end
1176

1177
    opts.controller = ctx
1,346✔
1178
    opts.action = action
1,346✔
1179
    opts.match = opts.path
1,346✔
1180
    opts.match = string.gsub(opts.match, '[-]', "[-]")
1,346✔
1181

1182
    local estash = {  }
1,346✔
1183
    local stash = {  }
1,346✔
1184
    while true do
1185
        local name = string.match(opts.match, ':([%a_][%w_]*)')
1,862✔
1186
        if name == nil then
1,862✔
1187
            break
1,346✔
1188
        end
1189
        if estash[name] then
516✔
1190
            errorf("duplicate stash: %s", name)
×
1191
        end
1192
        estash[name] = true
516✔
1193
        opts.match = string.gsub(opts.match, ':[%a_][%w_]*', '([^/]-)', 1)
516✔
1194

1195
        table.insert(stash, name)
516✔
1196
    end
1197
    while true do
1198
        local name = string.match(opts.match, '[*]([%a_][%w_]*)')
1,690✔
1199
        if name == nil then
1,690✔
1200
            break
1,346✔
1201
        end
1202
        if estash[name] then
344✔
1203
            errorf("duplicate stash: %s", name)
×
1204
        end
1205
        estash[name] = true
344✔
1206
        opts.match = string.gsub(opts.match, '[*][%a_][%w_]*', '(.-)', 1)
344✔
1207

1208
        table.insert(stash, name)
344✔
1209
    end
1210

1211
    if string.match(opts.match, '.$') ~= '/' and opts.trailing_slash then
1,346✔
1212
        opts.match = opts.match .. '/'
1,242✔
1213
    end
1214
    if string.match(opts.match, '^.') ~= '/' then
1,346✔
1215
        opts.match = '/' .. opts.match
×
1216
    end
1217

1218
    opts.match = '^' .. opts.match .. '$'
1,346✔
1219

1220
    estash = nil -- luacheck: no unused
1,346✔
1221

1222
    opts.stash = stash
1,346✔
1223
    opts.sub = sub
1,346✔
1224
    opts.url_for = url_for_route
1,346✔
1225

1226
    if opts.log_requests ~= nil then
1,346✔
1227
        if type(opts.log_requests) ~= 'function' and type(opts.log_requests) ~= 'boolean' then
10✔
1228
            error("'log_requests' option should be a function or a boolean")
4✔
1229
        end
1230
    end
1231

1232
    if opts.log_errors ~= nil then
1,342✔
1233
        if type(opts.log_errors) ~= 'function' and type(opts.log_errors) ~= 'boolean' then
10✔
1234
            error("'log_errors' option should be a function or a boolean")
4✔
1235
        end
1236
    end
1237

1238
    if opts.name ~= nil then
1,338✔
1239
        if opts.name == 'current' then
172✔
1240
            error("Route can not have name 'current'")
×
1241
        end
1242
        if self.iroutes[ opts.name ] ~= nil then
172✔
1243
            errorf("Route with name '%s' is already exists", opts.name)
×
1244
        end
1245
        table.insert(self.routes, opts)
172✔
1246
        self.iroutes[ opts.name ] = #self.routes
172✔
1247
    else
1248
        table.insert(self.routes, opts)
1,166✔
1249
    end
1250
    return self
1,338✔
1251
end
1252

1253
local function url_for_httpd(httpd, name, args, query)
1254

1255
    local idx = httpd.iroutes[ name ]
10✔
1256
    if idx ~= nil then
10✔
1257
        return httpd.routes[ idx ]:url_for(args, query)
8✔
1258
    end
1259

1260
    if string.match(name, '^/') == nil then
2✔
1261
        if string.match(name, '^https?://') ~= nil then
2✔
1262
            return name
×
1263
        else
1264
            return '/' .. name
2✔
1265
        end
1266
    else
1267
        return name
×
1268
    end
1269
end
1270

1271
local function httpd_start(self)
1272
    if type(self) ~= 'table' then
96✔
1273
        error("httpd: usage: httpd:start()")
×
1274
    end
1275

1276
    local server = self.tcp_server_f(self.host, self.port, {
192✔
1277
        name = 'http',
1278
        handler = function(...)
1279
            self.internal.preprocess_client_handler()
100✔
1280
            process_client(self, ...)
100✔
1281
            self.internal.postprocess_client_handler()
100✔
1282
        end,
1283
        http_server = self,
96✔
1284
    })
1285

1286
    if server == nil then
96✔
1287
        error(sprintf("Can't create tcp_server: %s", errno.strerror()))
×
1288
    end
1289

1290
    rawset(self, 'is_run', true)
96✔
1291
    rawset(self, 'tcp_server', server)
96✔
1292
    rawset(self, 'stop', httpd_stop)
96✔
1293

1294
    return self
96✔
1295
end
1296

1297
local exports = {
2✔
1298
    DETACHED = DETACHED,
2✔
1299

1300
    new = function(host, port, options)
1301
        if options == nil then
98✔
1302
            options = {}
2✔
1303
        end
1304
        if type(options) ~= 'table' then
98✔
1305
            errorf("options must be table not '%s'", type(options))
×
1306
        end
1307
        local disable_keepalive = options.disable_keepalive or {}
98✔
1308
        if type(disable_keepalive) ~= 'table' then
98✔
1309
            error('Option disable_keepalive must be a table.')
×
1310
        end
1311
        if options.idle_timeout ~= nil and
98✔
1312
           type(options.idle_timeout) ~= 'number' then
2✔
1313
            error('Option idle_timeout must be a number.')
×
1314
        end
1315

1316
        local default = {
98✔
1317
            max_header_size     = 4096,
1318
            header_timeout      = 100,
1319
            handler             = handler,
98✔
1320
            app_dir             = '.',
1321
            charset             = 'utf-8',
1322
            cache_templates     = true,
1323
            cache_controllers   = true,
1324
            cache_static        = true,
1325
            log_requests        = true,
1326
            log_errors          = true,
1327
            display_errors      = false,
1328
            disable_keepalive   = {},
98✔
1329
            idle_timeout        = 0, -- no timeout, option is disabled
1330
        }
1331

1332
        local self = {
98✔
1333
            host    = host,
98✔
1334
            port    = port,
98✔
1335
            is_run  = false,
1336
            stop    = httpd_stop,
98✔
1337
            start   = httpd_start,
98✔
1338
            options = extend(default, options, true),
196✔
1339

1340
            routes  = {  },
98✔
1341
            iroutes = {  },
98✔
1342
            helpers = {
98✔
1343
                url_for = url_for_helper,
98✔
1344
            },
98✔
1345
            hooks   = {  },
98✔
1346

1347
            -- methods
1348
            route   = add_route,
98✔
1349
            match   = match_route,
98✔
1350
            helper  = set_helper,
98✔
1351
            hook    = set_hook,
98✔
1352
            url_for = url_for_httpd,
98✔
1353

1354
            -- Exposed to make it replaceable by a user.
1355
            tcp_server_f = socket.tcp_server,
98✔
1356

1357
            -- caches
1358
            cache   = {
98✔
1359
                tpl         = {},
98✔
1360
                ctx         = {},
98✔
1361
                static      = {},
98✔
1362
            },
98✔
1363

1364
            disable_keepalive   = tomap(disable_keepalive),
196✔
1365
            idle_timeout        = options.idle_timeout,
98✔
1366

1367
            internal = {
98✔
1368
                preprocess_client_handler = function() end,
190✔
1369
                postprocess_client_handler = function() end,
190✔
1370
            }
98✔
1371
        }
1372

1373
        return self
98✔
1374
    end,
1375

1376
    internal = {
2✔
1377
        response_mt = response_mt,
2✔
1378
        request_mt = request_mt,
2✔
1379
        extend = extend,
2✔
1380
    }
2✔
1381
}
1382

1383
return exports
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc