Initial commit

This commit is contained in:
Loic Nageleisen 2017-09-15 18:13:04 +02:00
commit d668a36f92
8 changed files with 368 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/Gemfile.lock
/pkg
/test/serve
.DS_Store

32
.rubocop.yml Normal file
View file

@ -0,0 +1,32 @@
AllCops:
TargetRubyVersion: 2.3
Style/ClassAndModuleChildren:
Enabled: false
Style/Documentation:
Enabled: false
Style/TrailingCommaInLiteral:
EnforcedStyleForMultiline: comma
Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: comma
Style/NumericPredicate:
Enabled: false
Style/ZeroLengthPredicate:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/MethodLength:
Enabled: false
Metrics/AbcSize:
Enabled: false
Metrics/CyclomaticComplexity:
Enabled: false

5
Gemfile Normal file
View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gemspec

29
LICENSE Normal file
View file

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2017, Loic Nageleisen
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

10
Rakefile Normal file
View file

@ -0,0 +1,10 @@
require 'rake/testtask'
require 'bundler'
Bundler::GemHelper.install_tasks
Rake::TestTask.new do |t|
t.libs << 'test'
t.test_files = FileList['test/test_*.rb']
t.verbose = true
end

212
lib/nanoserve.rb Normal file
View file

@ -0,0 +1,212 @@
# frozen_string_literal: true
require 'socket'
require 'logger'
require 'time'
module NanoServe
class TCPResponder
def initialize(host, port, &block)
@host = host
@port = port
@block = block
@thr = nil
end
def start(y)
server = TCPServer.new(@port)
@thr = Thread.new do
Thread.abort_on_exception = true
conn = server.accept
port, host = conn.peeraddr[1, 2]
client = "#{host}:#{port}"
logger.debug "#{client}: connected"
begin
@block.call(conn, y)
rescue EOFError
logger.debug "#{client}: disconnected"
ensure
logger.debug "#{client}: closed"
conn.close
end
end
return unless block_given?
yield
@thr.join
server.close
y
end
def stop
@thr.kill
end
def logger
@logger ||= Logger.new(STDOUT).tap { |l| l.level = Logger::INFO }
end
end
class HTTPResponder < TCPResponder
def initialize(host, port)
super(host, port) do |conn, y|
req = Request.new
buf = +''
loop do
line = conn.readline
break if line.chomp == ''
req << line
buf << line
end
logger.debug "request:\n" + buf.gsub(/^/, ' ')
res = Response.new
logger.debug 'calling'
yield(res, req, y)
logger.debug "response:\n" + res.to_s.gsub(/^/, ' ')
conn.write(res.to_s)
end
end
class RequestError < StandardError; end
class Request
def initialize
@method = nil
@uri = nil
@http_version = nil
@sep = nil
@headers = {}
end
def params
Hash[*@uri.query.split('&').map { |kv| kv.split('=') }.flatten]
end
def <<(line)
if @method.nil?
parse_request(line.chomp)
elsif @sep.nil?
parse_header(line.chomp)
else
@body << line
end
end
REQ_RE = %r{(?<method>[A-Z]+)\s+(?<path>\S+)\s+(?<version>HTTP/\d+.\d+)$}
def parse_request(str)
unless (m = str.match(REQ_RE))
raise RequestError, "cannot parse request: '#{str}'"
end
@method = parse_method(m[:method])
@uri = parse_path(m[:path])
@http_version = parse_version(m[:version])
end
def parse_method(str)
str
end
def parse_path(str)
URI(str)
end
def parse_version(str)
str
end
def parse_header(str)
(@sep = '' && return) if str == ''
unless (m = str.match(/(?<header>[A-Z][-A-Za-z]*):\s+(?<value>.+)$/))
raise RequestError, "cannot parse header: '#{str}'"
end
@headers[m[:header]] = m[:value]
end
end
class Response
def headers
{
'Date' => date,
'Content-Type' => content_type,
'Content-Length' => content_length,
'Last-Modified' => last_modified,
'Server' => server,
'ETag' => etag,
'Connection' => connection,
}
end
def body
@body ||= ''
end
def body=(value)
@body = value.tap { @content_length = body.bytes.count.to_s }
end
def to_s
(status_line + header_block + body_block).encode('ASCII-8BIT')
end
private
def status_code
200
end
def status_string
'OK'
end
def status_line
"HTTP/1.1 #{status_code} #{status_string}\r\n"
end
def header_block
headers.map { |k, v| [k, v].join(': ') }.join("\r\n") + "\r\n\r\n"
end
def body_block
body
end
def content_length
@content_length ||= body.bytes.count.to_s
end
def content_type
@content_type ||= 'text/html; charset=UTF-8'
end
def date
@date ||= Time.now.httpdate
end
def last_modified
@last_modified ||= date
end
def etag
@etag ||= SecureRandom.uuid
end
def server
'NanoServe'
end
def connection
'close'
end
end
end
end

21
nanoserve.gemspec Normal file
View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
Gem::Specification.new do |s|
s.name = 'nanoserve'
s.version = '0.1.0'
s.licenses = ['3BSD']
s.summary = 'Listen to one-shot connections'
s.authors = ['Loic Nageleisen']
s.email = 'loic.nageleisen@gmail.com'
s.files = Dir['lib/**/*.rb'] + Dir['bin/*']
s.files += Dir['[A-Z]*'] + Dir['test/**/*']
s.description = <<-EOT
NanoServe allows you to wait for an external call and act on it.
EOT
s.required_ruby_version = '>= 2.3'
s.add_development_dependency 'minitest', '~> 5.10'
s.add_development_dependency 'pry'
s.add_development_dependency 'rake', '~> 12.0'
end

55
test/test_nanoserve.rb Normal file
View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
require 'minitest/autorun'
require 'nanoserve'
require 'securerandom'
require 'uri'
require 'net/http'
require 'pry'
require 'pathname'
class TestNanoServe < MiniTest::Test
def test_tcp_responder
uuid = SecureRandom.uuid.encode('UTF-8')
uri = URI('tcp://localhost:2000')
r = NanoServe::TCPResponder.new(uri.host, uri.port) do |conn, buf|
buf << conn.readpartial(1024)
end
buf = r.start(+'') do
s = TCPSocket.new(uri.host, uri.port)
s.write(uuid)
s.close
end
assert_equal(uuid, buf)
end
def test_http_responder
uuid = SecureRandom.uuid.encode('UTF-8')
uri = URI('http://localhost:2000')
r = NanoServe::HTTPResponder.new(uri.host, uri.port) do |res, req, y|
y << req
res.body = <<-EOS.gsub(/^ {8}/, '')
<html>
<head>
<title>An Example Page</title>
</head>
<body>
Hello World, this is a very simple HTML document.
</body>
</html>
EOS
end
req = r.start([]) do
Net::HTTP.get(uri + "test?uuid=#{uuid}")
end
assert_equal(uuid, req.first.params['uuid'])
end
end