| 1 | // Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | module http |
| 5 | |
| 6 | import compress.gzip |
| 7 | import compress.zlib |
| 8 | import net.http.chunked |
| 9 | import strconv |
| 10 | import strings |
| 11 | |
| 12 | // Response represents the result of the request |
| 13 | pub struct Response { |
| 14 | pub mut: |
| 15 | body string |
| 16 | header Header |
| 17 | status_code int |
| 18 | status_msg string |
| 19 | http_version string |
| 20 | } |
| 21 | |
| 22 | fn (mut resp Response) free() { |
| 23 | unsafe { resp.header.free() } |
| 24 | } |
| 25 | |
| 26 | // Formats resp to bytes suitable for HTTP response transmission |
| 27 | pub fn (resp Response) bytes() []u8 { |
| 28 | mut sb := strings.new_builder(resp.response_buffer_cap()) |
| 29 | resp.write_into_builder(mut sb) |
| 30 | return unsafe { sb.reuse_as_plain_u8_array() } |
| 31 | } |
| 32 | |
| 33 | // Formats resp to a string suitable for HTTP response transmission |
| 34 | pub fn (resp Response) bytestr() string { |
| 35 | mut sb := strings.new_builder(resp.response_buffer_cap()) |
| 36 | resp.write_into_builder(mut sb) |
| 37 | res := sb.str() |
| 38 | unsafe { sb.free() } |
| 39 | return res |
| 40 | } |
| 41 | |
| 42 | fn (resp Response) response_buffer_cap() int { |
| 43 | return resp.body.len + 64 + resp.header.cur_pos * 48 |
| 44 | } |
| 45 | |
| 46 | fn (resp Response) write_into_builder(mut sb strings.Builder) { |
| 47 | sb.write_string('HTTP/') |
| 48 | sb.write_string(resp.http_version) |
| 49 | sb.write_u8(` `) |
| 50 | sb.write_decimal(resp.status_code) |
| 51 | sb.write_u8(` `) |
| 52 | sb.write_string(resp.status_msg) |
| 53 | sb.write_string('\r\n') |
| 54 | resp.header.render_into_sb(mut sb, |
| 55 | version: resp.version() |
| 56 | ) |
| 57 | sb.write_string('\r\n') |
| 58 | sb.write_string(resp.body) |
| 59 | } |
| 60 | |
| 61 | // Parse a raw HTTP response into a Response object |
| 62 | pub fn parse_response(resp string) !Response { |
| 63 | version, status_code, status_msg := parse_status_line(resp.all_before('\r\n'))! |
| 64 | // Build resp header map and separate the body |
| 65 | start_idx, end_idx := find_headers_range(resp)! |
| 66 | header := parse_headers(resp.substr(start_idx, end_idx))! |
| 67 | mut body := resp.substr(end_idx, resp.len) |
| 68 | if has_header_token(header.get(.transfer_encoding) or { '' }, 'chunked') { |
| 69 | body = chunked.decode(body)! |
| 70 | } |
| 71 | body = decode_response_body(body, header.get(.content_encoding) or { '' }) |
| 72 | return Response{ |
| 73 | http_version: version |
| 74 | status_code: status_code |
| 75 | status_msg: status_msg |
| 76 | header: header |
| 77 | body: body |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | fn has_header_token(header_value string, expected_token string) bool { |
| 82 | for token in parse_header_tokens(header_value) { |
| 83 | if token == expected_token.to_lower() { |
| 84 | return true |
| 85 | } |
| 86 | } |
| 87 | return false |
| 88 | } |
| 89 | |
| 90 | fn parse_header_tokens(header_value string) []string { |
| 91 | mut tokens := []string{} |
| 92 | for part in header_value.split(',') { |
| 93 | token := part.all_before(';').trim_space().to_lower() |
| 94 | if token != '' { |
| 95 | tokens << token |
| 96 | } |
| 97 | } |
| 98 | return tokens |
| 99 | } |
| 100 | |
| 101 | fn decode_response_body(body string, content_encoding string) string { |
| 102 | if body.len == 0 { |
| 103 | return body |
| 104 | } |
| 105 | encodings := parse_header_tokens(content_encoding) |
| 106 | if encodings.len == 0 { |
| 107 | return body |
| 108 | } |
| 109 | mut decoded := body.bytes() |
| 110 | for i := encodings.len - 1; i >= 0; i-- { |
| 111 | encoding := encodings[i] |
| 112 | decoded = match encoding { |
| 113 | 'gzip', 'x-gzip' { |
| 114 | gzip.decompress(decoded) or { return body } |
| 115 | } |
| 116 | 'deflate' { |
| 117 | zlib.decompress(decoded) or { return body } |
| 118 | } |
| 119 | 'identity' { |
| 120 | decoded |
| 121 | } |
| 122 | else { |
| 123 | return body |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | return decoded.bytestr() |
| 128 | } |
| 129 | |
| 130 | // parse_status_line parses the first HTTP response line into the HTTP |
| 131 | // version, status code, and reason phrase |
| 132 | fn parse_status_line(line string) !(string, int, string) { |
| 133 | if line.len < 5 || line[..5].to_lower() != 'http/' { |
| 134 | return error('response does not start with HTTP/, line: `${line}`') |
| 135 | } |
| 136 | data := line.split_nth(' ', 3) |
| 137 | if data.len != 3 { |
| 138 | return error('expected at least 3 tokens, but found: ${data.len}') |
| 139 | } |
| 140 | version := data[0].substr(5, data[0].len) |
| 141 | // validate version is 1*DIGIT "." 1*DIGIT |
| 142 | digits := version.split_nth('.', 3) |
| 143 | if digits.len != 2 { |
| 144 | return error('HTTP version malformed, found: `${digits}`') |
| 145 | } |
| 146 | for digit in digits { |
| 147 | strconv.atoi(digit) or { |
| 148 | return error('HTTP version must contain only integers, found: `${digit}`') |
| 149 | } |
| 150 | } |
| 151 | return version, strconv.atoi(data[1])!, data[2] |
| 152 | } |
| 153 | |
| 154 | // cookies parses the Set-Cookie headers into Cookie objects |
| 155 | pub fn (r Response) cookies() []Cookie { |
| 156 | mut cookies := []Cookie{} |
| 157 | for cookie in r.header.values(.set_cookie) { |
| 158 | cookies << parse_cookie(cookie) or { continue } |
| 159 | } |
| 160 | return cookies |
| 161 | } |
| 162 | |
| 163 | // status parses the status_code and returns a corresponding enum field of Status |
| 164 | pub fn (r Response) status() Status { |
| 165 | return status_from_int(r.status_code) |
| 166 | } |
| 167 | |
| 168 | // set_status sets the status_code and status_msg of the response |
| 169 | pub fn (mut r Response) set_status(s Status) { |
| 170 | r.status_code = s.int() |
| 171 | r.status_msg = s.str() |
| 172 | } |
| 173 | |
| 174 | // version parses the version |
| 175 | pub fn (r Response) version() Version { |
| 176 | return match r.http_version { |
| 177 | '1.0' { .v1_0 } |
| 178 | '1.1' { .v1_1 } |
| 179 | '2.0' { .v2_0 } |
| 180 | else { .unknown } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // set_version sets the http_version string of the response |
| 185 | pub fn (mut r Response) set_version(v Version) { |
| 186 | if v == .unknown { |
| 187 | r.http_version = '' |
| 188 | return |
| 189 | } |
| 190 | maj, min := v.protos() |
| 191 | r.http_version = '${maj}.${min}' |
| 192 | } |
| 193 | |
| 194 | pub struct ResponseConfig { |
| 195 | pub: |
| 196 | version Version = .v1_1 |
| 197 | status Status = .ok |
| 198 | header Header |
| 199 | body string |
| 200 | } |
| 201 | |
| 202 | // new_response creates a Response object from the configuration. This |
| 203 | // function will add a Content-Length header if body is not empty. |
| 204 | pub fn new_response(conf ResponseConfig) Response { |
| 205 | mut resp := Response{ |
| 206 | body: conf.body |
| 207 | header: conf.header |
| 208 | } |
| 209 | if resp.body != '' && !resp.header.contains(.content_length) { |
| 210 | resp.header.add(.content_length, resp.body.len.str()) |
| 211 | } |
| 212 | resp.set_status(conf.status) |
| 213 | resp.set_version(conf.version) |
| 214 | return resp |
| 215 | } |
| 216 | |
| 217 | // find_headers_range returns the start (inclusive) and end (exclusive) |
| 218 | // index of the headers in the string, including the trailing newlines. This |
| 219 | // helper function expects the first line in `data` to be the HTTP status line |
| 220 | // (HTTP/1.1 200 OK). |
| 221 | fn find_headers_range(data string) !(int, int) { |
| 222 | start_idx := data.index('\n') or { return error('no start index found') } + 1 |
| 223 | mut count := 0 |
| 224 | for i := start_idx; i < data.len; i++ { |
| 225 | if data[i] == `\n` { |
| 226 | count++ |
| 227 | } else if data[i] != `\r` { |
| 228 | count = 0 |
| 229 | } |
| 230 | if count == 2 { |
| 231 | return start_idx, i + 1 |
| 232 | } |
| 233 | } |
| 234 | return error('no end index found') |
| 235 | } |
| 236 | |