Class: Syntropy::HTTP::ServerConnection

Inherits:
Object
  • Object
show all
Defined in:
lib/syntropy/http/server_connection.rb

Overview

Implements an HTTP/1.1 connection received by the Syntropy server. This implementation rejects incoming HTTP/0.9 or HTTP/1.0 requests. The response body is sent exclusively using chunked transfer encoding. Request bodies are accepted using either fixed length (Content-Length header) or chunked transfer encoding.

Constant Summary collapse

"; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Max-Age=0; HttpOnly"
SEND_FLAGS =
UM::MSG_NOSIGNAL | UM::MSG_WAITALL
EMPTY_CHUNK =
"0\r\n\r\n"
EMPTY_CHUNK_LEN =
EMPTY_CHUNK.bytesize
CHUNKED_ENCODING_POSTLUDE =
"\r\n#{EMPTY_CHUNK}"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(machine, fd, env, io_mode: :socket, &app) ⇒ void

Initializes a server connection.

Parameters:

  • machine (UringMachine)

    machine instance

  • fd (Integer)

    file descriptor

  • env (Hash)

    app environment

  • io_mode (Symbol) (defaults to: :socket)

    IO mode

  • app (Proc, Syntropy::App)

    server app



24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/syntropy/http/server_connection.rb', line 24

def initialize(machine, fd, env, io_mode: :socket, &app)
  @machine = machine
  @fd = fd
  @env = env
  @logger = env[:logger]
  @io = machine.io(fd, io_mode)
  @app = app

  @done = nil
  @response_headers = nil
  @response_cookies = nil
end

Instance Attribute Details

#fdObject (readonly)

Returns the value of attribute fd.



14
15
16
# File 'lib/syntropy/http/server_connection.rb', line 14

def fd
  @fd
end

#loggerObject (readonly)

Returns the value of attribute logger.



14
15
16
# File 'lib/syntropy/http/server_connection.rb', line 14

def logger
  @logger
end

#response_headersObject (readonly)

Returns the value of attribute response_headers.



14
15
16
# File 'lib/syntropy/http/server_connection.rb', line 14

def response_headers
  @response_headers
end

Instance Method Details

#closevoid

This method returns an undefined value.

Closes the connection.



285
286
287
288
289
290
291
# File 'lib/syntropy/http/server_connection.rb', line 285

def close
  return if @closed

  @closed = true
  @machine.shutdown(@fd, UM::SHUT_WR)
  @machine.close_async(@fd)
end

#complete?(req) ⇒ bool

Returns true if the request is done

Returns:

  • (bool)

    whether the request is complete



147
148
149
# File 'lib/syntropy/http/server_connection.rb', line 147

def complete?(req)
  req.headers[':body-done-reading']
end

#finish(request) ⇒ void

This method returns an undefined value.

Finishes the response to the current request. If no headers were sent, default headers are sent using #send_headers.



237
238
239
240
241
242
243
# File 'lib/syntropy/http/server_connection.rb', line 237

def finish(request)
  @machine.send(@fd, EMPTY_CHUNK, EMPTY_CHUNK_LEN, SEND_FLAGS)
  return if @done

  @logger&.info(request, request, response_headers: @response_headers)
  @done = true
end

#get_body(req) ⇒ String?

Reads the request body.

Parameters:

Returns:

  • (String, nil)

    request body



122
123
124
125
126
127
128
129
# File 'lib/syntropy/http/server_connection.rb', line 122

def get_body(req)
  headers = req.headers
  return nil if headers[':body-done-reading']

  body = @io.http_read_body(headers)
  headers[':body-done-reading'] = true if body
  body
end

#get_body_chunk(req) ⇒ String?

Reads a request body chunk.

Parameters:

Returns:

  • (String, nil)

    request body chunk



135
136
137
138
139
140
141
142
# File 'lib/syntropy/http/server_connection.rb', line 135

def get_body_chunk(req)
  headers = req.headers
  return nil if headers[':body-done-reading']

  chunk = @io.http_read_body_chunk(headers)
  headers[':body-done-reading'] = true if !chunk
  chunk
end

#handle_error(request, err) ⇒ void

This method returns an undefined value.

Handles an error encountered while serving a request by logging the error and optionally sending an error response with the relevant HTTP status code. For I/O errors, no response is sent.

Parameters:



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/syntropy/http/server_connection.rb', line 92

def handle_error(request, err)
  case err
  when Errno::ECONNRESET
    # ignore
  when SystemCallError
    log_error(err, 'Syscall error')
  when ProtocolError
    log_error(err, err.message)
    respond(request, err.message, ':status' => err.http_status)
  else
    log_error(err, 'Internal error')
    return if !request || @done

    respond(request, 'Internal server error', ':status' => INTERNAL_SERVER_ERROR)
  end
end

#log_error(err, message) ⇒ void

This method returns an undefined value.

Logs the given err and given message.

Parameters:

  • err (Exception)

    error

  • message (String)

    error message



114
115
116
# File 'lib/syntropy/http/server_connection.rb', line 114

def log_error(err, message)
  @logger&.error(message: "#{message}, closing connection", error: err)
end

#respond(request, body, headers) ⇒ Object

Sends response including headers and body. Waits for the request to complete if not yet completed. The body is sent using chunked transfer encoding.

Parameters:

  • request (Syntropy::Request)

    HTTP request

  • body (String)

    response body

  • headers


186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/syntropy/http/server_connection.rb', line 186

def respond(request, body, headers)
  add_set_cookie_headers if @response_cookies
  headers = @response_headers.merge(headers) if @response_headers

  formatted_headers = format_headers(headers, body)
  @response_headers = headers
  if body
    chunk_prelude = "#{body.bytesize.to_s(16)}\r\n"
    @machine.sendv(@fd, formatted_headers, chunk_prelude, body, CHUNKED_ENCODING_POSTLUDE)
  else
    @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
  end
  @logger&.info(request: request, response_headers: headers) if request
  @done = true
end

#respond_with_static_file(req, path, env, cache_headers) ⇒ void

This method returns an undefined value.

Responds by rendering a static file.

Parameters:

  • req (Syntropy::Request)

    request

  • path (String)

    file path

  • env (Hash)

    app environment

  • cache_headers (Hash)

    cache headers



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/syntropy/http/server_connection.rb', line 252

def respond_with_static_file(req, path, env, cache_headers)
  fd = @machine.open(path, UM::O_RDONLY)
  env ||= {}
  if env[:headers]
    env[:headers].merge!(cache_headers)
  else
    env[:headers] = cache_headers
  end

  maxlen = env[:max_len] || 65_536
  buf = String.new(capacity: maxlen)
  headers_sent = nil
  loop do
    res = @machine.read(fd, buf, maxlen, 0)
    if res < maxlen && !headers_sent
      return respond(req, buf, env[:headers])
    elsif res == 0
      return finish(req)
    end

    if !headers_sent
      send_headers(req, env[:headers])
      headers_sent = true
    end
    done = res < maxlen
    send_chunk(req, buf, done: done)
    return if done
  end
end

#runvoid

This method returns an undefined value.

Runs the connection.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/syntropy/http/server_connection.rb', line 40

def run
  loop do
    persist = serve_request
    break if !persist
  end
rescue UM::Terminate
  # server is terminated, do nothing
rescue SystemCallError
  @logger&.error(
    message:  'IO Error, closing...'
  )
rescue StandardError => e
  @logger&.error(
    message:  'Uncaught error while running connection',
    error:    e
  )
ensure
  @io.clear
  @machine.close_async(@fd)
end

#send_chunk(request, chunk, done: false) ⇒ void

This method returns an undefined value.

Sends a response body chunk. If no headers were sent, default headers are sent using #send_headers. if the done option is true(thy), an empty chunk will be sent to signal response completion to the client.

Parameters:

  • request (Syntropy::Request)

    HTTP request

  • chunk (String)

    response body chunk

  • done (boolean) (defaults to: false)

    whether the response is completed



221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/syntropy/http/server_connection.rb', line 221

def send_chunk(request, chunk, done: false)
  data = +''
  data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk
  data << EMPTY_CHUNK if done
  return if data.empty?

  @machine.send(@fd, data, data.bytesize, SEND_FLAGS)
  return if @done || !done

  @logger&.info(request: request, response_headers: @response_headers)
  @done = true
end

#send_headers(request, headers, empty_response: false) ⇒ void

This method returns an undefined value.

Sends response headers. If empty_response is truthy, the response status code will default to 204, otherwise to 200.

Parameters:

  • request (Syntropy::Request)

    HTTP request

  • headers (Hash)

    response headers

  • empty_response (boolean) (defaults to: false)

    whether a response body will be sent



208
209
210
211
212
# File 'lib/syntropy/http/server_connection.rb', line 208

def send_headers(request, headers, empty_response: false)
  formatted_headers = format_headers(headers, !empty_response)
  @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
  @response_headers = headers
end

#serve_requestObject

Processes an incoming request by parsing the headers, creating a request object and handing it off to the app handler. Returns true if the connection should be persisted.



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/syntropy/http/server_connection.rb', line 64

def serve_request
  @done = nil
  @response_headers = nil
  @response_cookies = nil
  @closed = nil
  headers = @io.http_read_request_headers
  return false if !headers

  request = Syntropy::Request.new(headers, self)

  @app.call(request)
  persist = persist_connection?(headers)
  if persist && !headers[':body-done-reading'] && (headers['content-length'] || headers['transfer-encoding'])
    get_body(request)
  end
  persist
rescue StandardError => e
  handle_error(request, e)
  false
end

This method returns an undefined value.

Adds a Set-Cookie header to the response headers.

Parameters:

  • key (String)

    cookie name

  • value (String)

    cookie value



170
171
172
# File 'lib/syntropy/http/server_connection.rb', line 170

def set_cookie(key, value)
  (@response_cookies ||= {})[key] = value || DELETE_COOKIE
end

#set_response_headers(headers) ⇒ void

This method returns an undefined value.

Sets response headers before sending any response. This method is used to add headers such as Set-Cookie or cache control headers to a response before actually responding, specifically in middleware hooks.

Parameters:

  • headers (Hash)

    response headers



159
160
161
# File 'lib/syntropy/http/server_connection.rb', line 159

def set_response_headers(headers)
  @response_headers ? @response_headers.merge!(headers) : @response_headers = headers
end

#with_stream {|@io, @fd| ... } ⇒ void

This method returns an undefined value.

Yields the uhnderlying connection IO and fd to the given block.

Yields:

  • (@io, @fd)


296
297
298
# File 'lib/syntropy/http/server_connection.rb', line 296

def with_stream
  yield @io, @fd
end