knod 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 28667421fdff9da10d052b49cc759c0510e685be
4
+ data.tar.gz: b9e50bfb52f5f594ab291edd520117959e55af87
5
+ SHA512:
6
+ metadata.gz: 411502ef0abac71bfdd536c4e94e38e28ca25324b1f42fa8cf1ef988480e4787b482e6ee38bbecef713bd7b161b067c14d2165c5e5ad0f91e71da8f640a5dd9d
7
+ data.tar.gz: b881af2bbe11b777bf3661a6560f77fecfc9a01e36304cc962045dec56b84f4e0d1b9a3902ea9d080c9650fd5c9bd0cbd79368e09a0928ca8f12fa207d23ac73
@@ -0,0 +1,4 @@
1
+ .DS_Store
2
+ /items
3
+ index.html
4
+ *.gem
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.0
4
+ - 2.1.2
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -0,0 +1,16 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ knod (0.4.3)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ rake (10.3.1)
10
+
11
+ PLATFORMS
12
+ ruby
13
+
14
+ DEPENDENCIES
15
+ knod!
16
+ rake (~> 10)
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Ryan Moser
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,39 @@
1
+ require 'rake/testtask'
2
+
3
+ Rake::TestTask.new do |t|
4
+ t.libs << 'test'
5
+ end
6
+
7
+ def gem_version
8
+ @version ||= Dir.glob("*.gem").sort.last
9
+ end
10
+
11
+ def report_error(task)
12
+ puts "There is no .gem file to #{task}"
13
+ end
14
+
15
+ desc 'Run tests'
16
+ task :default => :test
17
+
18
+ desc 'build gem'
19
+ task :build do
20
+ puts `gem build knod.gemspec`
21
+ end
22
+
23
+ desc 'Install a locally generated version of the gem'
24
+ task :install do |t|
25
+ if gem_version
26
+ puts `gem install ./#{gem_version}`
27
+ else
28
+ report_error(t.name)
29
+ end
30
+ end
31
+
32
+ desc 'Deploy the gem to Rubygems'
33
+ task :deploy do |t|
34
+ if gem_version
35
+ puts `gem push #{gem_version}`
36
+ else
37
+ report_error(t.name)
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ #Knod
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/knod.svg)](http://badge.fury.io/rb/knod) [![Build Status](https://travis-ci.org/moserrya/knod.svg?branch=master)](https://travis-ci.org/moserrya/knod) [![Code Climate](https://codeclimate.com/github/moserrya/knod.png)](https://codeclimate.com/github/moserrya/knod)
4
+
5
+ Knod is a lightweight HTTP server designed to facilitate front end development when the corresponding back end is missing or incomplete. It responds to GET, PUT, POST, PATCH, and DELETE, serving up, writing to, and deleting from the directory of your choosing. Knod has no dependencies outside of the Ruby standard library.
6
+
7
+ ## Installation
8
+
9
+ ```gem install knod```
10
+
11
+ ## Usage
12
+
13
+ The Knod gem comes with an executable; you can run it from the command line with `knod`. Knod will default to port 4444 and the current directory. You can change these with command line arguments (-p and -d, respectively).
14
+
15
+ You can also run it by requiring `knod` and calling `Knod.start`. Knod accepts an options hash that lets you change the port, root directory, and logging:
16
+
17
+ ```ruby
18
+ options = {port: 1234, root: './some/directory', logging: false}
19
+ Knod.start options
20
+ ```
21
+
22
+ Logging is enabled by default. The server will select an open ephemeral port at random if you pass in 0 as the port.
23
+
24
+ Knod sanitizes the path on all requests and does not allow access to folders outside of the root directory where it is run.
25
+
26
+ GET requests map suffixes into MIME types. Data is considered to be `application/octet-stream` if the content type is unrecognized.
27
+
28
+ All data from PUT, POST, and PATCH requests is stored as JSON. If the pathway specified in the request does not exist, Knod will create it.
29
+
30
+ POST requests auto-increment in the specified path and return the id of the file written as JSON (e.g if a POST request led to the server writing 56.json, the server would respond with `"{\"id\":56}"`.
31
+
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'knod'
4
+ require 'optparse'
5
+
6
+ options = {}
7
+ OptionParser.new do |opts|
8
+ cmd = File.basename($0)
9
+
10
+ opts.banner = "Usage: #{cmd} [options]"
11
+
12
+ opts.separator ''
13
+ opts.separator 'Specific options:'
14
+
15
+ opts.on('-p', '--port [PORT]', 'Set the port') do |p|
16
+ options[:port] = p
17
+ end
18
+
19
+ opts.on('-d', '--directory [DIR]', 'Set the root directory') do |dir|
20
+ options[:root] = dir
21
+ end
22
+
23
+ opts.on('--[no-]logging', "Use this flag to disable logging") do |logging|
24
+ options[:logging] = logging
25
+ end
26
+
27
+ opts.on('-v', '--version', 'Show version') do
28
+ puts "#{cmd} v#{Knod::VERSION}"
29
+ exit
30
+ end
31
+
32
+ end.parse!
33
+
34
+ trap("INT") { exit }
35
+ Knod.start(options)
@@ -0,0 +1,25 @@
1
+ #coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'knod/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = 'knod'
8
+ gem.version = Knod::VERSION
9
+ gem.date = '2014-05-27'
10
+ gem.authors = ['Ryan Moser']
11
+ gem.email = 'ryanpmoser@gmail.com'
12
+ gem.homepage = 'https://github.com/moserrya/knod'
13
+ gem.summary = 'A tiny RESTful http server'
14
+ gem.description = 'An http server built using Ruby\'s standard library'
15
+ gem.license = 'MIT'
16
+
17
+ gem.files = `git ls-files`.split($/)
18
+ gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
+ gem.require_paths = ['lib']
21
+
22
+ gem.required_ruby_version = '>= 1.9.3'
23
+
24
+ gem.add_development_dependency "rake", '~> 10'
25
+ end
@@ -0,0 +1,18 @@
1
+ require 'socket'
2
+ require 'uri'
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'knod/request'
6
+ require 'knod/server'
7
+ require 'knod/version'
8
+
9
+ module Knod
10
+ def self.start(options = {})
11
+ Server.new(options).start
12
+ end
13
+ end
14
+
15
+ if __FILE__ == $0
16
+ Knod.start
17
+ end
18
+
@@ -0,0 +1,42 @@
1
+ module Knod
2
+ class Request
3
+ attr_reader :socket, :headers, :request_line
4
+
5
+ def initialize(socket)
6
+ @socket = socket
7
+ @request_line = socket.gets
8
+ parse_request
9
+ end
10
+
11
+ def parse_request
12
+ headers = {}
13
+ loop do
14
+ line = socket.gets
15
+ break if line == "\r\n"
16
+ name, value = line.strip.split(": ")
17
+ headers[name] = value
18
+ end
19
+ @headers = headers
20
+ end
21
+
22
+ def content_length
23
+ headers["Content-Length"].to_i
24
+ end
25
+
26
+ def content_type
27
+ headers["Content-Type"]
28
+ end
29
+
30
+ def uri
31
+ @uri ||= request_line.split[1]
32
+ end
33
+
34
+ def method
35
+ @verb ||= request_line.split.first.upcase
36
+ end
37
+
38
+ def body
39
+ @body ||= socket.read(content_length)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,168 @@
1
+ module Knod
2
+ class Server
3
+ attr_reader :server, :socket, :request
4
+
5
+ DEFAULT_PORT = 4444
6
+ DEFAULT_WEB_ROOT = './'
7
+
8
+ def initialize(options={})
9
+ port = options.fetch(:port) { DEFAULT_PORT }
10
+ @root = options.fetch(:root) { DEFAULT_WEB_ROOT }
11
+ @logging = options.fetch(:logging) { true }
12
+ @server = TCPServer.new('0.0.0.0', port)
13
+ end
14
+
15
+ def start
16
+ log "Starting server on port #{port}"
17
+ loop do
18
+ Thread.start(server.accept) do |socket|
19
+ parse_request_and_respond(socket)
20
+ end
21
+ end
22
+ end
23
+
24
+ def parse_request_and_respond(socket)
25
+ @socket = socket
26
+ @request = Request.new(socket)
27
+ log request_line
28
+ public_send "do_#{request.method}"
29
+ rescue => e
30
+ log "#{e.class}: #{e.message}"
31
+ log e.backtrace
32
+ respond 500
33
+ ensure
34
+ socket.close if socket
35
+ end
36
+
37
+ def do_GET(head=false)
38
+ path = requested_path
39
+ path = File.join(path, 'index.html') if File.directory?(path)
40
+
41
+ if File.file?(path)
42
+ File.open(path, 'rb') do |file|
43
+ socket.print file_response_header(file)
44
+ IO.copy_stream(file, socket) unless head
45
+ end
46
+ else
47
+ message = head ? '' : "\"File not found\""
48
+ respond(404, message)
49
+ end
50
+ end
51
+
52
+ def do_HEAD
53
+ do_GET(head=true)
54
+ end
55
+
56
+ def do_DELETE
57
+ path = requested_path
58
+ File.delete(path) if File.file?(path)
59
+ respond 204
60
+ end
61
+
62
+ def do_PUT
63
+ write_to_path(requested_path) do |path|
64
+ File.write(path, request.body)
65
+ end
66
+ end
67
+
68
+ def do_POST
69
+ path = requested_path
70
+ FileUtils.mkdir_p(path)
71
+ records = Dir.glob(path + "/*.json")
72
+ next_id = (records.map {|r| File.basename(r, ".json") }.map(&:to_i).max || 0) + 1
73
+ File.write(File.join(path, "#{next_id}.json"), request.body)
74
+ respond(201, "{\"id\":#{next_id}}")
75
+ end
76
+
77
+ def port
78
+ server.addr[1]
79
+ end
80
+
81
+ private
82
+
83
+ def write_to_path(path)
84
+ directory = File.dirname(path)
85
+ FileUtils.mkdir_p(directory)
86
+ yield path
87
+ respond(200, "\"Success\"")
88
+ end
89
+
90
+ def log(message)
91
+ STDERR.puts message if @logging
92
+ end
93
+
94
+ def request_line
95
+ request.request_line
96
+ end
97
+
98
+ STATUS_CODE_MAPPINGS = {
99
+ 200 => "OK",
100
+ 201 => "Created",
101
+ 204 => "No Content",
102
+ 404 => "Not Found",
103
+ 500 => "Internal Server Error",
104
+ 501 => "Not Implemented"
105
+ }
106
+
107
+ def response_header(status_code, message)
108
+ header = "HTTP/1.1 #{status_code} #{STATUS_CODE_MAPPINGS.fetch(status_code)}\r\n"
109
+ header << "Content-Type: application/json\r\n" unless message.empty?
110
+ header << "Content-Length: #{message.size}\r\n"
111
+ header << "Connection: close\r\n\r\n"
112
+ end
113
+
114
+ def respond(status_code, message='')
115
+ socket.print response_header(status_code, message)
116
+ socket.print message unless message.empty?
117
+ end
118
+
119
+ def file_response_header(file)
120
+ "HTTP/1.1 200 OK\r\n" <<
121
+ "Content-Type: #{content_type(file)}\r\n" <<
122
+ "Content-Length: #{file.size}\r\n" <<
123
+ "Connection: close\r\n\r\n"
124
+ end
125
+
126
+ CONTENT_TYPE_MAPPING = {
127
+ 'json' => 'application/json',
128
+ 'bmp' => 'image/bmp',
129
+ 'gif' => 'image/gif',
130
+ 'jpg' => 'image/jpeg',
131
+ 'png' => 'image/png',
132
+ 'css' => 'text/css',
133
+ 'html' => 'text/html',
134
+ 'txt' => 'text/plain',
135
+ 'xml' => 'text/xml'
136
+ }
137
+
138
+ DEFAULT_CONTENT_TYPE = 'application/octet-stream'
139
+
140
+ def content_type(path)
141
+ ext = File.extname(path).split('.').last
142
+ CONTENT_TYPE_MAPPING[ext] || DEFAULT_CONTENT_TYPE
143
+ end
144
+
145
+ def requested_path
146
+ local_path = URI.unescape(URI(request.uri).path)
147
+
148
+ clean = []
149
+
150
+ parts = local_path.split("/")
151
+
152
+ parts.each do |part|
153
+ next if part.empty? || part == '.'
154
+ part == '..' ? clean.pop : clean << part
155
+ end
156
+
157
+ File.join(@root, *clean)
158
+ end
159
+
160
+ def method_missing(method_sym, *args, &block)
161
+ if method_sym.to_s.start_with?("do_")
162
+ respond(501, "\"not implemented\"")
163
+ else
164
+ super
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,3 @@
1
+ module Knod
2
+ VERSION = '0.4.3'
3
+ end
@@ -0,0 +1,53 @@
1
+ require 'net/http'
2
+ require 'json'
3
+
4
+ class Connection
5
+
6
+ def initialize(endpoint)
7
+ uri = URI.parse(endpoint)
8
+ @http = Net::HTTP.new(uri.host, uri.port)
9
+ end
10
+
11
+ VERB_MAP = {
12
+ get: Net::HTTP::Get,
13
+ post: Net::HTTP::Post,
14
+ put: Net::HTTP::Put,
15
+ patch: Net::HTTP::Patch,
16
+ delete: Net::HTTP::Delete,
17
+ head: Net::HTTP::Head,
18
+ options: Net::HTTP::Options
19
+ }
20
+
21
+ VERB_MAP.keys.each do |method|
22
+ define_method method, ->(path, params=nil) {request_json method, path, params}
23
+ end
24
+
25
+ private
26
+
27
+ def request_json(method, path, params)
28
+ response = request(method, path, params)
29
+ response.body = JSON.parse(response.body, symbolize_names: true)
30
+ response
31
+ rescue
32
+ response
33
+ end
34
+
35
+ def request(method, path, params = {})
36
+ case method
37
+ when :get, :head
38
+ full_path = encode_path_params(path, params)
39
+ request = VERB_MAP[method.to_sym].new(full_path)
40
+ else
41
+ request = VERB_MAP[method.to_sym].new(path)
42
+ request.body = params.to_json
43
+ end
44
+
45
+ @http.request(request)
46
+ end
47
+
48
+ def encode_path_params(path, params)
49
+ return path if params.nil?
50
+ encoded = URI.encode_www_form(params)
51
+ [path, encoded].join("?")
52
+ end
53
+ end
@@ -0,0 +1,158 @@
1
+ require 'knod'
2
+ require 'connection'
3
+ require 'minitest/autorun'
4
+
5
+ $knod = Knod::Server.new(port: 0, logging: false)
6
+ $port = $knod.port
7
+
8
+ Thread.new do
9
+ $knod.start
10
+ end
11
+
12
+ def parse_json_file(file)
13
+ JSON.parse(File.read(file), symbolize_names: true)
14
+ end
15
+
16
+ describe Knod, "a tiny http server" do
17
+ let(:connection) {Connection.new("http://0.0.0.0:#{$port}")}
18
+
19
+ describe 'non-writing methods' do
20
+ before do
21
+ @path = 'index.html'
22
+ @body = "<h1>Squids are fun!</h1>"
23
+ File.write(@path, @body)
24
+ end
25
+
26
+ after do
27
+ FileUtils.remove_entry(@path, true)
28
+ end
29
+
30
+ it 'responds with 200 when the route is valid' do
31
+ response = connection.get @path
32
+ response.code.must_equal '200'
33
+ end
34
+
35
+ it 'responds with the body of the requested file' do
36
+ response = connection.get @path
37
+ response.body.must_equal @body
38
+ end
39
+
40
+ it 'implictly serves up the index' do
41
+ response = connection.get "/"
42
+ response.body.must_equal @body
43
+ end
44
+
45
+ it 'returns a 404 if the file does not exist' do
46
+ response = connection.get "/squidbat.random"
47
+ response.code.must_equal '404'
48
+ end
49
+
50
+ it 'responds to HEAD requests without a body' do
51
+ response = connection.head @path
52
+ response.body.must_be_nil
53
+ end
54
+
55
+ it 'responds to unsupported methods with a 501' do
56
+ response = connection.options @path
57
+ response.code.must_equal '501'
58
+ end
59
+
60
+ it 'deletes files at the specified path' do
61
+ response = connection.delete @path
62
+ File.exists?(@path).must_equal false
63
+ end
64
+
65
+ it 'responds to delete requests with a 204' do
66
+ response = connection.delete @path
67
+ response.code.must_equal '204'
68
+ end
69
+ end
70
+
71
+ describe 'PUT' do
72
+ let(:directory) {'index'}
73
+ let(:path) {"#{directory}/81.json"}
74
+ let(:data) {{state: 'swell', predeliction: 'good challenges'}}
75
+
76
+ it 'returns a 200 on success' do
77
+ response = connection.put path, data
78
+ response.code.must_equal '200'
79
+ end
80
+
81
+ it 'writes to the local path' do
82
+ connection.put path, data
83
+ File.file?(path).must_equal true
84
+ end
85
+
86
+ it 'writes the data to the file as json' do
87
+ connection.put path, data
88
+ parse_json_file(path).must_equal data
89
+ end
90
+
91
+ after do
92
+ FileUtils.remove_entry(directory, true)
93
+ end
94
+ end
95
+
96
+ describe 'POST' do
97
+ let(:path) {'/items'}
98
+ let(:local_path) {File.join('.', path)}
99
+ let(:data) {{id: 81, state: 'swell', predeliction: 'good challenges'}}
100
+
101
+ before do
102
+ FileUtils.mkdir_p(local_path)
103
+ 2.times {|i| File.write(File.join(".", path, "#{i+1}.json"), {state: 'noodles'})}
104
+ end
105
+
106
+ after do
107
+ FileUtils.remove_entry(local_path, true)
108
+ end
109
+
110
+ it 'returns a 201 on success' do
111
+ response = connection.post path, data
112
+ response.code.must_equal '201'
113
+ end
114
+
115
+ it 'creates the required directory if it does not exist' do
116
+ FileUtils.remove_entry(local_path, true)
117
+ connection.post path, data
118
+ Dir.exists?(local_path).must_equal true
119
+ end
120
+
121
+ it 'writes to the correct path' do
122
+ connection.post path, data
123
+ File.file?(File.join(local_path, '3.json')).must_equal true
124
+ end
125
+
126
+ it 'responds with json' do
127
+ response = connection.post path, data
128
+ response.content_type.must_equal 'application/json'
129
+ end
130
+
131
+ it 'returns the id of the file created' do
132
+ response = connection.post path, data
133
+ response.body.must_equal ({id: 3})
134
+ end
135
+ end
136
+
137
+ describe 'error handling' do
138
+ before do
139
+ def $knod.do_HEAD
140
+ raise 'boom!'
141
+ end
142
+ end
143
+
144
+ after do
145
+ def $knod.do_HEAD
146
+ do_GET(head=true)
147
+ end
148
+ end
149
+
150
+ it 'responds to server errors with a 500' do
151
+ response = connection.head '/index.html'
152
+ response.code.must_equal '500'
153
+ end
154
+ end
155
+ end
156
+
157
+
158
+
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: knod
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.3
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Moser
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-05-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '10'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '10'
27
+ description: An http server built using Ruby's standard library
28
+ email: ryanpmoser@gmail.com
29
+ executables:
30
+ - knod
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".gitignore"
35
+ - ".travis.yml"
36
+ - Gemfile
37
+ - Gemfile.lock
38
+ - LICENSE.txt
39
+ - Rakefile
40
+ - Readme.md
41
+ - bin/knod
42
+ - knod.gemspec
43
+ - lib/knod.rb
44
+ - lib/knod/request.rb
45
+ - lib/knod/server.rb
46
+ - lib/knod/version.rb
47
+ - test/connection.rb
48
+ - test/test_knod.rb
49
+ homepage: https://github.com/moserrya/knod
50
+ licenses:
51
+ - MIT
52
+ metadata: {}
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 1.9.3
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubyforge_project:
69
+ rubygems_version: 2.2.2
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: A tiny RESTful http server
73
+ test_files:
74
+ - test/connection.rb
75
+ - test/test_knod.rb