├── .gitignore ├── README.md ├── samples └── hello_world.cr ├── spec ├── all_spec.cr ├── frank_handler_spec.cr ├── route_spec.cr └── spec_helper.cr └── src ├── frank.cr └── frank ├── config.cr ├── context.cr ├── dsl.cr ├── handler.cr ├── request.cr ├── response.cr └── route.cr /.gitignore: -------------------------------------------------------------------------------- 1 | .crystal 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # frank 2 | 3 | > [!IMPORTANT] 4 | > This library is no longer supported or updated by Manas.Tech, 5 | > therefore we have archived the repository. 6 | > 7 | > The contents are still available readonly and continue to work as a 8 | > [shards](https://github.com/crystal-lang/shards/) dependency. 9 | > 10 | > For a maintained version of a microframework inspired in sinatra, check 11 | > out [Kemal](https://github.com/sdogruyol/kemal). 12 | > 13 | > If you wish to continue development yourself, we recommend you fork it. 14 | > We can also arrange to transfer ownership. 15 | > 16 | > If you have further questions, please reach out on https://forum.crystal-lang.org 17 | > or crystal@manas.tech 18 | 19 | This is a proof-of-concept [Sinatra](http://www.sinatrarb.com/) clone for [Crystal](http://www.crystal-lang.org). 20 | 21 | ## Status 22 | 23 | Basic `get`, `put`, `post` and `head` routes can be matched, and request parameters can be obtained. 24 | We are still missing a lot! 25 | -------------------------------------------------------------------------------- /samples/hello_world.cr: -------------------------------------------------------------------------------- 1 | require "../src/frank" 2 | 3 | get "/" do 4 | "Hello World!" 5 | end 6 | -------------------------------------------------------------------------------- /spec/all_spec.cr: -------------------------------------------------------------------------------- 1 | require "./*" 2 | -------------------------------------------------------------------------------- /spec/frank_handler_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe "Frank::Handler" do 4 | it "routes" do 5 | frank = Frank::Handler.new 6 | frank.add_route "GET", "/" do 7 | "hello" 8 | end 9 | request = HTTP::Request.new("GET", "/") 10 | response = frank.call(request) 11 | response.body.should eq("hello") 12 | end 13 | 14 | it "routes request with query string" do 15 | frank = Frank::Handler.new 16 | frank.add_route "GET", "/" do |ctx| 17 | "hello #{ctx.params["message"]}" 18 | end 19 | request = HTTP::Request.new("GET", "/?message=world") 20 | response = frank.call(request) 21 | response.body.should eq("hello world") 22 | end 23 | 24 | it "route parameter has more precedence than query string arguments" do 25 | frank = Frank::Handler.new 26 | frank.add_route "GET", "/:message" do |ctx| 27 | "hello #{ctx.params["message"]}" 28 | end 29 | request = HTTP::Request.new("GET", "/world?message=coco") 30 | response = frank.call(request) 31 | response.body.should eq("hello world") 32 | end 33 | 34 | it "sets content type" do 35 | frank = Frank::Handler.new 36 | frank.add_route "GET", "/" do |env| 37 | env.response.content_type = "application/json" 38 | end 39 | request = HTTP::Request.new("GET", "/") 40 | response = frank.call(request) 41 | response.headers["Content-Type"].should eq("application/json") 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/route_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe "Route" do 4 | describe "match" do 5 | it "doesn't match because of route" do 6 | route = Route.new("GET", "/foo/bar") { "" } 7 | route.match("GET", "/foo/baz".split("/")).should be_nil 8 | end 9 | 10 | it "doesn't match because of method" do 11 | route = Route.new("GET", "/foo/bar") { "" } 12 | route.match("POST", "/foo/bar".split("/")).should be_nil 13 | end 14 | 15 | it "matches" do 16 | route = Route.new("GET", "/foo/:one/path/:two") { "" } 17 | params = route.match("GET", "/foo/uno/path/dos".split("/")) 18 | params.should eq({"one" => "uno", "two" => "dos"}) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/frank/*" 3 | 4 | include Frank 5 | -------------------------------------------------------------------------------- /src/frank.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | require "./frank/*" 3 | 4 | at_exit do 5 | OptionParser.parse! do |opts| 6 | opts.on("-p ", "--port ", "port") do |opt_port| 7 | Frank.config.port = opt_port.to_i 8 | end 9 | end 10 | 11 | config = Frank.config 12 | handlers = [] of HTTP::Handler 13 | handlers << HTTP::LogHandler.new 14 | handlers << Frank::Handler::INSTANCE 15 | handlers << HTTP::StaticFileHandler.new("./public") 16 | server = HTTP::Server.new(config.port, handlers) 17 | 18 | server.ssl = config.ssl 19 | 20 | puts "Listening on #{config.scheme}://0.0.0.0:#{config.port}" 21 | server.listen 22 | end 23 | -------------------------------------------------------------------------------- /src/frank/config.cr: -------------------------------------------------------------------------------- 1 | module Frank 2 | class Config 3 | INSTANCE = Config.new 4 | property ssl 5 | property port 6 | 7 | def initialize 8 | @port = 3000 9 | end 10 | 11 | def scheme 12 | ssl ? "https" : "http" 13 | end 14 | end 15 | 16 | def self.config 17 | yield Config::INSTANCE 18 | end 19 | 20 | def self.config 21 | Config::INSTANCE 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /src/frank/context.cr: -------------------------------------------------------------------------------- 1 | class Frank::Context 2 | getter request 3 | 4 | def initialize(@request) 5 | end 6 | 7 | def response 8 | @response ||= Response.new 9 | end 10 | 11 | def response? 12 | @response 13 | end 14 | 15 | def params 16 | request.params 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/frank/dsl.cr: -------------------------------------------------------------------------------- 1 | def get(path, &block : Frank::Context -> _) 2 | Frank::Handler::INSTANCE.add_route("GET", path, &block) 3 | end 4 | 5 | def post(path, &block : Frank::Context -> _) 6 | Frank::Handler::INSTANCE.add_route("POST", path, &block) 7 | end 8 | -------------------------------------------------------------------------------- /src/frank/handler.cr: -------------------------------------------------------------------------------- 1 | require "http/server" 2 | require "cgi" 3 | 4 | class Frank::Handler < HTTP::Handler 5 | INSTANCE = new 6 | 7 | def initialize 8 | @routes = [] of Route 9 | end 10 | 11 | def call(request) 12 | response = exec_request(request) 13 | response || call_next(request) 14 | end 15 | 16 | def add_route(method, path, &handler : Frank::Context -> _) 17 | @routes << Route.new(method, path, &handler) 18 | end 19 | 20 | def exec_request(request) 21 | uri = request.uri 22 | components = uri.path.not_nil!.split "/" 23 | @routes.each do |route| 24 | params = route.match(request.method, components) 25 | if params 26 | if query = uri.query 27 | CGI.parse(query) do |key, value| 28 | params[key] ||= value 29 | end 30 | end 31 | 32 | frank_request = Request.new(request, params) 33 | context = Context.new(frank_request) 34 | begin 35 | body = route.handler.call(context).to_s 36 | content_type = context.response?.try(&.content_type) || "text/plain" 37 | return HTTP::Response.ok(content_type, body) 38 | rescue ex 39 | return HTTP::Response.error("text/plain", ex.to_s) 40 | end 41 | end 42 | end 43 | nil 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /src/frank/request.cr: -------------------------------------------------------------------------------- 1 | class Frank::Request 2 | getter params 3 | 4 | def initialize(@request, @params) 5 | end 6 | 7 | delegate body, @request 8 | end 9 | -------------------------------------------------------------------------------- /src/frank/response.cr: -------------------------------------------------------------------------------- 1 | class Frank::Response 2 | property content_type 3 | end 4 | -------------------------------------------------------------------------------- /src/frank/route.cr: -------------------------------------------------------------------------------- 1 | class Frank::Route 2 | getter handler 3 | 4 | def initialize(@method, path, &@handler : Frank::Context -> _) 5 | @components = path.split "/" 6 | end 7 | 8 | def match(method, components) 9 | return nil unless method == @method 10 | return nil unless components.length == @components.length 11 | 12 | params = nil 13 | 14 | @components.zip(components) do |route_component, req_component| 15 | if route_component.starts_with? ':' 16 | params ||= {} of String => String 17 | params[route_component[1 .. -1]] = req_component 18 | else 19 | return nil unless route_component == req_component 20 | end 21 | end 22 | 23 | params ||= {} of String => String 24 | params 25 | end 26 | end 27 | --------------------------------------------------------------------------------