Skip to content
This repository has been archived by the owner on Jan 2, 2021. It is now read-only.

Adds reverse proxy middleware for proxying and load balancing #5

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions src/middleware/README.md

This file was deleted.

89 changes: 89 additions & 0 deletions src/raze/middleware/PROXY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Raze::Proxy

> *Proxies the request to another endpoint*

This allows Raze to proxy to external servers and also function as a load balancer.

```ruby
Raze::Proxy.new(
host : String | Array(String),
path : String,
lchop_proxy_path : String,
ignore_proxy_path : Bool,
headers : Hash(String, String),
timeout : Int32
)
```

### Arguments

**`host : String | Array(String)` - target(s) the the proxy should pass to.**

```ruby
all "/yeezy/*" Raze::Proxy.new(
host: "http://example.com"
)
#=> http://exmaple.com/yeezy/*
```

Raze will load balance (round robin) to mutlitple targets if `host` is an array. The following example will split traffic to `http://yeezy1.example.com` and `http://yeezy2.example.com`:

```ruby
all "/yeezy/*" Raze::Proxy.new(
host: ["http://yeezy1.example.com", "http://yeezy2.example.com"]
)

```

**`path : String` - appends a path to the proxy target**

```ruby
all "/yeezy/*" Raze::Proxy.new(
host: "http://example.com",
path: "/dank"
)
#=> http://example.com/dank/yeezy/*
```

**`lchop_proxy_path : String` - removes a substring from the beginning of the request path**

In the following example, if Raze is running on `http://localhost:7777`, then a request to `http://localhost:7777/yeezy/dank` will proxy to `http://example.com/dank`

```ruby
all "/yeezy/*" Raze::Proxy.new(
host: "http://example.com",
lchop_proxy_path: "/yeezy"
)
# http://localhost:7777/yeezy/dank -> http://example.com/dank
```

**`ignore_proxy_path : Bool` - ignores request path and will not pass it to the target**

In the following example, if Raze is running on `http://localhost:7777`, then a request to `http://localhost:7777/yeezy/dank` will proxy to `http://example.com/banana`

```ruby
all "/yeezy/*" Raze::Proxy.new(
host: "http://example.com",
path: "/banana",
ignore_proxy_path: true
)
# http://localhost:7777/yeezy/dank -> http://example.com/banana
```

**`headers : Hash(String, String)` - adds headers to the original request headers before proxying to the target**

```ruby
all "/yeezy/*" Raze::Proxy.new(
host: "http://example.com",
headers: {"X-Forwarded-By" => "Raze", "Proxy-Auth": "my-secret-key"}
)
```

**`timeout : Int32 | Nil` - number of seconds until timeout when trying to establish a connection and/or read the response body**

```ruby
all "/yeezy/*" Raze::Proxy.new(
host: "http://example.com",
timeout: 5
)
```
6 changes: 6 additions & 0 deletions src/raze/middleware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Raze Core Middlewares

- [Raze::Proxy](PROXY.md)
- Raze::BodyParser [*In Development*]
- Raze::StaticCach [*In Development*]
- Raze::SecureHeaders [*In Development*]
106 changes: 106 additions & 0 deletions src/raze/middleware/proxy.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
require "../handler"
require "uri"

# A middleware for proxying the request to another endpoint
class Raze::Proxy < Raze::Handler
@target_hosts = [] of String
@target_path = ""
@next_target_host_index = 0
@headers : Hash(String, String) | Nil
@timeout : Int32 | Nil

def initialize(host : String, path = "", @lchop_proxy_path = "", @ignore_proxy_path = false, @headers = nil, @timeout = nil)
@target_hosts << host
@target_path = path
if timeout = @timeout
timeout = nil
end
validate_props
end

def initialize(host : Array(String), path = "", @lchop_proxy_path = "", @ignore_proxy_path = false, @headers = nil, @timeout = nil)
raise "Proxy hosts array cannot be empty" if host.empty?
@target_hosts = host
@target_path = path
if timeout = @timeout
timeout = nil
end
validate_props
end

def call(ctx, done)
# get the request information
req_method = ctx.request.method
req_headers = ctx.request.headers
req_body = ctx.request.body
req_path = ctx.request.path

# set any headers that need to be set
if headers = @headers
headers.each do |k, v|
req_headers[k] = v
end
end

# TODO: if or when Crystal exposes the remote ip address, set the
# "X-Forwarded-For" header.
# https://github.com/crystal-lang/crystal/issues/453

client = HTTP::Client.new(URI.parse get_host)

# set timeout if specified
if timeout = @timeout
client.connect_timeout = timeout.seconds
client.read_timeout = timeout.seconds
end

begin
response = client.exec(
req_method, generate_path(req_path), req_headers, req_body
)
response.headers.each do |k, v|
ctx.response.headers[k] = v
end
ctx.response.status_code = response.status_code
ctx.response << response.body
ctx.response.close
rescue IO::Timeout
ctx.response.status_code = 408
end
end

# gets host amd updates the round-robin index
private def get_host
host = @target_hosts[@next_target_host_index]

if @target_hosts[@next_target_host_index + 1]?
@next_target_host_index = @next_target_host_index + 1
else
@next_target_host_index = 0
end

host
end

private def generate_path(req_path)
path = String.build do |io|
io << @target_path
unless @ignore_proxy_path
io << req_path.lchop @lchop_proxy_path
end
end
path.empty? ? "/" : path
end

private def validate_props
@target_hosts.each do |host|
uri = URI.parse host
path = uri.path
raise "Proxy hosts cannot contain a hash (##{uri.fragment})" if uri.fragment
raise "Proxy hosts cannot contain a query string (?#{uri.query})" if uri.query
if path && !path.empty?
raise "Proxy hosts cannot contain a path (#{uri.path}). Use the 'path' argument when initializing Raze::Proxy instead"
end
end
end
end