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

tarantool / http / 10822459639

12 Sep 2024 01:28AM UTC coverage: 78.161% (-0.06%) from 78.222%
10822459639

push

github

web-flow
Merge edcb47228 into 0b471d8ce

8 of 9 new or added lines in 1 file covered. (88.89%)

1 existing line in 1 file now uncovered.

612 of 783 relevant lines covered (78.16%)

80.97 hits per line

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

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

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

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

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

16
local DETACHED = 101
1✔
17

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

22
local function sprintf(fmt, ...)
23
    return string.format(fmt, ...)
972✔
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 = {}
43✔
30
    for _, v in pairs(tbl) do
88✔
31
        map[v] = true
2✔
32
    end
33
    return map
43✔
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
802✔
41
            byte ~= string.byte(",") and byte ~= string.byte(";") and byte ~= string.byte("\\")
802✔
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(";")
302✔
48
end
49

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

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

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

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

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

79
local function uri_escape(str)
80
    local res = {}
4✔
81
    if type(str) == 'table' then
4✔
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)
4✔
87
    end
88
    return res
4✔
89
end
90

91
local function uri_unescape(str, unescape_plus_sign)
92
    local res = {}
50✔
93
    if type(str) == 'table' then
50✔
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
50✔
99
            str = string.gsub(str, '+', ' ')
6✔
100
        end
101

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

107
local function extend(tbl, tblu, raise)
108
    local res = {}
612✔
109
    for k, v in pairs(tbl) do
2,380✔
110
        res[ k ] = v
1,156✔
111
    end
112
    for k, v in pairs(tblu) do
2,096✔
113
        if raise then
872✔
114
            if res[ k ] == nil then
142✔
115
                errorf("Unknown option '%s'", k)
×
116
            end
117
        end
118
        res[ k ] = v
872✔
119
    end
120
    return res
612✔
121
end
122

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

128
    local t = mime_types[ fmt ]
10✔
129

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

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

137
local function reason_by_code(code)
138
    code = tonumber(code)
43✔
139
    if codes[code] ~= nil then
43✔
140
        return codes[code]
43✔
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)
201✔
147
end
148

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

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

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

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

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

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

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

194
        rawset(self, 'query_param', cached_query_param)
4✔
195
        return self:query_param(name)
4✔
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
2✔
201
        return nil
×
202
    end
203

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

210
local function post_param(self, name)
211
    local body = self:read_cached()
5✔
212

213
    if body == '' then
5✔
214
        rawset(self, 'post_params', {})
4✔
215
    elseif self:content_type() == 'multipart/form-data' then
2✔
216
        -- TODO: do that!
217
        rawset(self, 'post_params', {})
×
218
    elseif self:content_type() == 'application/json' then
2✔
219
        local params = self:json()
1✔
220
        rawset(self, 'post_params', params)
1✔
221
    elseif self:content_type() == 'application/x-www-form-urlencoded' then
×
222
        local params = lib.params(body)
×
223
        local pres = {}
×
224
        for k, v in pairs(params) do
×
225
            pres[ uri_unescape(k) ] = uri_unescape(v, true)
×
226
        end
227
        rawset(self, 'post_params', pres)
×
228
    else
229
        local params = lib.params(body)
×
230
        local pres = {}
×
231
        for k, v in pairs(params) do
×
232
            pres[ uri_unescape(k) ] = uri_unescape(v)
×
233
        end
234
        rawset(self, 'post_params', pres)
×
235
    end
236

237
    rawset(self, 'post_param', cached_post_param)
5✔
238
    return self:post_param(name)
5✔
239
end
240

241
local function param(self, name)
242
        if name ~= nil then
4✔
243
            local v = self:post_param(name)
×
244
            if v ~= nil then
×
245
                return v
×
246
            end
247
            return self:query_param(name)
×
248
        end
249

250
        local post = self:post_param()
4✔
251
        local query = self:query_param()
4✔
252
        return extend(post, query, false)
4✔
253
end
254

255
local function catfile(...)
256
    local sp = { ... }
20✔
257

258
    local path
259

260
    if #sp == 0 then
20✔
261
        return
×
262
    end
263

264
    for _, pe in pairs(sp) do
99✔
265
        if path == nil then
59✔
266
            path = pe
20✔
267
        elseif string.match(path, '.$') ~= '/' then
39✔
268
            if string.match(pe, '^.') ~= '/' then
39✔
269
                path = path .. '/' .. pe
35✔
270
            else
271
                path = path .. pe
4✔
272
            end
273
        else
274
            if string.match(pe, '^.') == '/' then
×
275
                path = path .. string.gsub(pe, '^/', '', 1)
×
276
            else
277
                path = path .. pe
×
278
            end
279
        end
280
    end
281

282
    return path
20✔
283
end
284

285
local response_mt
286
local request_mt
287

288
local function expires_str(str)
289

290
    local now = os.time()
1✔
291
    local gmtnow = now - os.difftime(now, os.time(os.date("!*t", now)))
1✔
292
    local fmt = '%a, %d-%b-%Y %H:%M:%S GMT'
1✔
293

294
    if str == 'now' or str == 0 or str == '0' then
1✔
295
        return os.date(fmt, gmtnow)
×
296
    end
297

298
    local diff, period = string.match(str, '^[+]?(%d+)([hdmy])$')
1✔
299
    if period == nil then
1✔
300
        return str
1✔
301
    end
302

303
    diff = tonumber(diff)
×
304
    if period == 'h' then
×
305
        diff = diff * 3600
×
306
    elseif period == 'd' then
×
307
        diff = diff * 86400
×
308
    elseif period == 'm' then
×
309
        diff = diff * 86400 * 30
×
310
    else
311
        diff = diff * 86400 * 365
×
312
    end
313

314
    return os.date(fmt, gmtnow + diff)
×
315
end
316

317
local function setcookie(resp, cookie, options)
318
    options = options or {}
458✔
319
    local name = cookie.name
458✔
320
    local value = cookie.value
458✔
321

322
    if name == nil then
458✔
323
        error('cookie.name is undefined')
×
324
    end
325
    if value == nil then
458✔
326
        error('cookie.value is undefined')
×
327
    end
328

329
    if not options.raw then
458✔
330
        value = escape_value(value)
398✔
331
    end
332
    local str = sprintf('%s=%s', name, value)
458✔
333
    if cookie.path ~= nil then
458✔
334
        local cookie_path = cookie.path
229✔
335
        if not options.raw then
229✔
336
            cookie_path = escape_path(cookie.path)
200✔
337
        end
338
        str = sprintf('%s;path=%s', str, cookie_path)
458✔
339
    end
340
    if cookie.domain ~= nil then
458✔
341
        str = sprintf('%s;domain=%s', str, cookie.domain)
4✔
342
    end
343

344
    if cookie.expires ~= nil then
458✔
345
        str = sprintf('%s;expires=%s', str, expires_str(cookie.expires))
3✔
346
    end
347

348
    if not resp.headers then
458✔
349
        resp.headers = {}
456✔
350
    end
351
    if resp.headers['set-cookie'] == nil then
458✔
352
        resp.headers['set-cookie'] = { str }
458✔
353
    elseif type(resp.headers['set-cookie']) == 'string' then
×
354
        resp.headers['set-cookie'] = {
×
355
            resp.headers['set-cookie'],
×
356
            str
357
        }
358
    else
359
        table.insert(resp.headers['set-cookie'], str)
×
360
    end
361
    return resp
458✔
362
end
363

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

381
local function url_for_helper(tx, name, args, query)
382
    return tx:url_for(name, args, query)
×
383
end
384

385
local function load_template(self, r, format)
386
    if r.template ~= nil then
9✔
387
        return
×
388
    end
389

390
    if format == nil then
9✔
391
        format = 'html'
×
392
    end
393

394
    local file
395
    if r.file ~= nil then
9✔
396
        file = r.file
8✔
397
    elseif r.controller ~= nil and r.action ~= nil then
1✔
398
        file = catfile(
2✔
399
            string.gsub(r.controller, '[.]', '/'),
1✔
400
            r.action .. '.' .. format .. '.el')
2✔
401
    else
402
        errorf("Can not find template for '%s'", r.path)
×
403
    end
404

405
    if self.options.cache_templates then
9✔
406
        if self.cache.tpl[ file ] ~= nil then
9✔
407
            return self.cache.tpl[ file ]
×
408
        end
409
    end
410

411

412
    local tpl = catfile(self.options.app_dir, 'templates', file)
9✔
413
    local fh, err = fio.open(tpl)
9✔
414
    if err ~= nil then
9✔
415
        errorf("Can not load template for '%s': '%s'", r.path, err)
×
416
    end
417

418
    local template
419
    template, err = fh:read()
18✔
420
    if err ~= nil then
9✔
421
        errorf("Can not load template for '%s': '%s'", r.path, err)
×
422
    end
423

424
    fh:close()
9✔
425

426
    if self.options.cache_templates then
9✔
427
        self.cache.tpl[ file ] = template
9✔
428
    end
429
    return template
9✔
430
end
431

432
local function render(tx, opts)
433
    if tx == nil then
24✔
434
        error("Usage: self:render({ ... })")
×
435
    end
436

437
    local resp = setmetatable({ headers = {} }, response_mt)
24✔
438
    local vars = {}
24✔
439
    if opts ~= nil then
24✔
440
        if opts.text ~= nil then
20✔
441
            if tx.httpd.options.charset ~= nil then
13✔
442
                resp.headers['content-type'] =
13✔
443
                    sprintf("text/plain; charset=%s",
26✔
444
                        tx.httpd.options.charset
13✔
445
                    )
26✔
446
            else
447
                resp.headers['content-type'] = 'text/plain'
×
448
            end
449
            resp.body = tostring(opts.text)
13✔
450
            return resp
13✔
451
        end
452

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

466
        if opts.data ~= nil then
5✔
467
            resp.body = tostring(opts.data)
×
468
            return resp
×
469
        end
470

471
        vars = extend(tx.tstash, opts, false)
10✔
472
    end
473

474
    local tpl
475

476
    local format = tx.tstash.format
9✔
477
    if format == nil then
9✔
478
        format = 'html'
×
479
    end
480

481
    if tx.endpoint.template ~= nil then
9✔
482
        tpl = tx.endpoint.template
×
483
    else
484
        tpl = load_template(tx.httpd, tx.endpoint, format)
18✔
485
        if tpl == nil then
9✔
486
            errorf('template is not defined for the route')
×
487
        end
488
    end
489

490
    if type(tpl) == 'function' then
9✔
491
        tpl = tpl()
×
492
    end
493

494
    for hname, sub in pairs(tx.httpd.helpers) do
36✔
495
        vars[hname] = function(...) return sub(tx, ...) end
21✔
496
    end
497
    vars.action = tx.endpoint.action
9✔
498
    vars.controller = tx.endpoint.controller
9✔
499
    vars.format = format
9✔
500

501
    resp.body = lib.template(tpl, vars)
9✔
502
    resp.headers['content-type'] = type_by_format(format)
18✔
503

504
    if tx.httpd.options.charset ~= nil then
9✔
505
        if format == 'html' or format == 'js' or format == 'json' then
9✔
506
            resp.headers['content-type'] = resp.headers['content-type']
9✔
507
                .. '; charset=' .. tx.httpd.options.charset
9✔
508
        end
509
    end
510
    return resp
9✔
511
end
512

513
local function iterate(_, gen, param, state)
514
    return setmetatable({ body = { gen = gen, param = param, state = state } },
1✔
515
        response_mt)
1✔
516
end
517

518
local function redirect_to(tx, name, args, query)
519
    local location = tx:url_for(name, args, query)
×
520
    return setmetatable({ status = 302, headers = { location = location } },
×
521
        response_mt)
×
522
end
523

524
local function access_stash(tx, name, ...)
525
    if type(tx) ~= 'table' then
×
526
        error("usage: ctx:stash('name'[, 'value'])")
×
527
    end
528
    if select('#', ...) > 0 then
×
529
        tx.tstash[ name ] = select(1, ...)
×
530
    end
531

532
    return tx.tstash[ name ]
×
533
end
534

535
local function url_for_tx(tx, name, args, query)
536
    if name == 'current' then
×
537
        return tx.endpoint:url_for(args, query)
×
538
    end
539
    return tx.httpd:url_for(name, args, query)
×
540
end
541

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

554
local function request_read(req, opts, timeout)
555
    local remaining = req._remaining
58✔
556
    if not remaining then
58✔
557
        remaining = tonumber(req.headers['content-length'])
52✔
558
        if not remaining then
52✔
559
            return ''
46✔
560
        end
561
    end
562

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

579
    local buf = req.s:read(opts, timeout)
12✔
580
    if buf == nil then
12✔
581
        req._remaining = 0
×
582
        return ''
×
583
    end
584
    remaining = remaining - #buf
12✔
585
    assert(remaining >= 0)
12✔
586
    req._remaining = remaining
12✔
587
    return buf
12✔
588
end
589

590
local function request_read_cached(self)
591
    if self.cached_data == nil then
9✔
592
        local data = self:read()
6✔
593
        rawset(self, 'cached_data', data)
6✔
594
        return data
6✔
595
    else
596
        return self.cached_data
3✔
597
    end
598
end
599

600
local function static_file(self, request, format)
601
        local file = catfile(self.options.app_dir, 'public', request.path)
4✔
602

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

613
        local fh, err = fio.open(file, {'O_RDONLY'})
4✔
614
        if err ~= nil then
4✔
615
            return { status = 404 }
3✔
616
        end
617

618
        local body
619
        body, err = fh:read()
2✔
620
        if err ~= nil then
1✔
621
            errorf("Can not return static file for '%s': '%s'", request:path(), err)
×
622
        end
623

624
        fh:close()
1✔
625

626
        if self.options.cache_static then
1✔
627
            self.cache.static[ file ] = body
1✔
628
        end
629

630
        return {
1✔
631
            status = 200,
632
            headers = {
1✔
633
                [ 'content-type'] = type_by_format(format),
2✔
634
            },
1✔
635
            body = body
1✔
636
        }
1✔
637
end
638

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

659
response_mt = {
1✔
660
    __index = {
1✔
661
        setcookie = setcookie;
1✔
662
    }
1✔
663
}
1✔
664

665
local function is_function(obj)
666
    return type(obj) == 'function'
9✔
667
end
668

669
local function get_request_logger(server_opts, route_opts)
670
    if route_opts and route_opts.endpoint.log_requests ~= nil then
43✔
671
        if is_function(route_opts.endpoint.log_requests) then
6✔
672
            return route_opts.endpoint.log_requests
3✔
673
        elseif route_opts.endpoint.log_requests == false then
×
674
            return log.debug
×
675
        end
676
    end
677

678
    if server_opts.log_requests then
40✔
679
        if is_function(server_opts.log_requests) then
6✔
680
            return server_opts.log_requests
2✔
681
        end
682

683
        return log.info
1✔
684
    end
685

686
    return log.debug
37✔
687
end
688

689
local function get_error_logger(server_opts, route_opts)
690
    if route_opts and route_opts.endpoint.log_errors ~= nil then
6✔
691
        if is_function(route_opts.endpoint.log_errors) then
4✔
692
            return route_opts.endpoint.log_errors
2✔
693
        elseif route_opts.endpoint.log_errors == false then
×
694
            return log.debug
×
695
        end
696
    end
697

698
    if server_opts.log_errors then
4✔
699
        if is_function(server_opts.log_errors) then
2✔
700
            return server_opts.log_errors
1✔
701
        end
702

703
        return log.error
×
704
    end
705

706
    return log.debug
3✔
707
end
708

709
local function handler(self, request)
710
    if self.hooks.before_dispatch ~= nil then
43✔
711
        self.hooks.before_dispatch(self, request)
×
712
    end
713

714
    local format = 'html'
43✔
715

716
    local pformat = string.match(request.path, '[.]([^.]+)$')
43✔
717
    if pformat ~= nil then
43✔
718
        format = pformat
2✔
719
    end
720

721
    local r = self:match(request.method, request.path)
43✔
722
    if r == nil then
43✔
723
        return static_file(self, request, format)
4✔
724
    end
725

726
    local stash = extend(r.stash, { format = format })
39✔
727

728
    request.endpoint = r.endpoint
39✔
729
    request.tstash   = stash
39✔
730

731
    local resp = r.endpoint.sub(request)
39✔
732
    if self.hooks.after_dispatch ~= nil then
33✔
733
        self.hooks.after_dispatch(request, resp)
×
734
    end
735
    return resp
33✔
736
end
737

738
local function normalize_headers(hdrs)
739
    local res = {}
27✔
740
    for h, v in pairs(hdrs) do
81✔
741
        res[ string.lower(h) ] = v
54✔
742
    end
743
    return res
27✔
744
end
745

746
local function parse_request(req)
747
    local p = lib._parse_request(req)
43✔
748
    if p.error then
43✔
749
        return p
×
750
    end
751
    p.path_raw = p.path
43✔
752
    p.path = uri_unescape(p.path)
86✔
753
    if p.path:sub(1, 1) ~= "/" then
86✔
754
        p.error = "invalid uri"
×
755
        return p
×
756
    end
757
    for _, path_segment in ipairs(p.path:split('/')) do
172✔
758
        if path_segment == "." or path_segment == ".." then
86✔
759
            p.error = "invalid uri"
×
760
            return p
×
761
        end
762
    end
763

764
    return p
43✔
765
end
766

767
local function process_client(self, s, peer)
768
    while true do
769
        local hdrs = ''
46✔
770

771
        local is_eof = false
46✔
772
        while true do
773
            local chunk = s:read({
92✔
774
                delimiter = { "\n\n", "\r\n\r\n" },
46✔
775
            }, self.idle_timeout)
46✔
776

777
            if chunk == '' then
44✔
UNCOV
778
                is_eof = true
×
779
                break -- eof
780
            elseif chunk == nil then
44✔
781
                log.error('failed to read request: %s', errno.strerror())
2✔
782
                return
1✔
783
            end
784

785
            hdrs = hdrs .. chunk
43✔
786

787
            if string.endswith(hdrs, "\n\n") or string.endswith(hdrs, "\r\n\r\n") then
129✔
788
                break
43✔
789
            end
790
        end
791

792
        if is_eof then
43✔
793
            break
794
        end
795

796
        log.debug("request:\n%s", hdrs)
43✔
797
        local p = parse_request(hdrs)
43✔
798
        if p.error ~= nil then
43✔
799
            log.error('failed to parse request: %s', p.error)
×
800
            s:write(sprintf("HTTP/1.0 400 Bad request\r\n\r\n%s", p.error))
×
801
            break
802
        end
803
        p.httpd = self
43✔
804
        p.s = s
43✔
805
        p.peer = peer
43✔
806
        setmetatable(p, request_mt)
43✔
807

808
        if p.headers['expect'] == '100-continue' then
43✔
809
            s:write('HTTP/1.0 100 Continue\r\n\r\n')
×
810
        end
811

812
        local route = self:match(p.method, p.path)
43✔
813
        local logreq = get_request_logger(self.options, route)
43✔
814
        logreq("%s %s%s", p.method, p.path,
86✔
815
               p.query ~= "" and "?"..p.query or "")
43✔
816

817
        local res, reason = pcall(self.options.handler, self, p)
43✔
818
        p:read() -- skip remaining bytes of request body
43✔
819
        local status, hdrs, body
820

821
        if not res then
43✔
822
            status = 500
6✔
823
            hdrs = {}
6✔
824
            local trace = debug.traceback()
6✔
825
            local logerror = get_error_logger(self.options, route)
6✔
826
            logerror('unhandled error: %s\n%s\nrequest:\n%s',
12✔
827
                     tostring(reason), trace, tostring(p))
6✔
828
            if self.options.display_errors then
6✔
829
            body =
830
                  "Unhandled error: " .. tostring(reason) .. "\n"
3✔
831
                .. trace .. "\n\n"
3✔
832
                .. "\n\nRequest:\n"
3✔
833
                .. tostring(p)
6✔
834
            else
835
                body = "Internal Error"
3✔
836
            end
837
       elseif type(reason) == 'table' then
37✔
838
            if reason.status == nil then
32✔
839
                status = 200
24✔
840
            elseif type(reason.status) == 'number' then
8✔
841
                status = reason.status
8✔
842
            else
843
                error('response.status must be a number')
×
844
            end
845
            if reason.headers == nil then
32✔
846
                hdrs = {}
5✔
847
            elseif type(reason.headers) == 'table' then
27✔
848
                hdrs = normalize_headers(reason.headers)
54✔
849
            else
850
                error('response.headers must be a table')
×
851
            end
852
            body = reason.body
32✔
853
        elseif reason == nil then
5✔
854
            status = 200
5✔
855
            hdrs = {}
5✔
856
        elseif type(reason) == 'number' then
×
857
            if reason == DETACHED then
×
858
                break
859
            end
860
        else
861
            error('invalid response')
×
862
        end
863

864
        local gen, param, state
865
        if type(body) == 'string' then
43✔
866
            -- Plain string
867
            hdrs['content-length'] = #body
33✔
868
        elseif type(body) == 'function' then
10✔
869
            -- Generating function
870
            gen = body
×
871
            hdrs['transfer-encoding'] = 'chunked'
×
872
        elseif type(body) == 'table' and body.gen then
10✔
873
            -- Iterator
874
            gen, param, state = body.gen, body.param, body.state
1✔
875
            hdrs['transfer-encoding'] = 'chunked'
1✔
876
        elseif body == nil then
9✔
877
            -- Empty body
878
            hdrs['content-length'] = 0
9✔
879
        else
880
            body = tostring(body)
×
881
            hdrs['content-length'] = #body
×
882
        end
883

884
        if hdrs['content-type'] == nil then
43✔
885
            hdrs['content-type'] = 'text/plain; charset=utf-8'
18✔
886
        end
887

888
        if hdrs.server == nil then
43✔
889
            hdrs.server = sprintf('Tarantool http (tarantool v%s)', _TARANTOOL)
86✔
890
        end
891

892
        if p.proto[1] ~= 1 then
43✔
893
            hdrs.connection = 'close'
×
894
        elseif p.broken then
43✔
895
            hdrs.connection = 'close'
×
896
        elseif rawget(p, 'body') == nil then
43✔
897
            hdrs.connection = 'close'
×
898
        elseif p.proto[2] == 1 then
43✔
899
            if p.headers.connection == nil then
43✔
900
                hdrs.connection = 'keep-alive'
×
901
            elseif string.lower(p.headers.connection) ~= 'keep-alive' then
86✔
902
                hdrs.connection = 'close'
39✔
903
            else
904
                hdrs.connection = 'keep-alive'
4✔
905
            end
906
        elseif p.proto[2] == 0 then
×
907
            if p.headers.connection == nil then
×
908
                hdrs.connection = 'close'
×
909
            elseif string.lower(p.headers.connection) == 'keep-alive' then
×
910
                hdrs.connection = 'keep-alive'
×
911
            else
912
                hdrs.connection = 'close'
×
913
            end
914
        end
915

916
        local useragent = p.headers['user-agent']
43✔
917
        if self.disable_keepalive[useragent] == true then
43✔
918
            hdrs.connection = 'close'
1✔
919
        end
920

921
        local response = {
43✔
922
            "HTTP/1.1 ";
923
            status;
43✔
924
            " ";
925
            reason_by_code(status);
86✔
926
            "\r\n";
927
        };
928
        for k, v in pairs(hdrs) do
260✔
929
            if type(v) == 'table' then
174✔
930
                for _, sv in pairs(v) do
6✔
931
                    table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), sv))
6✔
932
                end
933
            else
934
                table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), v))
516✔
935
            end
936
        end
937
        table.insert(response, "\r\n")
43✔
938

939
        if type(body) == 'string' then
43✔
940
            table.insert(response, body)
33✔
941
            response = table.concat(response)
33✔
942
            if not s:write(response) then
66✔
943
                break
944
            end
945
        elseif gen then
10✔
946
            response = table.concat(response)
1✔
947
            if not s:write(response) then
2✔
948
                break
949
            end
950
            response = nil -- luacheck: no unused
1✔
951
            -- Transfer-Encoding: chunked
952
            for _, part in gen, param, state do
4✔
953
                part = tostring(part)
3✔
954
                if not s:write(sprintf("%x\r\n%s\r\n", #part, part)) then
9✔
955
                    break
956
                end
957
            end
958
            if not s:write("0\r\n\r\n") then
2✔
959
                break
960
            end
961
        else
962
            response = table.concat(response)
9✔
963
            if not s:write(response) then
18✔
964
                break
965
            end
966
        end
967

968
        if p.proto[1] ~= 1 then
43✔
969
            break
970
        end
971

972
        if hdrs.connection ~= 'keep-alive' then
43✔
973
            break
40✔
974
        end
975
    end
976
end
977

978
local function httpd_stop(self)
979
   if type(self) ~= 'table' then
42✔
980
       error("httpd: usage: httpd:stop()")
×
981
    end
982
    if self.is_run then
42✔
983
        self.is_run = false
42✔
984
    else
985
        error("server is already stopped")
×
986
    end
987

988
    if self.tcp_server ~= nil then
42✔
989
        self.tcp_server:close()
42✔
990
        self.tcp_server = nil
42✔
991
    end
992
    return self
42✔
993
end
994

995
local function match_route(self, method, route)
996
    -- route must have '/' at the begin and end
997
    if string.match(route, '.$') ~= '/' then
103✔
998
        route = route .. '/'
92✔
999
    end
1000
    if string.match(route, '^.') ~= '/' then
103✔
1001
        route = '/' .. route
×
1002
    end
1003

1004
    method = string.upper(method)
206✔
1005

1006
    local fit
1007
    local stash = {}
103✔
1008

1009
    for _, r in pairs(self.routes) do
1,463✔
1010
        if r.method == method or r.method == 'ANY' then
1,257✔
1011
            local m = { string.match(route, r.match)  }
1,249✔
1012
            local nfit
1013
            if #m > 0 then
1,249✔
1014
                if #r.stash > 0 then
94✔
1015
                    if #r.stash == #m then
16✔
1016
                        nfit = r
16✔
1017
                    end
1018
                else
1019
                    nfit = r
78✔
1020
                end
1021

1022
                if nfit ~= nil then
94✔
1023
                    if fit == nil then
94✔
1024
                        fit = nfit
94✔
1025
                        stash = m
94✔
1026
                    else
1027
                        if #fit.stash > #nfit.stash then
×
1028
                            fit = nfit
×
1029
                            stash = m
×
1030
                        elseif r.method ~= fit.method then
×
1031
                            if fit.method == 'ANY' then
×
1032
                                fit = nfit
×
1033
                                stash = m
×
1034
                            end
1035
                        end
1036
                    end
1037
                end
1038
            end
1039
        end
1040
    end
1041

1042
    if fit == nil then
103✔
1043
        return fit
9✔
1044
    end
1045
    local resstash = {}
94✔
1046
    for i = 1, #fit.stash do
113✔
1047
        resstash[ fit.stash[ i ] ] = stash[ i ]
19✔
1048
    end
1049
    return  { endpoint = fit, stash = resstash }
94✔
1050
end
1051

1052
local function set_helper(self, name, sub)
1053
    if sub == nil or type(sub) == 'function' then
35✔
1054
        self.helpers[ name ] = sub
35✔
1055
        return self
35✔
1056
    end
1057
    errorf("Wrong type for helper function: %s", type(sub))
×
1058
end
1059

1060
local function set_hook(self, name, sub)
1061
    if sub == nil or type(sub) == 'function' then
×
1062
        self.hooks[ name ] = sub
×
1063
        return self
×
1064
    end
1065
    errorf("Wrong type for hook function: %s", type(sub))
×
1066
end
1067

1068
local function url_for_route(r, args, query)
1069
    if args == nil then
4✔
1070
        args = {}
1✔
1071
    end
1072
    local name = r.path
4✔
1073
    for _, sn in pairs(r.stash) do
14✔
1074
        local sv = args[sn]
6✔
1075
        if sv == nil then
6✔
1076
            sv = ''
2✔
1077
        end
1078
        name = string.gsub(name, '[*:]' .. sn, sv, 1)
6✔
1079
    end
1080

1081
    if query ~= nil then
4✔
1082
        if type(query) == 'table' then
1✔
1083
            local sep = '?'
1✔
1084
            for k, v in pairs(query) do
4✔
1085
                name = name .. sep .. uri_escape(k) .. '=' .. uri_escape(v)
6✔
1086
                sep = '&'
2✔
1087
            end
1088
        else
1089
            name = name .. '?' .. query
×
1090
        end
1091
    end
1092

1093
    if string.match(name, '^/') == nil then
4✔
1094
        return '/' .. name
×
1095
    else
1096
        return name
4✔
1097
    end
1098
end
1099

1100
local function ctx_action(tx)
1101
    local ctx = tx.endpoint.controller
3✔
1102
    local action = tx.endpoint.action
3✔
1103
    if tx.httpd.options.cache_controllers then
3✔
1104
        if tx.httpd.cache[ ctx ] ~= nil then
3✔
1105
            if type(tx.httpd.cache[ ctx ][ action ]) ~= 'function' then
×
1106
                errorf("Controller '%s' doesn't contain function '%s'",
×
1107
                    ctx, action)
×
1108
            end
1109
            return tx.httpd.cache[ ctx ][ action ](tx)
×
1110
        end
1111
    end
1112

1113
    local ppath = package.path
3✔
1114
    package.path = catfile(tx.httpd.options.app_dir, 'controllers', '?.lua')
3✔
1115
                .. ';'
1116
                .. catfile(tx.httpd.options.app_dir,
6✔
1117
                    'controllers', '?/init.lua')
6✔
1118
    if ppath ~= nil then
3✔
1119
        package.path = package.path .. ';' .. ppath
3✔
1120
    end
1121

1122
    local st, mod = pcall(require, ctx)
3✔
1123
    package.path = ppath
3✔
1124
    package.loaded[ ctx ] = nil
3✔
1125

1126
    if not st then
3✔
1127
        errorf("Can't load module '%s': %s'", ctx, tostring(mod))
1✔
1128
    end
1129

1130
    if type(mod) ~= 'table' then
2✔
1131
        errorf("require '%s' didn't return table", ctx)
×
1132
    end
1133

1134
    if type(mod[ action ]) ~= 'function' then
2✔
1135
        errorf("Controller '%s' doesn't contain function '%s'", ctx, action)
1✔
1136
    end
1137

1138
    if tx.httpd.options.cache_controllers then
1✔
1139
        tx.httpd.cache[ ctx ] = mod
1✔
1140
    end
1141

1142
    return mod[action](tx)
1✔
1143
end
1144

1145
local possible_methods = {
1✔
1146
    GET    = 'GET',
1147
    HEAD   = 'HEAD',
1148
    POST   = 'POST',
1149
    PUT    = 'PUT',
1150
    DELETE = 'DELETE',
1151
    PATCH  = 'PATCH',
1152
}
1153

1154
local function add_route(self, opts, sub)
1155
    if type(opts) ~= 'table' or type(self) ~= 'table' then
486✔
1156
        error("Usage: httpd:route({ ... }, function(cx) ... end)")
×
1157
    end
1158

1159
    opts = extend({method = 'ANY'}, opts, false)
972✔
1160

1161
    local ctx
1162
    local action
1163

1164
    if sub == nil then
486✔
1165
        sub = render
74✔
1166
    elseif type(sub) == 'string' then
412✔
1167

1168
        ctx, action = string.match(sub, '(.+)#(.*)')
105✔
1169

1170
        if ctx == nil or action == nil then
105✔
1171
            errorf("Wrong controller format '%s', must be 'module#action'", sub)
×
1172
        end
1173

1174
        sub = ctx_action
105✔
1175

1176
    elseif type(sub) ~= 'function' then
307✔
1177
        errorf("wrong argument: expected function, but received %s",
×
1178
            type(sub))
×
1179
    end
1180

1181
    opts.method = possible_methods[string.upper(opts.method)] or 'ANY'
972✔
1182

1183
    if opts.path == nil then
486✔
1184
        error("path is not defined")
×
1185
    end
1186

1187
    opts.controller = ctx
486✔
1188
    opts.action = action
486✔
1189
    opts.match = opts.path
486✔
1190
    opts.match = string.gsub(opts.match, '[-]', "[-]")
486✔
1191

1192
    local estash = {  }
486✔
1193
    local stash = {  }
486✔
1194
    while true do
1195
        local name = string.match(opts.match, ':([%a_][%w_]*)')
696✔
1196
        if name == nil then
696✔
1197
            break
486✔
1198
        end
1199
        if estash[name] then
210✔
1200
            errorf("duplicate stash: %s", name)
×
1201
        end
1202
        estash[name] = true
210✔
1203
        opts.match = string.gsub(opts.match, ':[%a_][%w_]*', '([^/]-)', 1)
210✔
1204

1205
        table.insert(stash, name)
210✔
1206
    end
1207
    while true do
1208
        local name = string.match(opts.match, '[*]([%a_][%w_]*)')
557✔
1209
        if name == nil then
557✔
1210
            break
486✔
1211
        end
1212
        if estash[name] then
71✔
1213
            errorf("duplicate stash: %s", name)
×
1214
        end
1215
        estash[name] = true
71✔
1216
        opts.match = string.gsub(opts.match, '[*][%a_][%w_]*', '(.-)', 1)
71✔
1217

1218
        table.insert(stash, name)
71✔
1219
    end
1220

1221
    if string.match(opts.match, '.$') ~= '/' then
486✔
1222
        opts.match = opts.match .. '/'
477✔
1223
    end
1224
    if string.match(opts.match, '^.') ~= '/' then
486✔
1225
        opts.match = '/' .. opts.match
×
1226
    end
1227

1228
    opts.match = '^' .. opts.match .. '$'
486✔
1229

1230
    estash = nil -- luacheck: no unused
486✔
1231

1232
    opts.stash = stash
486✔
1233
    opts.sub = sub
486✔
1234
    opts.url_for = url_for_route
486✔
1235

1236
    if opts.log_requests ~= nil then
486✔
1237
        if type(opts.log_requests) ~= 'function' and type(opts.log_requests) ~= 'boolean' then
5✔
1238
            error("'log_requests' option should be a function or a boolean")
2✔
1239
        end
1240
    end
1241

1242
    if opts.log_errors ~= nil then
484✔
1243
        if type(opts.log_errors) ~= 'function' and type(opts.log_errors) ~= 'boolean' then
5✔
1244
            error("'log_errors' option should be a function or a boolean")
2✔
1245
        end
1246
    end
1247

1248
    if opts.name ~= nil then
482✔
1249
        if opts.name == 'current' then
71✔
1250
            error("Route can not have name 'current'")
×
1251
        end
1252
        if self.iroutes[ opts.name ] ~= nil then
71✔
1253
            errorf("Route with name '%s' is already exists", opts.name)
×
1254
        end
1255
        table.insert(self.routes, opts)
71✔
1256
        self.iroutes[ opts.name ] = #self.routes
71✔
1257
    else
1258
        table.insert(self.routes, opts)
411✔
1259
    end
1260
    return self
482✔
1261
end
1262

1263
local function delete_route(self, name)
1264
    local route = self.iroutes[name]
1✔
1265
    if route == nil then
1✔
NEW
1266
        return
×
1267
    end
1268

1269
    self.iroutes[name] = nil
1✔
1270
    table.remove(self.routes, route)
1✔
1271

1272
    -- Update iroutes numeration.
1273
    for n, r in ipairs(self.routes) do
14✔
1274
        if r.name then
13✔
1275
            self.iroutes[r.name] = n
2✔
1276
        end
1277
    end
1278
end
1279

1280
local function url_for_httpd(httpd, name, args, query)
1281

1282
    local idx = httpd.iroutes[ name ]
5✔
1283
    if idx ~= nil then
5✔
1284
        return httpd.routes[ idx ]:url_for(args, query)
4✔
1285
    end
1286

1287
    if string.match(name, '^/') == nil then
1✔
1288
        if string.match(name, '^https?://') ~= nil then
1✔
1289
            return name
×
1290
        else
1291
            return '/' .. name
1✔
1292
        end
1293
    else
1294
        return name
×
1295
    end
1296
end
1297

1298
local function httpd_start(self)
1299
    if type(self) ~= 'table' then
42✔
1300
        error("httpd: usage: httpd:start()")
×
1301
    end
1302

1303
    local server = self.tcp_server_f(self.host, self.port, {
84✔
1304
        name = 'http',
1305
        handler = function(...)
1306
            self.internal.preprocess_client_handler()
43✔
1307
            process_client(self, ...)
43✔
1308
            self.internal.postprocess_client_handler()
41✔
1309
        end,
1310
        http_server = self,
42✔
1311
    })
1312

1313
    if server == nil then
42✔
1314
        error(sprintf("Can't create tcp_server: %s", errno.strerror()))
×
1315
    end
1316

1317
    rawset(self, 'is_run', true)
42✔
1318
    rawset(self, 'tcp_server', server)
42✔
1319
    rawset(self, 'stop', httpd_stop)
42✔
1320

1321
    return self
42✔
1322
end
1323

1324
local exports = {
1✔
1325
    _VERSION = require('http.version'),
2✔
1326
    DETACHED = DETACHED,
1✔
1327

1328
    new = function(host, port, options)
1329
        if options == nil then
43✔
1330
            options = {}
3✔
1331
        end
1332
        if type(options) ~= 'table' then
43✔
1333
            errorf("options must be table not '%s'", type(options))
×
1334
        end
1335
        local disable_keepalive = options.disable_keepalive or {}
43✔
1336
        if type(disable_keepalive) ~= 'table' then
43✔
1337
            error('Option disable_keepalive must be a table.')
×
1338
        end
1339
        if options.idle_timeout ~= nil and
43✔
1340
           type(options.idle_timeout) ~= 'number' then
1✔
1341
            error('Option idle_timeout must be a number.')
×
1342
        end
1343

1344
        local default = {
43✔
1345
            max_header_size     = 4096,
1346
            header_timeout      = 100,
1347
            handler             = handler,
43✔
1348
            app_dir             = '.',
1349
            charset             = 'utf-8',
1350
            cache_templates     = true,
1351
            cache_controllers   = true,
1352
            cache_static        = true,
1353
            log_requests        = true,
1354
            log_errors          = true,
1355
            display_errors      = false,
1356
            disable_keepalive   = {},
43✔
1357
            idle_timeout        = 0, -- no timeout, option is disabled
1358
        }
1359

1360
        local self = {
43✔
1361
            host    = host,
43✔
1362
            port    = port,
43✔
1363
            is_run  = false,
1364
            stop    = httpd_stop,
43✔
1365
            start   = httpd_start,
43✔
1366
            options = extend(default, options, true),
86✔
1367

1368
            routes  = {  },
43✔
1369
            iroutes = {  },
43✔
1370
            helpers = {
43✔
1371
                url_for = url_for_helper,
43✔
1372
            },
43✔
1373
            hooks   = {  },
43✔
1374

1375
            -- methods
1376
            route   = add_route,
43✔
1377
            delete  = delete_route,
43✔
1378
            match   = match_route,
43✔
1379
            helper  = set_helper,
43✔
1380
            hook    = set_hook,
43✔
1381
            url_for = url_for_httpd,
43✔
1382

1383
            -- Exposed to make it replaceable by a user.
1384
            tcp_server_f = socket.tcp_server,
43✔
1385

1386
            -- caches
1387
            cache   = {
43✔
1388
                tpl         = {},
43✔
1389
                ctx         = {},
43✔
1390
                static      = {},
43✔
1391
            },
43✔
1392

1393
            disable_keepalive   = tomap(disable_keepalive),
86✔
1394
            idle_timeout        = options.idle_timeout,
43✔
1395

1396
            internal = {
43✔
1397
                preprocess_client_handler = function() end,
70✔
1398
                postprocess_client_handler = function() end,
82✔
1399
            }
43✔
1400
        }
1401

1402
        return self
43✔
1403
    end,
1404

1405
    internal = {
1✔
1406
        response_mt = response_mt,
1✔
1407
        request_mt = request_mt,
1✔
1408
        extend = extend,
1✔
1409
    }
1✔
1410
}
1411

1412
return exports
1✔
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